Skip to content

fix(persist): fence async commits against late-landing stale writes (#373) - #409

Merged
ranxianglei merged 1 commit into
masterfrom
2026-09-25_flushsync-fence
Sep 25, 2026
Merged

ranxianglei merged 1 commit into
masterfrom
2026-09-25_flushsync-fence

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Fixes #373. Also closes the phantom-delivery gap recorded in #408 (a prior comment claimed a PR #374 / commit dddf0a4 that never existed).

The bug (as diagnosed in the issue, confirmed here)

writeInner snapshots the payload at chain time, then commits via async fsp.rename. flushSync writes synchronously and returns — but never joins writeChains. Sequence: in-flight write snapshots v1 → state advances to v2 → flushSync writes v2, returns true → the in-flight rename lands v1 afterward. The documented contract ("callers that use the result to drop in-memory state" may drop on success) is violated: disk regresses to v1 while memory is gone.

The same shape exists on the spill path: the .fb.json slot is shared per id, so a stale in-flight spill write could overwrite a newer spill.

The fix: per-id commit fence + synchronous commit block

  • commitSeq: Map<id, number> — bumped by every committed write (flushSync canonical, flushSync spill, writeInner canonical, writeInner spill).
  • writeInner captures the seq before build() (a flushSync re-entering from inside build() must fence this snapshot too), keeps async staging (mkdir/writeFile to a unique tmp — safe to interleave), then commits in one synchronous block: fence check → renameSync → seq bump. The event loop cannot run a flushSync between the check and the rename, so a stale payload can never land after a newer commit.
  • Spill fallback: fenced pre-check + synchronous writeFileSync inside the same block (same shared-slot clobber shape). Rejection semantics on spill unchanged (still throws after spilling — existing tests assert this).
  • Superseded writes settle quietly: no failure recorded (nothing failed — a newer payload owns disk), no throw into the chain.

Chained async-vs-async ordering is preserved: a queued write built after a commit sees the bumped seq as its base and commits normally.

Verification

  • Mutation gate: the new regression test drives a flushSync re-entrant from inside the async builder's build() — a deterministic interleave. On the pre-fix code it fails exactly as the issue describes (disk holds the stale snapshot); on the fence it passes. Confirmed red→green via stash.
  • Full suite: 1050/1050 pass (typecheck clean, build clean).
  • Six existing failure-injection tests migrated their mock seam from fsp.rename (async surface) to fs.renameSync (the new synchronous commit surface) — same injected failures, same assertions, intent unchanged.

…373)

writeInner snapshots the payload, then commits via async rename. A
flushSync writing a NEWER payload could return, and the stale in-flight
rename could land afterward and clobber it — the flushSync success
contract (disk holds the newest payload on return) was violated, and
callers that drop in-memory state on success could lose data.

Root fix: per-id commit fence. Every committed write (sync or async)
bumps a seq. writeInner captures the seq BEFORE build() and re-checks
it inside a SYNCHRONOUS commit block (fence check + renameSync), so the
event loop cannot interleave a flushSync between check and rename. The
spill fallback gets the same fenced synchronous write (its slot is
shared per id, same clobber shape). Superseded writes settle quietly —
no failure recorded, no throw into the chain; a newer payload owns disk.

Regression test uses a flushSync re-entering from inside the async
builder's build(), making the interleave deterministic: fails on the
old code (disk holds the stale snapshot), passes on the fence. Spill
and retry-path tests migrate their failure-injection seam from the
async fsp.rename surface to the new synchronous commit surface; their
intent is unchanged.
@github-actions

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-09-25_flushsync-fence (f3df3eb)

Option A — Install from npm PR tag (recommended)

In your adapter project:

npm install acp-kernel@pr-409

Each push to this PR publishes a new version under the pr-409 npm tag.

Option B — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf acp-kernel-pr409.tgz
npm install ./package

This comment is automatically updated on each push.

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.

StateStore: flushSync 不与 in-flight writeNow 排序,磁盘可残留旧快照(bounded staleness)

1 participant