Version
v0.25.0 (ghcr.io/smilyorg/photofield:latest, image created 2026-08-17)
Related: #109 — same root cause (applyConfig closes old source without draining in-flight requests or clearing scene cache), different crash path.
Summary
After a config reload triggered by a filesystem watcher event (collection changed, reloading), all subsequent GET /api/scenes/{sceneId}/regions/{id} requests on scenes created before the reload panic with nil pointer dereference inside sqlitex.Pool.Get. The panic is recovered by chi's Recoverer middleware and returned as HTTP 500, but the condition is permanent — the old scenes remain in SceneSource's cache and keep referencing the closed *image.Source, whose Database.pool has been set to nil by Close(). The instance never recovers without a restart.
Stack trace
panic: runtime error: invalid memory address or nil pointer dereference
github.com/go-chi/chi/v5/middleware.Recoverer.func1.1
recoverer.go:30
panic
panic.go:860
zombiezen.com/go/sqlite/sqlitex.(*Pool).Take
pool.go:146
zombiezen.com/go/sqlite/sqlitex.(*Pool).Get
pool.go:126
photofield/internal/image.(*Database).GetPathFromId
internal/image/database.go:1001
photofield/internal/image.(*Source).GetImagePath
internal/image/source.go:363
photofield/internal/render.(*Photo).GetPath
internal/render/photo.go:32
photofield/internal/layout.PhotoRegionSource.getRegionFromPhoto
internal/layout/common.go:181
photofield/internal/layout.PhotoRegionSource.GetRegionById
internal/layout/common.go:334
photofield/internal/render.(*Scene).GetRegion
internal/render/scene.go:478
main.(*Api).GetScenesSceneIdRegionsId
main.go:1308
Reproduction
Setup: single Photofield instance with expand_subdirs: true in configuration.yaml, multiple subdirectories under the collection dir.
Steps:
- Start the instance, let it index, create scenes by browsing collections (via web UI or API).
- Trigger a config reload by adding, removing, or renaming a subdirectory under the collection dir. Container logs show:
collection changed, reloading
config path /app/data/configuration.yaml
database closed
- Request a region from any scene that was created before the reload:
GET /api/scenes/{sceneId}/regions/1
→ HTTP 500
- The panic repeats for every subsequent request to any pre-reload scene. New scenes created after the reload (via
POST /api/scenes) work correctly (200).
Observed in production: 16 panics over 3 days on a single instance, all from the same reload event. The time gap between reload and first panic was ~8 hours — the panic only fires when a client requests a region from a cached pre-reload scene.
Root cause analysis
1. applyConfig closes the old source immediately after swapping
main.go:1975-1979:
oldSource := imageSource
imageSource = image.NewSource(appConfig.Media, migrations, globalGeo)
if oldSource != nil {
oldSource.Close()
}
There is no grace period and no mechanism to wait for in-flight requests referencing oldSource to drain.
2. Database.Close() sets pool = nil
internal/image/database.go:217-218:
source.pool.Close()
source.pool = nil
3. GetPathFromId dereferences the nil pool
internal/image/database.go:1000-1001:
func (source *Database) GetPathFromId(id ImageId) (string, bool) {
conn := source.pool.Get(context.TODO()) // pool is nil → panic
4. Scene cache is not cleared on reload
applyConfig does not clear sceneSource's cache. Scenes created before the reload retain their RegionSource.Source pointer (internal/layout/common.go:110), which points to the now-closed *image.Source. SceneSource has no public method to clear all scenes, and there is no TTL — scenes are only evicted by LRU when total cache size exceeds 67 MB (internal/scene/sceneSource.go:52).
5. UpdateStaleness does not flag these scenes as stale
internal/render/scene.go:155-163:
func (scene *Scene) UpdateStaleness() {
for _, dep := range scene.Dependencies {
if dep.UpdatedAt().After(scene.CreatedAt) {
scene.Stale = true
return
}
}
scene.Stale = false
}
If the collection's UpdatedAt has not changed since the scene was created, the scene reports stale: false even though its underlying source is dead. Clients that check stale before reusing a scene cannot distinguish a healthy scene from one backed by a closed source.
Difference from #109
#109 reports panic: send on closed channel from Database.Write during indexing — the write path. This issue reports panic: nil pointer dereference from Database.GetPathFromId during region serving — the read path. Both originate from the same applyConfig → oldSource.Close() sequence, but crash at different call sites. Fixing #109 alone would not prevent this panic.
Environment
- Image:
ghcr.io/smilyorg/photofield:latest (v0.25.0, ea0c7c9)
- Host: Raspberry Pi (arm64), Docker
- Config:
expand_subdirs: true, layout WALL / FLEX
zombiezen.com/go/sqlite v1.4.2
Version
v0.25.0 (
ghcr.io/smilyorg/photofield:latest, image created 2026-08-17)Related: #109 — same root cause (
applyConfigcloses old source without draining in-flight requests or clearing scene cache), different crash path.Summary
After a config reload triggered by a filesystem watcher event (
collection changed, reloading), all subsequentGET /api/scenes/{sceneId}/regions/{id}requests on scenes created before the reload panic withnil pointer dereferenceinsidesqlitex.Pool.Get. The panic is recovered by chi'sRecoverermiddleware and returned as HTTP 500, but the condition is permanent — the old scenes remain inSceneSource's cache and keep referencing the closed*image.Source, whoseDatabase.poolhas been set tonilbyClose(). The instance never recovers without a restart.Stack trace
Reproduction
Setup: single Photofield instance with
expand_subdirs: trueinconfiguration.yaml, multiple subdirectories under the collection dir.Steps:
POST /api/scenes) work correctly (200).Observed in production: 16 panics over 3 days on a single instance, all from the same reload event. The time gap between reload and first panic was ~8 hours — the panic only fires when a client requests a region from a cached pre-reload scene.
Root cause analysis
1.
applyConfigcloses the old source immediately after swappingmain.go:1975-1979:There is no grace period and no mechanism to wait for in-flight requests referencing
oldSourceto drain.2.
Database.Close()setspool = nilinternal/image/database.go:217-218:3.
GetPathFromIddereferences the nil poolinternal/image/database.go:1000-1001:4. Scene cache is not cleared on reload
applyConfigdoes not clearsceneSource's cache. Scenes created before the reload retain theirRegionSource.Sourcepointer (internal/layout/common.go:110), which points to the now-closed*image.Source.SceneSourcehas no public method to clear all scenes, and there is no TTL — scenes are only evicted by LRU when total cache size exceeds 67 MB (internal/scene/sceneSource.go:52).5.
UpdateStalenessdoes not flag these scenes as staleinternal/render/scene.go:155-163:If the collection's
UpdatedAthas not changed since the scene was created, the scene reportsstale: falseeven though its underlying source is dead. Clients that checkstalebefore reusing a scene cannot distinguish a healthy scene from one backed by a closed source.Difference from #109
#109 reports
panic: send on closed channelfromDatabase.Writeduring indexing — the write path. This issue reportspanic: nil pointer dereferencefromDatabase.GetPathFromIdduring region serving — the read path. Both originate from the sameapplyConfig→oldSource.Close()sequence, but crash at different call sites. Fixing #109 alone would not prevent this panic.Environment
ghcr.io/smilyorg/photofield:latest(v0.25.0,ea0c7c9)expand_subdirs: true, layoutWALL/FLEXzombiezen.com/go/sqlite v1.4.2