Skip to content

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

Closed

Description

@objectstack-fleet

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: DatasourceConnectionService disconnects a handle whose connect() 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)

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.

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

  1. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 26 (p3 only, by the maintainer's order 「你后续只处理 p3」) · 2026-10-21T15:30Z · p3, domain:engine (triage's grade), filed by domain:engine seat 2 from #25019's dev report (6310958104) and the contract review of PR #25080 (6311414662). When a declared SQLite datasource's connect() 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 -shm and an empty -wal beside it until exit. Neither SqlDriver.connect() nor DatasourceConnectionService.connect releases 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 as GET /user answers it; always the card's assignee)
    Branch: claude/issue-25084-sqlite-refused-connect-release
    Worktree: objectstack-issue-25084
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface:

    • The seam: the card names two, and this lane's is the driver's. SqlDriver.connect() releases what it opened before it rejects, so a connect() 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 (DatasourceConnectionService disconnecting a rejected handle) is domain:services' and is not taken here.
    • Measured first:
      • every way 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;
      • whether anything calls 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;
      • what disconnect() does today (async disconnect()), and whether it is safe to call on a half-connected instance;
      • whether the admin test-connection door (datasource-admin-plugin.ts, probe) builds a driver per test and goes through this connect() (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 release disconnect() performs, or the pool's destroy()), then rethrow the original error unchanged.
    • Pins: on the production path, a refused WAL-header file leaves no -shm / -wal sibling after connect() rejects, and the error is the same SqliteDatabaseFileError as before. A control: a healthy file connects and keeps its handle. Ablation: revert, and the sibling pin goes red.
    • The changeset: patch for @objectstack/driver-sql.

    ⛔ Not in scope:

    Container & model: S, mode:cloud (one failure path in one driver method, and a filesystem pin), model: TIER_DEFAULT (this run's dispatch-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 / -wal siblings 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:dispatched claims were read at this claim, on origin/main 03966ceae0. PR #25080 (#25019) has landed. The epic PRs #24940 and #24893 edit sql-driver.ts only far from connect(): their hunks sit at the imports (:120–:205) and from :12075 on, while connect() is at :7006–:7140. Whichever lands second merges main. No open PR touches datasource-connection-service.ts.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-21T15:34Z: mode:cloud. The dev session is session_01WQM1Hwm3eDA8xPhFuZGmZ6. It builds at the default tier on branch claude/issue-25084-sqlite-refused-connect-release, under claim 6319787214.

    • Read first:
      • every way SqlDriver.connect() can reject, and which of those rejections hold an opened pool or file handle;
      • whether anything calls connect() again on the same driver instance after a rejection;
      • what disconnect() does today, and whether it is safe on a half-connected instance;
      • whether the admin test-connection probe goes through this connect() (read only).
    • What lands:
      • connect() releases what it opened on any rejection, then rethrows the original error unchanged;
      • pins: a refused WAL-header file leaves no -shm / -wal sibling and raises the same SqliteDatabaseFileError; a healthy file connects and keeps its handle; an ablation;
      • a patch changeset for @objectstack/driver-sql, with its Metadata-protocol: line.
    • Fork, not widening: if the release cannot happen in the driver without a change in packages/services/service-datasource (domain:services), the dev reports needs_decision instead.
    • Not built: the caller-side seam, the refusal codes and messages, the fail-fast policy, driver-turso.
    • Owed: the seat's review of record before the queue.

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  4. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-21T17:27Z

    Probe Reading
    Review TIER_DEFAULT. Record 6321106643 PASS on 808e62f0df. ACCEPT 6321547761. Not governed.
    Queue It entered the merge queue at 2026-10-21T16:55Z and left it merged at 2026-10-21T17:26Z. No gh-readonly-queue/main/pr-25126-* branch remains.
    origin/main 4e2c42f363 is on origin/main, one squash commit carrying PR #25126's 4 files, +286 / −17.
    Card Closed completed through Fixes #25084; pm:dispatched is cleared.

    What main now holds:

    • A refused SQLite file no longer keeps a handle open. When 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 -shm and empty -wal siblings are gone when the rejection arrives. Before, they stayed for the life of a process that kept running on the degraded path.
    • The refusal is the same error object. Same class, code, message and cause. A release that itself fails is logged at warn, and the refusal is still what is thrown.
    • The instance stays usable. The pool is destroyed and a fresh, empty one put in its place, so a host-adopted driver (createPrebuiltDriverFactory) that is connected again gives the same refusal while the file is bad, and connects once it is repaired.
    • Reach: every caller of SqlDriver.connect(), including SqliteWasmDriver and the local mode of TursoDriver through super.connect(). Server dialects (pg, mysql) are untouched: their connect() rejects only when no connection was made.
    • Shipped as: patch of @objectstack/driver-sql, Clause-②: no (resource release only: …), Metadata-protocol: none.

    Beside it:

    • The caller-side seam in packages/services/service-datasource (domain:services) was not taken and no longer needs to be: the driver no longer leaves a handle to release.
    • Noted in the PR, not filed: disconnect() followed by connect() 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, and reconnect() closes only a replaced, non-host pool.

    The dev session session_01WQM1Hwm3eDA8xPhFuZGmZ6 is archived.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:devpathThe road — create, dev, verify, publish/install, connect an agent, iteratebugSomething isn't workingdomain:enginepriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions