Skip to content

fix(driver-sql): a refused SQLite connect() closes the connection it opened - #25126

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-25084-sqlite-refused-connect-release
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-25084-sqlite-refused-connect-release

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 SQLite connect(), the new releaseRefusedSqliteConnection() destroys the Knex pool and puts a fresh, empty one in its place (knex.destroy() then knex.initialize()). Then the ORIGINAL error is rethrown unchanged: the same object, so the same class, code, message and cause. A release that itself fails is logged at warn, 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-datasource is not touched.

Measured before the edit (at origin/main 03966ceae)

Every rejection path of connect(), and what is open at that point:

path rejects with pool / handle open when it rejects?
ensureDatabaseExists, SQLite: makeDatabaseDirectory fails SqliteDatabaseFileError (directory could not be made; a dangling link) No. It runs before any statement, so the pool has never opened a connection.
ensureDatabaseExists, pg / mysql: SELECT 1 fails with any code but database-missing the driver error No. The connection itself could not be made, so the pool holds none.
ensureDatabaseExists, pg / mysql: createDatabase() fails the driver error No. Its admin Knex is destroyed in its own finally, and the main pool holds no connection (its SELECT 1 failed).
applyAutoVacuumIncremental: first statement answers SQLITE_NOTADB / SQLITE_CORRUPT SqliteDatabaseFileError Yes. better-sqlite3 opened the file to answer, and the pool keeps that connection. For a header that says WAL, -shm and an empty -wal sit beside the file.
applyAutoVacuumIncremental: SQLITE_CANTOPEN SqliteDatabaseFileError No. The open itself failed, so no connection was created.
applySqliteJournalMode / verifyWalReadable never rejects: every failure is caught and logged —

So 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() and reconnect() build a new handle per attempt through factory.create. But createPrebuiltDriverFactory returns the SAME instance on every create(), so a host-adopted driver is connected again on the same object. The admin probe (datasource-admin-plugin.ts) builds a driver per test and disconnect()s it in finally, 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 second connect() 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's Client.destroy destroys the pool only when one is set, then sets it to undefined. So it is idempotent and does not throw on an already-destroyed pool. After this change, a caller's disconnect() on a refused instance destroys the fresh, empty pool: still a no-throw no-op.

Inheritance: SqliteWasmDriver and TursoDriver (local mode) call super.connect(), so they inherit the release. For wasm it is the same knex.destroy() the dev resolver's closeQuietly already performs on that rejection. Turso's own steps after super.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:

  • a damaged file whose header says WAL (byte 18 = 2), under NODE_ENV=production: the same SqliteDatabaseFileError (SQLITE_CORRUPT, the filename, cause.code SQLITE_CORRUPT, the cause's message inside), the directory holds archive.db alone (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);
  • reuse: a second connect() on the same refused instance gives the same refusal, and after the file is restored from a good copy, a third connect() resolves and the query answers [{ a: 7 }];
  • a release that fails (knex.destroy rejects once): the refusal is still the SqliteDatabaseFileError, and warn received the release failure;
  • CONTROLS: a healthy file connects, answers, and keeps its handle until disconnect(); a healthy WAL file connects, writes and reads, with its -shm / -wal held open beside it.

The #25019 pin's docblock (expectLeftAsItWas) said the refused connection keeps -shm / -wal until 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 == HEAD b4bd77749243, git diff HEAD empty)

  • A: the release call removed (anchor 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.
  • B: only this.knex.initialize() removed (1 → 0): 1 red / 7 green. The reuse pin fails with connect() resolved for a file the datasource cannot use, which is the hazard described above.

The test imports ./sql-driver.js from source, so neither leg needs a dist/ rebuild.

Verification (head 808e62f0d, merged with origin/main 03966ceae: already up to date)

Acceptance notes

  • SqlDriver.disconnect() followed by connect() 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 after disconnect() was NOT MEASURED, and it is left as is.

Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 2 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via SqlDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 03966ceae096f75c73c8fada48c828b86f86e02a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5f1bcccdb7cbf98f4830fc88d464576ba3befce3 — the merge of head 808e62f0df782808520bfa76b5e02213c9ff0d1d into base 03966ceae096f75c73c8fada48c828b86f86e02a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 03966ceae096f75c73c8fada48c828b86f86e02a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 808e62f0df
Local-runs: none

Scope: the net diff against the merge base (4 files, +286/−17):

Read with them:

① Derived judgments

  • The release is where the claim put it, and the error is untouched: right.

    • Any rejection of a SQLite connect() runs releaseRefusedSqliteConnection(), then throw error rethrows the same object: same class, code, message and cause.
    • The release is knex.destroy() then knex.initialize(). A release that throws is logged at warn and never replaces the refusal.
  • Scoping it to SQLite is faithful to the claim. The claim asked for a release "after the pool or a handle was opened".

    • The dev's census of rejection paths finds only one that holds a handle: the SQLITE_NOTADB / SQLITE_CORRUPT refusal from applyAutoVacuumIncremental, where better-sqlite3 opened the file to answer it.
    • A server dialect rejects only when no connection was made. SQLITE_CANTOPEN and the makeDatabaseDirectory refusal open nothing.
    • Destroying a server pool on a failed re-connect would buy nothing and could abort live queries.
  • The fresh pool is needed, not decoration. createPrebuiltDriverFactory returns the same instance on every create(), so a host-adopted driver really is connected again on the same object.

    • With destroy alone, a second connect() meets Knex's code-less "Unable to acquire a connection". applyAutoVacuumIncremental swallows that as a warning and resolves over a dead pool. Ablation B measures exactly that.
    • For a live shared instance, the release can only fire when its own pooled connection answers with a file refusal. That connection would give the same answer to every other caller anyway.
  • Inherited reach is safe.

    • SqliteWasmDriver.connect() calls super.connect() first, so its beforeExit flush is never registered on a refusal. Its connection's close() flushes only a dirty database, so the release writes nothing a disconnect() would not.
    • TursoDriver's remote mode never reaches super.connect(). Its local-mode steps after super.connect() never run on this path.
  • The dev's open note is dormant, and the seat measured it. disconnect() followed by connect() on one instance resolves over a dead pool. That predates this PR. No caller in the connection service does it:

    • disconnect() never closes a host-owned (adopted) instance;
    • reconnect() closes only the replaced pool, and only when it is not host-owned.

    It stays in the PR's acceptance notes and is not filed.

  • The pins have teeth.

    • Eight cases:
      • the WAL-header file under NODE_ENV=production: the same SQLITE_CORRUPT refusal, no -shm / -wal sibling, bytes unchanged, read before any disconnect();
      • NOTADB, CORRUPT and CANTOPEN: the open-descriptor count from /proc/self/fd;
      • reuse: the same refusal twice, then a working connection once the file is restored;
      • a release that fails;
      • two controls with a healthy file.
    • Ablation A (the release call removed): 5 red, both controls green. CANTOPEN stays green by construction, since it never held a handle.
    • Ablation B (only initialize() removed): the reuse pin alone goes red. Both legs restored blob-equal to HEAD.

② Semver level

③ Boundary flags

Implemented-by: claude/issue-25084-sqlite-refused-connect-release
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-21T16:51Z · ACCEPT

Head: 808e62f0df. It carries the release on connect()'s failure path, its pins and the changeset.

Card #25084 (p3, bug, area:devpath; filed by domain:engine seat 2 from #25019's dev report 6310958104): claim 6319787214, dispatch 6319866040. Dev report: 6320986048.

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:

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #25084 then closes the card.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 21, 2026 16:53
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 21, 2026 16:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 4e2c42f Oct 21, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25084-sqlite-refused-connect-release branch October 21, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants