Skip to content

fix(storage): browser-safe cross-instance re-entry + actionable ConnectionReentryError - #658

Merged
sroussey merged 1 commit into
claude/dazzling-archimedes-o87xemfrom
claude/dazzling-archimedes-3e56is
Jul 24, 2026
Merged

fix(storage): browser-safe cross-instance re-entry + actionable ConnectionReentryError#658
sroussey merged 1 commit into
claude/dazzling-archimedes-o87xemfrom
claude/dazzling-archimedes-3e56is

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Stacks on top of #657 — the ConnectionMutex module lives on that branch.
This PR addresses two HIGH-severity follow-ups found in review.

F1 — browser ALS shim cannot detect cross-instance re-entry across await

Finding. The original detection ran ALS-first:

const als = await ensureAls();
const store = als.getStore();
if (store !== undefined && store.handle === handle) {
  if (store.owner === owner) return fn();
  throw new ConnectionReentryError(...);
}
// ...fall through to chain wait

On Node with real AsyncLocalStorage, the store survives across await and this works.
On the browser bundle we ship the synchronous shim, whose run(store, cb) restores
current in its finally the moment cb() returns (which for an async cb is
immediately — the promise, not the resolved value). Any code that runs after the
first await inside the tx body sees current === undefined, so a sibling call
from a different storage instance on the same handle is silently misclassified as
"no ALS context" and falls through to a chain wait — which is precisely the
failure mode the mutex exists to prevent (it can also deadlock when the tx body
is awaiting work that depends on the sibling completing).

Approach.

  • Extracted classifyReentry(state, owner, alsStore, handle): "throw" | "inline" | "chain":
    • state.txOwner !== null && state.txOwner !== owner"throw" (regardless of ALS)
    • ALS store present, matches handle + owner → "inline"
    • Otherwise → "chain"
  • runOnConnection / runInTransactionOnConnection now check state.txOwner (which
    lives on the handle state, not the ALS store) before awaiting ensureAls, so the
    cross-instance throw path is synchronous and ALS-independent — identical on Node and
    the browser shim.
  • AsyncLocalStorage remains only as an inline optimization on Node: when the store
    survives across await and matches, a same-instance re-entry runs inline rather
    than re-taking the chain. On the browser shim, same-instance re-entry falls back
    to the chain wait — safe, and the sanctioned path (tx proxy → _fooInternal)
    bypasses runOnConnection entirely anyway.
  • Added __resetAlsForTesting(useShim?) — clears the cached alsPromise and
    optionally installs a fresh shim so the test suite can exercise the browser path
    on a Node test runner.
  • JSDoc rewritten to state clearly that cross-instance detection is ALS-independent.

F2 — ConnectionReentryError gave no migration path

Finding. The original message was one line, named the two tables, and left the
operator to guess. A dev hitting this in production has no idea whether they should
switch to the tx proxy, open a SAVEPOINT, or split their storage instances apart.

Approach.

  • Added readonly mode: "sibling-op" | "nested-transaction" as the third
    constructor arg. runOnConnection throws with "sibling-op";
    runInTransactionOnConnection throws with "nested-transaction".
  • Multi-line message that names both callers, states the mode, and lists three
    supported refactors:
    1. Route the sibling through the tx proxy on the same instance
    2. Open a SAVEPOINT on tx for a nested rollback boundary
    3. Collapse to a single storage instance or give each instance its own connection
  • Class-level JSDoc documents the same three refactors, in order of preference.

Verification

New file packages/test/src/test/storage-tabular/ConnectionMutex.test.ts — 10 tests
covering both real ALS and the shim path (via __resetAlsForTesting(true)):

  • Cross-instance sibling call throws ConnectionReentryError(mode="sibling-op")
    in under 200 ms on both platforms (no hang)
  • Cross-instance nested tx throws ConnectionReentryError(mode="nested-transaction")
    in under 200 ms on both platforms
  • Sibling throw does not corrupt the outer tx's chain — a subsequent op runs cleanly
  • Same-instance nested via captured this inlines under real ALS
  • Message contains all three refactor hints; falls back to generic labels when the
    owner has no table property

