You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(server): a background sync with no owner outlives the server that started it (GDK-270) (#26)
`handlePutSettings` ends by kicking a full sync when the mirrored scope
changes, and `startSyncJob` started it as `go s.runSyncJob(context.Background(),
…)`: no cancel, no WaitGroup, no handle. The comment above that line correctly
explained why the job cannot hang off the request context, and then handed its
lifetime to nobody.
The symptom CI reported was a test failing on a PR that changed one markdown
file: `TempDir RemoveAll cleanup: directory not empty`. The mirror is WAL, so a
connection that opens or closes there recreates -wal/-shm — and the detached
goroutine was still writing into a directory the test had already torn down.
It never reproduced on macOS by repetition, which is why it survived.
It is deterministic when you ask the right question. A goroutine that still
holds the pool is `database/sql` reporting `InUse > 0` after `Close`, and that
needs no race detector, no Linux and no repetition: the four scope-changing
settings PUTs fail in under a second. The four that fail are exactly the four
where `scopeChanged` is true; the siblings that do not change scope pass.
Structural: the server owns the lifetime. `newServer` builds a cancelable
`jobsCtx`, `startSyncJob` registers on a WaitGroup and refuses once the context
is cancelled, and `Handler.Shutdown`/`Close` cancel and wait within the same 3s
bound `cmd/gadak/serve.go` already uses for `http.Server`. Waiting for the
goroutine is not sufficient — `database/sql` rolls a cancelled Tx back from
`Tx.awaitDone`, which can still hold the connection — so the wait ends on the
pool going idle, which is the writer this bug is actually about.
Wired into every production path that closes a mirror, because a shutdown
method nobody calls is decoration: `cmd/gadak/serve.go`, `desktop/main.go`, and
`workspace.Registry.Close`, each ordered so the sync stops before the database
it writes to closes. `Entry.Handler` narrows from `http.Handler` to
`*server.Handler`: the entry owns that lifetime, and the old type hid it.
Recurrence: `quiesceFixtureDir` now asserts no connection is checked out once a
test is done, so this class fails in one second on any machine instead of
rarely on Linux; the existing "files came back" check stays, since it catches an
external writer a pool check cannot see. CI repeats `internal/server` and
`internal/workspace` under `-race`, not path-scoped.
FAIL-first, both halves lead-run. The fixture assertion on the unfixed tree:
four PUTs, `1 connection(s) still checked out after Close`, 0.66s. The
production wiring, with `Registry.Close` unwired: `1 connection(s) still checked
out after Registry.Close — the workspace's sync outlived its mirror`.
Debuggability: `store.PoolStats()` is what makes the assertion expressible, and
`Handler.SnapshotSync()` answers "what background work is running right now"
in-process, mirroring the existing GET /sync/progress/ rather than adding a
route.
Known and unchanged: the suite still attempts an outbound request to the
fixture's `x.atlassian.net`; the job is cancelled at teardown so it no longer
outlives the test, but removing the attempt is a separate change.
Co-authored-by: midagedev <midagedev@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
0 commit comments