Skip to content

panic: nil pointer dereference in sqlitex.Pool.Get after config reload — stale scenes hold reference to closed database #196

Description

@CheerChen

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:

  1. Start the instance, let it index, create scenes by browsing collections (via web UI or API).
  2. 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
    
  3. Request a region from any scene that was created before the reload:
    GET /api/scenes/{sceneId}/regions/1
    → HTTP 500
    
  4. 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 applyConfigoldSource.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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions