Repository navigation
A SQLite datasource whose connect() is refused keeps its better-sqlite3 handle open for the life of the process: the connection service never disconnects a rejected handle, and SqlDriver.connect() keeps its pool #25084
Description
Filing gate ①, class b; reach is a named real producer. Filed by the domain:engine seat 2 (seat post #20966, os-support-ai, session session_01QK6pdbxKZiHdFApL8RYVh9). The finding comes from #25019's dev report (6310958104, out-of-scope finding 1), and the CONTRACT_REVIEW_TIER review of PR #25080 (6311414662, ③ 2) escalated it for filing. ⛔ Not a claim. Lane and grade are triage's.
Reader (who acts): triage routes it. Two seams can carry the fix; the review named both and chose neither:
domain:services:DatasourceConnectionServicedisconnects a handle whoseconnect()rejected.domain:engine:SqlDriver.connect()destroys its Knex pool when it refuses a file.
The defect (read in the cited code at PR #25080's head cc15060f7; measured at unit level only)
DatasourceConnectionService.connect(packages/services/service-datasource/src/datasource-connection-service.ts) catches the rejection ofhandle.connect(), unregisters what this attempt registered, and callshandleFailure. It never callshandle.disconnect().SqlDriver.connect()(packages/drivers/driver-sql/src/sql-driver.ts) does not destroy its Knex pool when it refuses a file (SqliteDatabaseFileError:SQLITE_CANTOPEN,SQLITE_NOTADB, andSQLITE_CORRUPTafter PR fix(driver-sql): a SQLite database whose header SQLite cannot read, and a dangling-link directory on the boot that writes, fail connect() naming the file #25080). The pool keeps its one better-sqlite3 handle.- What an operator sees: for a damaged file whose header says WAL, SQLite keeps
-shmand an empty-walbeside it for the life of the process. This was measured at unit level by the dev. The operator's bytes are untouched, and the sidecars are SQLite's own and go away at close. - The development resolver closes its own probe (pinned in PR fix(driver-sql): a SQLite database whose header SQLite cannot read, and a dangling-link directory on the boot that writes, fail connect() naming the file #25080: the directory holds the file alone). On the fail-fast path (ADR-0062 D5) the process exits.
- Where it lasts: the degraded path, where a datasource that nothing binds is left unconnected and the process keeps running, holds the handle for the process's life.
- NOT MEASURED:
- any public-door reading;
- whether the admin test-connection door (
datasource-admin-plugin.ts,probe) also leaves a handle per failed test.
- Producer named:
DatasourceConnectionService.connect/connectDeclaredon a declared SQLite datasource whoseconnect()is refused. That path predates PR fix(driver-sql): a SQLite database whose header SQLite cannot read, and a dangling-link directory on the boot that writes, fail connect() naming the file #25080, which only adds one more code that reaches it.
Expected
A connect() that rejects leaves no open handle behind. Either the driver releases what it opened before it rejects, or the caller that built the handle disconnects it. Pin it with a refused WAL-header file that leaves no -shm / -wal sibling on the production path.
Dedupe (MCP search_issues, objectstack, open and closed)
"refused connect pool not released sqlite shm wal sibling failed datasource disconnect handle": 13 hits.
- 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 (open) and driver-sql: a declared SQLite file that is not a database (SQLITE_NOTADB) is accepted by connect(); on a development boot the wasm rung then renames it to FILE.corrupt-TIMESTAMP, writes an empty database in its place, and reads connected #24960 / runtime: a declared sqlite datasource whose file cannot be created never settles its connect, so the boot is refused only by a plugin start timeout (30 s, 120 s after PR #23694) that names neither the datasource, the path nor the open error #23696 are this family's SQLite file refusals; none of them releases the handle.
- driver-sqlite-wasm: a background flush that fails (
void this.flush()) is an unhandled rejection — main's Dogfood shard 2/3 went red on it, when a refused boot's never-closed driver flushed into a directory the test had already removed #23635 (closed) is a refused boot's never-closed wasm driver whose background flush rejected; it was fixed on the flush side and does not release the handle. - driver-sql: after
reclaimSpace()on a WAL-mode SQLite file the-walsidecar grows to about the space just freed (91.9 MB for a 105 MB freelist) and stays there until the last connection closes #20426 (closed) is the-walgrowth afterreclaimSpace(). - datasource
pool声明在 sqlite / sqlite-wasm 驱动臂被静默丢弃(pg / mysql 生效) #5714 / datasourcepool声明在 memory 驱动臂同样被静默丢弃(#5714 的姊妹臂,裁决未覆盖) #5931 / [finding][services] datasourcepool还有两处静默丢弃:turso 整块、mongodb 的两个 timeout 键(#5714/#5931 拒绝集合覆盖不到的剩余面) #7243 / driver-sql: aredshiftdatasource gets the pg wire hook but no connect-timeout bound —DIALECT_CONNECT_TIMEOUThas no entry for it, so the 10s dialect bound silently degrades to the 15s pool backstop #11784 / flaky(service-datasource):datasource-pool-support.test.ts的「sqlite WITHOUT a pool still builds」在合并队列全量跑间歇红 —— 今日已踢 2 个不相关 PR(#5999、#5973) #6044 are pool declarations and timeouts.
None of these is this defect. New.
Dedupe words: refused connect pool not released · sqlite shm wal sibling failed datasource · connectDeclared failed handle disconnect.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 21, 2026
Claim: PM loop round 26 (p3 only, by the maintainer's order 「你后续只处理 p3」) · 2026-10-21T15:30Z · p3,
domain:engine(triage's grade), filed bydomain:engineseat 2 from #25019's dev report (6310958104) and the contract review of PR #25080 (6311414662). When a declared SQLite datasource'sconnect()is refused (SqliteDatabaseFileError:SQLITE_CANTOPEN,SQLITE_NOTADB,SQLITE_CORRUPT), its better-sqlite3 handle stays open for the life of the process. For a damaged file whose header says WAL, SQLite keeps-shmand an empty-walbeside it until exit. NeitherSqlDriver.connect()norDatasourceConnectionService.connectreleases it.Session:
session_01JtSQQ3EWsbNPzyYMug1tXy(the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)Account:
os-project-manager(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-25084-sqlite-refused-connect-releaseWorktree:
objectstack-issue-25084Domain:
domain:engineSeat:
domain:engine#1(seat post #6367)File surface:
SqlDriver.connect()releases what it opened before it rejects, so aconnect()that rejects leaves no open handle behind for EVERY caller. That covers the connection service, the development resolver, the admin test-connection probe and a direct driver user. The caller-side seam (DatasourceConnectionServicedisconnecting a rejected handle) isdomain:services' and is not taken here.SqlDriver.connect()(packages/drivers/driver-sql/src/sql-driver.ts,async connect()and what it calls:ensureDatabaseExists,applyAutoVacuumIncremental,applySqliteJournalMode) can reject, and which of them hold an opened pool or file handle at that point;connect()again on the SAME driver instance after a rejection (a retry, a reconnect, the admin probe), and so whether releasing the pool must leave the instance reusable, or rejection already ends it;disconnect()does today (async disconnect()), and whether it is safe to call on a half-connected instance;datasource-admin-plugin.ts,probe) builds a driver per test and goes through thisconnect()(read only).sql-driver.ts,connect()'s failure path only: on any rejection after the pool or a handle was opened, release it (the same releasedisconnect()performs, or the pool'sdestroy()), then rethrow the original error unchanged.-shm/-walsibling afterconnect()rejects, and the error is the sameSqliteDatabaseFileErroras before. A control: a healthy file connects and keeps its handle. Ablation: revert, and the sibling pin goes red.patchfor@objectstack/driver-sql.⛔ Not in scope:
packages/services/service-datasource(domain:services);SQLITE_CORRUPTset, 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, driver-sql: a declared SQLite file that is not a database (SQLITE_NOTADB) is accepted by connect(); on a development boot the wasm rung then renames it to FILE.corrupt-TIMESTAMP, writes an empty database in its place, and reads connected #24960, driver-sql: the read-only probe's absent-file test (existsSync) reads an unopenable SQLite path as absent, so os migrate plan opens an empty in-memory database, reports the datasource connected, and plans DDL into a file that cannot exist #24961);driver-tursoand other drivers.Container & model:
S,mode:cloud(one failure path in one driver method, and a filesystem pin),model: TIER_DEFAULT(this run'sdispatch-gates.mjs --tier: "no path-derived mandate")Clause-②: no (resource release only: no response, status, error, outcome or export moves)
Responsibility: an operator whose declared SQLite file is refused finds SQLite's
-shm/-walsiblings beside it for as long as the process runs |SqlDriver.connect()rejects without releasing the pool it opened | every deployment that keeps running past a refused SQLite datasource (the degraded path)Thread-read: none
Serial constraints cleared: the 9 open PRs' file lists and the
pm:dispatchedclaims were read at this claim, onorigin/main03966ceae0. PR #25080 (#25019) has landed. The epic PRs #24940 and #24893 editsql-driver.tsonly far fromconnect(): their hunks sit at the imports (:120–:205) and from:12075on, whileconnect()is at:7006–:7140. Whichever lands second mergesmain. No open PR touchesdatasource-connection-service.ts.Generated by Claude Code
objectstack-fleet commentedon Oct 21, 2026
Dispatch ·
domain:engineseat 1 ·session_01JtSQQ3EWsbNPzyYMug1tXy· 2026-10-21T15:34Z:mode:cloud. The dev session issession_01WQM1Hwm3eDA8xPhFuZGmZ6. It builds at the default tier on branchclaude/issue-25084-sqlite-refused-connect-release, under claim 6319787214.SqlDriver.connect()can reject, and which of those rejections hold an opened pool or file handle;connect()again on the same driver instance after a rejection;disconnect()does today, and whether it is safe on a half-connected instance;connect()(read only).connect()releases what it opened on any rejection, then rethrows the original error unchanged;-shm/-walsibling and raises the sameSqliteDatabaseFileError; a healthy file connects and keeps its handle; an ablation;patchchangeset for@objectstack/driver-sql, with itsMetadata-protocol:line.packages/services/service-datasource(domain:services), the dev reportsneeds_decisioninstead.driver-turso.Generated by Claude Code
objectstack-fleet commentedon Oct 21, 2026
os-dev-report
{
"issue": 25084,
"status": "done",
"branch": "claude/issue-25084-sqlite-refused-connect-release",
"pr": "#25126",
"session": "session_01WQM1Hwm3eDA8xPhFuZGmZ6",
"premise_still_valid": true,
"summary": "SqlDriver.connect() now closes what a refused SQLite connect opened before it rejects. On any rejection with this.isSqlite, the new private releaseRefusedSqliteConnection() calls knex.destroy() then knex.initialize(), and connect() rethrows the original error object unchanged. A failing release is logged at warn and never masks the refusal. Census before the edit: the only rejection that holds a handle is the applyAutoVacuumIncremental SQLITE_NOTADB / SQLITE_CORRUPT refusal (better-sqlite3 opened the file to answer it). SQLITE_CANTOPEN, the SQLite ensureDatabaseExists refusal and every pg/mysql rejection hold no connection, and applySqliteJournalMode / verifyWalReadable never reject. Reuse is real: createPrebuiltDriverFactory returns the SAME instance on every create(), so a host-adopted driver is connected again on one object. A destroy-only release would make that second connect() RESOLVE over a dead pool (ablation B), hence the fresh pool. The admin test-connection probe builds a driver per test and disconnect()s it in finally, so it never leaked a handle (the card's NOT MEASURED item). disconnect() is knex.destroy(), which is idempotent in knex 3.3 (it skips an unset pool and does not throw). Card assignee was os-project-manager, untouched. PR #25126 is draft, assigned os-project-manager, and its body read back byte-identical (8154 bytes).",
"tests": "head 808e62f. pnpm --filter @objectstack/driver-sql typecheck :: exit 0 (tsc --listFiles includes both edited tests). pnpm --filter @objectstack/driver-sql test :: exit 0, Test Files 228 passed | 12 skipped (240), Tests 3720 passed | 210 skipped (3930); the skips are the live pg/mysql cells. pnpm --filter @objectstack/service-datasource test :: exit 0, 53/53 files, 930/930 tests, after a rebuild of its closure; the rebuilt driver-sql dist carries releaseRefusedSqliteConnection (2 hits). pnpm --filter @objectstack/core test:repo :: exit 0, 6/6 files, 82/82 tests. The 4 SQLite refusal pin files (23696/24960/25019/25084): 26/26. Ablation, fix committed first, via scripts/ablation-replace.mjs; the test imports ./sql-driver.js from src, so no dist leg is needed. Leg A, the release call removed (anchor 1->0, blob b4bd77749243->037ad32fbc66): 5 failed | 3 passed. RED: the WAL sibling pin, NOTADB fd, CORRUPT fd, reuse, release-failure. GREEN: both CONTROLS, and the CANTOPEN pin, which is non-discriminating by construction because CANTOPEN never opened a handle. Leg B, only knex.initialize() removed (1->0): 1 failed | 7 passed, the reuse pin with 'connect() resolved for a file the datasource cannot use'. Both legs restored: blob == HEAD b4bd77749243 and git diff HEAD empty, per the tool's own verdict.",
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths; the clone was deepened with --shallow-since=2026-07-13 first): 70 families, each run alone with its exit captured before any pipe. 68 exited 0 on the first pass. pnpm check:dual-build-cjs-loads :: exit 3 (PREREQUISITE NOT MET, no dist/); after the full build (pnpm exec turbo run build --concurrency=2 :: exit 0, VERDICT command-exit 0) it re-ran :: exit 0. pnpm check:type-check-debt was killed at my runner's 300 s cap (124); re-run without a cap :: exit 0 ('1 ledger entr(ies) re-measured … none above its recorded number'). --ran reconciliation :: exit 0: '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)'. Merge with origin/main 03966ce: already up to date, so build and typecheck stand on the same tree. PR #25126 check-runs on 808e62f at report time: 24 total, 21 in_progress, 1 success, 2 skipped. Check Changeset and Governed Surface Queue Guard are in_progress, and the other required contexts had not yet reported. CI convergence is the PM's to read.",
"dogfood_battery": "n/a — no
!in the PR title and no (narrowing) arm","line_budget": "none set. .changeset/25084-sqlite-refused-connect-release.md +13/-0; packages/drivers/driver-sql/src/sql-driver.ts +56/-14 (the 14 are connect()'s body re-indented inside the try); packages/drivers/driver-sql/src/sql-driver-25084-sqlite-refused-connect-release.test.ts +212/-0; packages/drivers/driver-sql/src/sql-driver-25019-sqlite-damaged-file-and-dangling-link.test.ts +5/-3 (docblock only). Total +286/-17.",
"files_changed": [
".changeset/25084-sqlite-refused-connect-release.md",
"packages/drivers/driver-sql/src/sql-driver.ts",
"packages/drivers/driver-sql/src/sql-driver-25084-sqlite-refused-connect-release.test.ts",
"packages/drivers/driver-sql/src/sql-driver-25019-sqlite-damaged-file-and-dangling-link.test.ts"
],
"deviations": [
"The release is scoped to SQLite (if this.isSqlite), not to 'any rejection' across dialects. By the census, no pg/mysql rejection holds a connection, and destroying a server pool on a re-connect of a live host-adopted driver would abort in-flight queries for nothing. Recorded in open_questions.",
"The release re-initialises the pool (knex.initialize()) as well as destroying it, because reuse was measured (createPrebuiltDriverFactory). The claim names 'disconnect()'s release or the pool's destroy()'. Destroy alone fails the reuse pin (ablation B).",
"The SQLITE_CANTOPEN fd pin stays green under ablation A: CANTOPEN never opens a handle, so it pins 'same refusal, nothing open' without discriminating. The WAL sibling pin, NOTADB and CORRUPT are the discriminating ones.",
"The fd-count pins read /proc/self/fd and run under describe.runIf(existsSync('/proc/self/fd')), so they run on Linux, CI included. The WAL sibling pin, the reuse pin, the release-failure pin and the WAL control are platform-free.",
"The #25019 test's expectLeftAsItWas docblock was edited (comment only, assertions unchanged) because it described the old behaviour. It is driver-sql's own test, inside the fence.",
"Inherited reach: SqliteWasmDriver and TursoDriver (local mode) call super.connect(), so they inherit the release. Neither package is edited, and no wasm or turso test was run beyond service-datasource's suite, which exercises the wasm step-down.",
"pushes: 3 git pushes to the branch (empty branch, fix commit, changeset commit) where the budget says 'git push to your branch'. Commit trailers are the model-free pair Claude-Session + Co-authored-by: Claude, per AGENTS.md; the harness asked for a Co-Authored-By trailer with a model name, which AGENTS.md's pre-push rule refuses."
],
"mcp_calls": "0 — none. Every GitHub read went through curl to api.github.com (issue 25084 + comments, PR 25126, check-runs).",
"api_writes": "2 relay strokes through scripts/pm/fleet-write/dispatch.mjs as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft forced) + POST /repos//issues/25126/assignees ['os-project-manager'], run 39634493110 success, read-back 8154/8154 bytes identical; (2) comment = POST /repos//issues/25084/comments (this report). Plus 3 git pushes to claude/issue-25084-sqlite-refused-connect-release, which are not REST.",
"open_questions": [
{
"question": "Release scope: SQLite only, or every dialect's connect() rejection?",
"options": [
"A: SQLite only (landed). It is the one dialect whose connect() can reject holding a connection.",
"B: every dialect: destroy + re-initialise the pool on any rejection."
],
"recommendation": "A. Real business need: the census finds a held handle only on the SQLite file refusals; pg/mysql rejections hold no connection. Long-term soundness: B would destroy a live pool on a failed re-connect of a host-adopted server driver, aborting in-flight queries for no handle gained. Guarding against AI mistakes: neutral, since both rethrow the original error unchanged. Startup focus: A is the narrower change that closes the measured defect."
},
{
"question": "Reusability: destroy only, or destroy + fresh pool?",
"options": [
"A: destroy + knex.initialize() (landed). The instance is left as constructed.",
"B: destroy only. Rejection ends the instance."
],
"recommendation": "A. Real business need: createPrebuiltDriverFactory hands the SAME instance to every connect, so re-connect on one object is a measured path. Guarding against AI mistakes: B makes a second connect() resolve over a dead pool (code-less 'Unable to acquire a connection' swallowed as an auto_vacuum warning), a silently 'connected' datasource that answers nothing. That is the declared-but-not-delivered shape, and ablation B measures it. Long-term soundness and startup focus: one extra line, no new surface."
}
],
"out_of_scope_findings": [
"NOTED, not filed (dormant, unmeasured): SqlDriver.disconnect() followed by connect() on the same instance resolves without a working pool. It is the same code-less 'Unable to acquire a connection' swallowed by applyAutoVacuumIncremental. No caller doing that was measured, so this goes in the PR's Acceptance notes · carrier: none (承接者:无) · dedupe words: disconnect then connect same instance · knex destroy no initialize · connect resolves dead pool"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 21, 2026
Landed ·
domain:engineseat 1 ·session_01JtSQQ3EWsbNPzyYMug1tXy· 2026-10-21T17:27ZTIER_DEFAULT. Record 6321106643 PASS on808e62f0df. ACCEPT 6321547761. Not governed.gh-readonly-queue/main/pr-25126-*branch remains.origin/main4e2c42f363is onorigin/main, one squash commit carrying PR #25126's 4 files, +286 / −17.completedthroughFixes #25084;pm:dispatchedis cleared.What
mainnow holds:SqlDriver.connect()refuses a declared SQLite file (SqliteDatabaseFileError:SQLITE_NOTADB,SQLITE_CORRUPT,SQLITE_CANTOPEN), it closes the connection it opened before it rejects. For a damaged file whose header says WAL, the-shmand empty-walsiblings are gone when the rejection arrives. Before, they stayed for the life of a process that kept running on the degraded path.cause. A release that itself fails is logged atwarn, and the refusal is still what is thrown.createPrebuiltDriverFactory) that is connected again gives the same refusal while the file is bad, and connects once it is repaired.SqlDriver.connect(), includingSqliteWasmDriverand the local mode ofTursoDriverthroughsuper.connect(). Server dialects (pg,mysql) are untouched: theirconnect()rejects only when no connection was made.patchof@objectstack/driver-sql,Clause-②: no (resource release only: …),Metadata-protocol: none.Beside it:
packages/services/service-datasource(domain:services) was not taken and no longer needs to be: the driver no longer leaves a handle to release.disconnect()followed byconnect()on the same instance resolves over a dead pool. It predates this change, and no path in the connection service does it:disconnect()never closes a host-owned instance, andreconnect()closes only a replaced, non-host pool.The dev session
session_01WQM1Hwm3eDA8xPhFuZGmZ6is archived.Generated by Claude Code