Repository navigation
fix(driver-sql): a refused SQLite connect() closes the connection it opened - #25126
objectstack-fleet[bot] merged 2 commits into
Conversation
Claude-Session: https://claude.ai/code/session_01WQM1Hwm3eDA8xPhFuZGmZ6 Co-authored-by: Claude <noreply@anthropic.com>
…d connection's siblings Claude-Session: https://claude.ai/code/session_01WQM1Hwm3eDA8xPhFuZGmZ6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5f1bcccdb7cbf98f4830fc88d464576ba3befce3 && git checkout 5f1bcccdb7cbf98f4830fc88d464576ba3befce3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 03966ceae096f75c73c8fada48c828b86f86e02a 808e62f0df782808520bfa76b5e02213c9ff0d1d && git checkout -B drift-repro 03966ceae096f75c73c8fada48c828b86f86e02a && git merge --no-ff 808e62f0df782808520bfa76b5e02213c9ff0d1d
node scripts/docs-audit/affected-docs.mjs --json 03966ceae096f75c73c8fada48c828b86f86e02a
|
Contract reviewServed-tier: Scope: the net diff against the merge base (4 files, +286/−17):
Read with them:
① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | Draft PR → main. The body opens Fixes #25084. 4 files, +286/−17. Not governed: check-governed-merges matched 0 of 4 paths. |
| Scope | A SQLite connect() that rejects destroys its Knex pool and puts a fresh, empty one in its place before it rethrows the same error object. A refused WAL-header file leaves no -shm / -wal beside it, no descriptor stays open on a refused file, and the same instance connects once the file is repaired. |
| Seat's own read | The release is SQLite-only because only a SQLite file refusal holds a handle. A failing release is logged at warn and never replaces the refusal. The wasm and turso drivers inherit it safely. No connection-service path calls connect() after disconnect() on one instance, so the dev's dormant note stays unfiled. |
| Review | TIER_DEFAULT. Record 6321106643 is PASS on this head. |
| Declaration | One changeset, @objectstack/driver-sql patch, with Clause-②: no (resource release only: …) and Metadata-protocol: none at line starts, as on the PR body. |
| Cross-lane | None. service-datasource, driver-sqlite-wasm and driver-turso are read, not edited. service-datasource passed 930/930 against the rebuilt dist. |
| Docs drift | The bot's 9 listed pages name SqlDriver. None states what a refused connect() leaves open, so none is falsified. |
| Gates | Dev at 808e62f0d: 70 derived, 70 run, all exit 0 (two re-run after the full build). driver-sql passed 3720 tests, and the four SQLite file-refusal pin files 26/26. Ablation A (release removed): 5 red, controls green. Ablation B (initialize() removed): the reuse pin red. Both restored to HEAD. |
| CI | At this read on 808e62f0df: ci-failure GREEN, all 41 check-runs completed, none failed. git merge-tree against today's origin/main (fccbd9d0d5) is clean. |
Disposition:
open_questions: both answered A by the seat: SQLite-only scope, and destroy plus a fresh pool. Both sit inside the claim's fence and are measured by the ablations.deviations: all accepted:- the two scope points above;
- the non-discriminating
CANTOPENpin; - the fd pins limited to Linux;
- the driver-sql: two more SQLite file failures read connected on a declared datasource — SQLITE_CORRUPT (a truncated header) is swallowed at connect, and a dangling-symlink directory passes makeDatabaseDirectory; on a development boot both step down to wasm #25019 docblock edit;
- three pushes to the branch;
- the AGENTS.md commit trailers.
out_of_scope_findings:disconnect()thenconnect()on one instance resolves over a dead pool. It predates this PR and has no caller in the connection service, so it is noted in the PR, not filed.
Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #25084 then closes the card.
Generated by Claude Code
Fixes #25084
Clause-②: no (resource release only: no response, status, error, outcome or export moves)
Metadata-protocol: none
What changed
SqlDriver.connect()(packages/drivers/driver-sql/src/sql-driver.ts) now closes what a refused SQLite connect opened before it rejects. On any rejection of a SQLiteconnect(), the newreleaseRefusedSqliteConnection()destroys the Knex pool and puts a fresh, empty one in its place (knex.destroy()thenknex.initialize()). Then the ORIGINAL error is rethrown unchanged: the same object, so the same class, code, message andcause. A release that itself fails is logged atwarn, and the original error is still the one thrown.This is the driver seam the claim takes. Every caller benefits: the connection service's degraded path (which never disconnects a rejected handle), the development resolver, the admin test-connection probe, and a direct driver user. The caller seam in
service-datasourceis not touched.Measured before the edit (at
origin/main03966ceae)Every rejection path of
connect(), and what is open at that point:ensureDatabaseExists, SQLite:makeDatabaseDirectoryfailsSqliteDatabaseFileError(directory could not be made; a dangling link)ensureDatabaseExists, pg / mysql:SELECT 1fails with any code but database-missingensureDatabaseExists, pg / mysql:createDatabase()failsfinally, and the main pool holds no connection (itsSELECT 1failed).applyAutoVacuumIncremental: first statement answersSQLITE_NOTADB/SQLITE_CORRUPTSqliteDatabaseFileError-shmand an empty-walsit beside the file.applyAutoVacuumIncremental:SQLITE_CANTOPENSqliteDatabaseFileErrorapplySqliteJournalMode/verifyWalReadableSo the only rejection that holds a handle is a SQLite file refusal after the open. The release is scoped to SQLite (
if (this.isSqlite)): no server-dialect rejection holds a connection, and destroying a server pool on a re-connect of a live, host-adopted driver would abort queries in flight for nothing.Reuse after a rejection: yes, it happens. The connection service's
connect()andreconnect()build a new handle per attempt throughfactory.create. ButcreatePrebuiltDriverFactoryreturns the SAME instance on everycreate(), so a host-adopted driver is connected again on the same object. The admin probe (datasource-admin-plugin.ts) builds a driver per test anddisconnect()s it infinally, so it never leaked a handle. That answers the card's NOT MEASURED item. The development resolver closes its probe (closeQuietly). So the release must leave the instance reusable. Destroying alone would not: Knex then answers every statement with a code-less "Unable to acquire a connection", so a secondconnect()would log it as an auto_vacuum failure and RESOLVE over a datasource that cannot run one query. Ablation B below measures exactly that. Hence the fresh pool.disconnect()today:await this.knex.destroy(). Knex 3.3'sClient.destroydestroys the pool only when one is set, then sets it toundefined. So it is idempotent and does not throw on an already-destroyed pool. After this change, a caller'sdisconnect()on a refused instance destroys the fresh, empty pool: still a no-throw no-op.Inheritance:
SqliteWasmDriverandTursoDriver(local mode) callsuper.connect(), so they inherit the release. For wasm it is the sameknex.destroy()the dev resolver'scloseQuietlyalready performs on that rejection. Turso's own steps aftersuper.connect()never ran on that path. Neither package is edited.Pins (
sql-driver-25084-sqlite-refused-connect-release.test.ts)Read straight after the rejection, with no
disconnect()first:NODE_ENV=production: the sameSqliteDatabaseFileError(SQLITE_CORRUPT, the filename,cause.codeSQLITE_CORRUPT, the cause's message inside), the directory holdsarchive.dbalone (no-shm/-wal), and the bytes are unchanged;SQLITE_NOTADB,SQLITE_CORRUPT,SQLITE_CANTOPEN: the same refusal, and no descriptor open on the file, counted by the operating system (/proc/self/fd, Linux;describe.runIf);connect()on the same refused instance gives the same refusal, and after the file is restored from a good copy, a thirdconnect()resolves and the query answers[{ a: 7 }];knex.destroyrejects once): the refusal is still theSqliteDatabaseFileError, andwarnreceived the release failure;disconnect(); a healthy WAL file connects, writes and reads, with its-shm/-walheld open beside it.The #25019 pin's docblock (
expectLeftAsItWas) said the refused connection keeps-shm/-waluntil it is closed. That is no longer true, so the comment is updated. Its assertions are unchanged.Ablation (fix committed first;
scripts/ablation-replace.mjs, restore blob ==HEADb4bd77749243,git diff HEADempty)if (this.isSqlite) await this.releaseRefusedSqliteConnection();, 1 → 0): 5 red / 3 green. RED: the WAL sibling pin, NOTADB fd, CORRUPT fd, reuse, release-failure. GREEN: both CONTROLS, and the CANTOPEN pin. That pin is green either way, because a CANTOPEN open never held a handle (see the table). It stays as the "same refusal" pin.this.knex.initialize()removed (1 → 0): 1 red / 7 green. The reuse pin fails withconnect() resolved for a file the datasource cannot use, which is the hazard described above.The test imports
./sql-driver.jsfrom source, so neither leg needs adist/rebuild.Verification (head
808e62f0d, merged withorigin/main03966ceae: already up to date)pnpm --filter @objectstack/driver-sql typecheck: exit 0.tsc --listFilesincludes both edited test files.pnpm --filter @objectstack/driver-sql test: exit 0. 228 files passed and 12 skipped; 3720 tests passed and 210 skipped. The skips are the live pg / mysql cells; that package's reporter names them.pnpm --filter @objectstack/service-datasource test: exit 0. 53/53 files, 930/930 tests. It was run after building its closure, and the rebuiltdriver-sqldistcarriesreleaseRefusedSqliteConnection(2 hits).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 70 lines, run one by one, each exit code captured before any pipe. 68 exited 0 on the first run.check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET, nodist/) andcheck:type-check-debthit the runner's 300 s cap. Both exited 0 when re-run after the full build.--ranreconciliation: "70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED (a DERIVED zero — all 70 recorded an exit code and none of them is 3)". Full build (pnpm exec turbo run build --concurrency=2): exit 0.pnpm --filter @objectstack/core test:repo: exit 0, 6/6 files, 82/82 tests.Acceptance notes
SqlDriver.disconnect()followed byconnect()on the same instance resolves without a working pool. That is the same code-less "Unable to acquire a connection" this PR avoids on the refusal path. It predates this PR. Whether any caller connects an instance again afterdisconnect()was NOT MEASURED, and it is left as is.Generated by Claude Code