Skip to content

fix(cli): stop Ctrl-C from stranding the local maintenance lock - #556

Merged
Makisuo merged 1 commit into
mainfrom
fix/cli-maintenance-lock-sigint
Aug 20, 2026
Merged

fix(cli): stop Ctrl-C from stranding the local maintenance lock#556
Makisuo merged 1 commit into
mainfrom
fix/cli-maintenance-lock-sigint

Conversation

@Makisuo

@MakisuoMakisuo commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

What

maintenanceOperation — an uninterruptible Effect boundary for promise-land work that takes the maintenance lock internally — replacing bare Effect.tryPromise at the 12 call sites that reach the lock: nine in commands/archive.ts (create, gc, reconcile, rebuild-catalog, expire, retire, and three calibration paths) and three in commands/schema.ts (migrate preview/apply, abandon).

Nothing inside gc.ts, retention.ts, listing.ts, generation.ts or local-store-migrations.ts changes.

Why

The maintenance lock is released by a finally inside promise land, so whether it survives a Ctrl-C is decided entirely by the Effect boundary above it — and every one of those boundaries was a bare Effect.tryPromise.

Effect.tryPromise is interruptible, and interruption abandons the promise rather than cancelling it: callbackOptions marks the async resumed, aborts its signal, and unwinds the fiber without waiting. Under BunRuntime.runMain a Ctrl-C during maple archive create therefore tore the process down while withMaintenanceLock was still mid-write, so its finally never ran and <dataDir>.maple-maintenance-lock survived carrying a plausible owner record.

The next run only recovered because acquireMaintenance quarantines a provably dead PID — recovery by luck, one PID reuse away from a hard failure.

Effect.uninterruptible is enough to fix it on its own: the interrupt is recorded on the fiber and not delivered, the fiber stays parked until the promise settles, the lock's finally releases, and setInterruptibleTrue re-raises the recorded interrupt afterwards.

Reviewer notes

The rejected alternative. An acquireRelease bracket with an interruptible body was the first instinct and is wrong: it releases the lock promptly while the abandoned promise keeps writing — strictly worse than the bug. The work was never abortable; the only real choice was whether Effect waits for it.

The deliberate cost. Ctrl-C is honoured when the operation finishes, not immediately. That is the right trade for the only writer of a lock the next process must trust. To make Ctrl-C prompt again, the promise bodies would have to observe the AbortSignal that try already passes them — until they do, an interruptible boundary only deletes the lock out from under work that keeps running. This is documented on maintenanceOperation itself.

The calibration watchdog is not defanged. It kills with an untrappable SIGKILL on both paths (killSignal: "SIGKILL" and process.kill(-pgid, "SIGKILL")), which is the crash case the on-disk journals already reconcile.

Behaviour claims were read out of the runtime, not assumedcallbackOptions, interruptUnsafe, and setInterruptibleTrue in packages/effect/src/internal/effect.ts at the pinned 4.0.0-rc.108.

Testing

test/native-maintenance-sigint-probe.sh SIGINTs a real worker process holding the lock under real BunRuntime.runMain, and asserts both arms so it cannot pass vacuously:

arm 1: bare Effect.tryPromise → lock=stranded exit=130 completed=no
arm 2: maintenanceOperation → lock=released exit=130 completed=yes

test/maintenance-operation.test.ts (3 tests) drives interruption through fiber.interruptUnsafe() — exactly what runMain's SIGINT handler calls — and covers the ordinary success and failure release paths. Mutation-checked: deleting Effect.uninterruptible fails it with "THE FIX: the lock was released".

tsc --noEmit clean, 70 targeted tests pass, oxlint reports nothing on the changed files.

Not run locally: the native probes that need a bundled binary + chDB + duckdb (native-archive-smoke.sh, native-local-store-migration.sh). They exercise the converted commands against real archives and are worth a CI run.


View with [code]smithAutofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

The maintenance lock is released by a `finally` inside promise land, so
whether it survives a Ctrl-C is decided entirely by the Effect boundary
above it — and every one of those boundaries was a bare
`Effect.tryPromise`.
`Effect.tryPromise` is interruptible, and interruption ABANDONS the
promise rather than cancelling it: `callbackOptions` marks the async
resumed, aborts its signal and unwinds the fiber without waiting. Under
`BunRuntime.runMain` a Ctrl-C during `maple archive create` therefore
tore the process down while `withMaintenanceLock` was still mid-write, so
its `finally` never ran and `<dataDir>.maple-maintenance-lock` survived
carrying a plausible owner record. The next run only recovered because
`acquireMaintenance` quarantines a provably dead PID — recovery by luck,
one PID reuse away from a hard failure.
`maintenanceOperation` makes that boundary uninterruptible, which is
enough on its own: an interrupt is recorded on the fiber and not
delivered, the fiber stays parked until the promise settles, the lock's
`finally` releases, and `setInterruptibleTrue` re-raises the recorded
interrupt afterwards. The 12 call sites that reach the lock now use it —
nine in `archive.ts` (create, gc, reconcile, rebuild-catalog, expire,
retire, three calibration paths) and three in `schema.ts` (migrate
preview/apply, abandon). Nothing inside gc.ts, retention.ts, listing.ts,
generation.ts or local-store-migrations.ts changes.
The rejected alternative was an `acquireRelease` bracket with an
interruptible body. It releases the lock promptly while the abandoned
promise keeps writing — strictly worse than the bug. The work was never
abortable; the only real choice was whether Effect waits for it.
The cost is deliberate and documented: Ctrl-C is honoured when the
operation finishes, not immediately. That is the right trade for the only
writer of a lock the next process must trust, and it does not weaken the
calibration watchdog, which kills with an untrappable SIGKILL (both
`killSignal` and `process.kill(-pgid, ...)`) — the crash case the on-disk
journals already reconcile. To make Ctrl-C prompt again, those promise
bodies would have to observe the `AbortSignal` that `try` already passes
them.
Tested end to end against real processes and real signals:
`native-maintenance-sigint-probe.sh` SIGINTs a worker holding the lock
under real `runMain` and asserts BOTH arms, so it cannot pass vacuously —
the bare shape must strand the lock (exit 130, operation abandoned) and
the fixed shape must release it (exit 130, operation completed). The unit
tests drive `fiber.interruptUnsafe()`, which is exactly what runMain's
SIGINT handler calls; removing `Effect.uninterruptible` fails them.
@Makisuo
Makisuo merged commit 3ccdd75 into mainAug 20, 2026
32 checks passed
@Makisuo
Makisuo deleted the fix/cli-maintenance-lock-sigint branch August 20, 2026 23:36
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit e39f4e8 · View workflow run

Sign up for freeto 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.

1 participant

@Makisuo