Integration tests updated:

  • SqliteTabularStorage.integration.test.ts — the earlier "sibling blocks" test
    now asserts the F1 stricter contract ("sibling throws") plus the F2 mode / message
    shape; the "nested withTransaction rejects" test asserts mode === "nested-transaction"
    and the migration hints. The "same-instance nested writes via tx proxy still work"
    regression check is preserved.
  • PostgresTabularStorage.integration.test.ts — same shape, and the "same-instance
    nested writes via tx proxy still work" regression check is added to reach parity
    with the SQLite suite.

Verified with:

bunx vitest run \
  packages/test/src/test/storage-tabular/ConnectionMutex.test.ts \
  packages/test/src/test/storage-tabular/SqliteTabularStorage.integration.test.ts \
  packages/test/src/test/storage-tabular/PostgresTabularStorage.integration.test.ts \
  --pool=forks --no-file-parallelism

All three files pass: ConnectionMutex (10/10), Sqlite integration (150 passed / 1
pre-existing skip), Postgres integration (146 passed / 1 pre-existing skip).

Behavior change to flag

The "sibling single-op blocks across a transaction on the same handle" test
asserted that a sibling put would silently wait for the outer tx to release and
then succeed. Under F1 that path now throws ConnectionReentryError(mode="sibling-op")
synchronously — the safer contract, and the whole point of the fix — so the test
is rewritten to the new contract rather than preserved. This is the only intentional
behavior break; every other pre-existing test still passes.


Generated by Claude Code

…ctionReentryError

Two follow-ups on the ConnectionMutex introduced in the previous commit.

F1 (browser ALS shim cannot detect cross-instance re-entry across await):
the ALS-first check missed a legitimate cross-instance sibling call whenever
the shim reset its `current` at the first `await`, so a sibling call on a
different storage instance could sneak into another's BEGIN/COMMIT window
on the browser bundle. Move the `state.txOwner` check ahead of `ensureAls`
so the throw path is synchronous and ALS-independent; ALS survives only as
an inline optimization on Node (browser shim falls back to chain-wait for
same-instance re-entry, which the sanctioned tx-proxy path avoids anyway).
Add a `classifyReentry(state, owner, alsStore)` helper and a
`__resetAlsForTesting(useShim?)` export so the test suite can exercise
both the real ALS and the shim paths.

F2 (ConnectionReentryError gave no migration path): thread a
`mode: "sibling-op" | "nested-transaction"` discriminator onto the error
and rewrite the message to a multi-line block naming both callers, the
mode, and the three supported refactors: (a) route the sibling through
the `tx` proxy, (b) open a SAVEPOINT on `tx` for a nested rollback
boundary, (c) collapse to a single storage instance or give each its own
connection. `runOnConnection` throws with `"sibling-op"`,
`runInTransactionOnConnection` with `"nested-transaction"`.

Tests: new `ConnectionMutex.test.ts` covers F1 across the real-ALS and
browser-shim paths (via `__resetAlsForTesting(true)`), asserting the
throw path is synchronous on both and same-instance nested inlines under
real ALS. Sqlite and Postgres integration tests replace the earlier
"sibling blocks" contract with the new "sibling throws" contract and
assert the F2 mode/message shape for both sibling-op and
nested-transaction reach-throughs; the same-instance tx-proxy regression
check is preserved (and added to the Postgres suite for parity).

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 63.15% 27526 / 43586
🔵 Statements 63.01% 28531 / 45274
🔵 Functions 63.62% 5257 / 8263
🔵 Branches 51.86% 13574 / 26174
File CoverageNo changed files found.
Generated in workflow #2780 for commit 024a056 by the Vitest Coverage Report Action

@sroussey
sroussey merged commit 0dd2a12 into claude/dazzling-archimedes-o87xem Jul 24, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants