Skip to content

fix(host): release the write lock after a throwing applyEditSettled transition - #409

Merged
mtskf merged 17 commits into
mainfrom
fix/release-write-lock-after-throwing-settlement
Sep 10, 2026
Merged

fix(host): release the write lock after a throwing applyEditSettled transition#409
mtskf merged 17 commits into
mainfrom
fix/release-write-lock-after-throwing-settlement

Conversation

@mtskf

@mtskf mtskf commented Sep 10, 2026

Copy link
Copy Markdown
Owner

What

A throwing applyEditSettled transition left the host write lock held for the rest of the panel's life. quoll-editor-panel.ts commits reducer state as state = result.state inside commitTransition, so when the transition throws that assignment never runs: pendingApplyBaseVersion stays non-null, and applyEditSettled is the only event that releases it.

PR #406 paid the barrier half (settle(false) releases the deferred side channels' at-receipt guards) but that payment was one-shot — a retried side channel re-entered editSettledBarrier.run(), found the lock still held, and re-deferred. Meanwhile every later inbound edit was stashed into pendingEdit whose only drain is the settlement that already threw, and at dispose that stash was lost silently (the disposed arm clears the lock with effects: [], and revert-rescue snapshots the held lock → NO_RESCUE).

How

One new reducer-owned event, settlementTransitionFailed, committed from host-session-step.ts's existing transition catch — before the barrier settle, so the guard release is durable.

The arm is a pure field reset plus effects by design: decideEdit (through the injected validator) and the outcome switch (failureToasts' default) are the transition's own throw sources, so the recovery must not re-enter them. That is also why the stash is dropped rather than re-run — canDrain's safety condition needs an OBSERVED canonical snapshot this arm must not go and read.

  • Version, not re-read: the step passes the throwing event's own settledVersion through. It is the label the settlement itself would have used, nothing can interleave before the throw, and it keeps effect-executor.ts's "ONE guarded version reader" contract true — the recovery adds no read seam.
  • Resync before release, so resyncLiveVersion's lock-free foreign-advance branch cannot fire on the alive path: no epoch bump ⇒ the webview's replay buffer survives and replays over the reposted Document. Reversed, the in-flight apply's own late echo would read as a foreign advance and drop those keystrokes.
  • Toast is unconditional, the loss claim is not: the throw abandoned the effect list that (on any non-ok outcome) began with the save-failure toast, so the recovery raises one outcome-blind internal-error toast in its place. The "a later unsaved edit was dropped" clause is added only post-dispose, where edit-sync.ts's retained replay buffer went with the iframe and the stash really was the only carrier.
  • Effect order: the executor guards showError / postDocument per effect but runs logWarn as a bare console.warn, so the triage log goes last. The two withheld-ack lists (withholdAckEffects and editRejectedDeliveryFailed's inline copy) are reordered for the same reason.

Intended side consequence: with the lock released, a later dispose snapshots writeInFlight: false, so revert-rescue is eligible again — correct, since the settlement has already resolved.

Scope notes

  • Does not widen PR fix(host): rescue the edit barrier when an applyEditSettled transition throws #406's settle(false) rescue into an unconditional one (dropAll() before consulting isLocked remains a separate bug).
  • Two pre-existing failure-path lists still put an unguarded logWarn first (settlementEffects' refused arm; the undrainable-stash alive path). Left out to keep this to one purpose — fixing them requires rewriting refused's indexed effects[0] === logWarn assertion — and filed as a follow-up TODO.
  • Two accepted residuals are documented in the arm and in LEARNING.md, each with why paying them is worse: the site-2 epoch bump the outcome-blind recovery cannot make (needs a type violation plus a foreign race to reach), and the un-latched repeat toast under a livelock.

Verification

  • pnpm compile — green; pnpm test:unit — 279 files / 5400 tests green (24 new).
  • Non-vacuity measured, not assumed: stubbing commitWriteLockRecovery to () => [] reddens the composition test's lock-release assertion; moving settlementTransitionFailed into releasesWriteLockOnCommit's true arm reddens "no rescue of the rescue" (the false-side pin LEARNING 2026-09-09 asked for). Both exhaustive-switch tests carry a console.error guard, because the default arms already answer false — the guard is what proves an explicit arm answered.
  • The real panel composition (core + step + barrier + dispatcher, panel's own state closure) pins the lock release, the stash disposition, the durable side-channel release, and that the next edit is accepted and persisted.
  • The replay-buffer claim is covered by two pins meeting at one interface: this PR's epoch assertion (sending side) and cm-edit-sync.test.ts's "(h) replays a buffer on a same-generation, same-epoch settlement" (receiving side), now commented as such so the coupling is discoverable from that file.
  • The "applyEditSettled is the only live release site" claim is re-anchored at all twelve sites where it appeared, enumerated against a measured sweep.

A throwing applyEditSettled transition unwinds before the panel commits the
state that releases the write lock, so the lock stayed held for the rest of the
panel's life and every later edit was stashed into a pendingEdit that could
never drain. Add the reducer arm that releases the lock, drops the stash, and
reposts the authoritative Document so the webview's single flight un-parks with
its replay buffer intact. The throw also abandoned the settlement's own
save-failure toast, so the arm always raises one outcome-blind internal-error
toast in its place, adding a dropped-edit clause only post-dispose, where the
replay buffer went with the iframe and the stash really was the only carrier.

The arm is a pure field reset by design: decideEdit and the outcome switch are
throw sources of the transition being recovered from, so the recovery must not
re-enter them. Wiring follows.

Also order the two withheld-ack effect lists so the user-visible
showResyncFailure precedes the logWarn the executor runs unguarded.
…catch

PR #406's rescue released the deferred side channels' at-receipt guards but
never the write lock, so the payment was one-shot: a retried side channel
re-deferred behind a lock nothing would release. The catch now commits the
recovery transition first and drops the side channels second, so the drop is
durable and a retry runs. The recovery re-bases on the throwing settlement's
own observed version, so no second guarded version reader is introduced.

Also re-anchor the release-site claims in this module and broaden the
onSettleError channel, which now carries recovery failures too.
The panel hands the step its own state-committing lambda, so the recovery
releases the lock THROUGH the reducer and the panel never patches reducer state
itself. Pinned end to end against the real core + step + barrier + dispatcher
composition, including that the next edit is accepted and persisted.

Non-vacuity measured: stubbing commitWriteLockRecovery to return no effects
reddens the lock-release assertion.
…tude

The recovery arm makes "applyEditSettled is the only live release site" false.
LEARNING 2026-09-09 recorded that this claim lives at several altitudes and that
reviews twice caught one copy left stale, so all of them are updated together
and the roster is pinned in the plan's inventory step.

Comment-only, plus the stale "STAYS locked" narration on the barrier-drop test,
which now says what the stub is isolating and points at the composition test
for the actual release.
…ery's receiving side

The recovery's data-preservation claim rests on this test, but nothing in this
file said so — an unrelated webview-sync refactor could have deleted it and left
the claim silently unverified.
…ontracts

Review-cycle findings on the write-lock recovery, in four groups.

1. runEffects' `logWarn` was the one effect case with no `try` of its own, and
   several reducer arms put a triage log AHEAD of the effect that pays the
   incident — the drain `accept` arm returns `[...staleReBaseWarn, applyEdit]`.
   A throw there unwound the loop and abandoned the applyEdit, and since the
   committed state had already re-acquired the write lock, no settlement was
   ever dispatched: the same stranded lock this branch exists to repair, except
   the recovery hangs off the TRANSITION catch and never sees a runEffects
   throw. Guarded at that one seam rather than by reordering each effect list.

2. The recovery's loss clause was gated on `disposed` alone, so the alive
   withheld-ack branch dropped the stash and told the user nothing was lost.
   There is no ack Document in that branch, so the webview's retained replay
   buffer is never replayed; the recovery then clears the lock, the next
   lock-free resync bumps `externalEpoch`, and the buffer is dropped on
   `recordedEpoch > buf.epoch`. The gate is now "will any ack go out?".

3. Three contracts that the comments declared and nothing enforced:
   - `releasesWriteLockOnCommit` returns the SETTLEMENT rather than a boolean,
     so the version reaches the recovery through the same decision that
     selected it. A future member mis-placed in that arm was silently handed
     the caller's fabricated `null`; it is now TS2322.
   - `HostSessionInputEvent` excludes `settlementTransitionFailed` from both
     dispatch surfaces. A dispatched recovery would queue behind a sibling
     whose lock-held stash arm then loses its stash to it; injecting that
     dispatch used to type-check clean. Derived with `Exclude` so a new member
     is carried automatically. commitTransition / commitWriteLockRecovery keep
     the wide type — committing the recovery is the legitimate path.
   - `commitWriteLockRecovery` staying REQUIRED is pinned in
     types-equality.test.ts, the one test program `pnpm compile` checks.

4. Comment accuracy at every altitude where this branch left a claim behind
   (LEARNING 2026-09-09). The rescue's `settle(false)` rationale said `true`
   would take the barrier's WAIT arm; since the recovery releases the lock
   first, `true` would DRAIN and RUN the thunks — the choice is DROP-vs-RUN.
   Also: the ack-site census (two, now three), the withhold pair's scope and
   post-dispose route count (three, now four), the accepted residual's throw
   source, `refused`'s effect order, "unbounded" recursion, and the test
   comment that presented its own stub as the production mechanism.

Non-vacuity measured for every group. Reverting the logWarn guard reddens the
new containment test; restoring the old `lostStash` reddens the new loss claim;
gating the toast on `ackLabelObserved` reddens three core tests (it was green
before, because the ordering test read presence through `indexOf` and absorbed
absence as -1); gating the rescue on the recovery's success reddens exactly one
of 335 step tests. For the two type mechanisms the probes were run BEFORE the
narrowing as well as after: both were tsc-clean beforehand, which is the
measurement that the contracts were prose-only. Adding `?` plus a
`?? (() => [])` default to commitWriteLockRecovery leaves all 335 runtime tests
passing and reddens only the type pin.
… its test fixtures

The recovery arm spelled `toast` and `triage` out on both branches of its
return, so an ordering fix — the kind this PR made twice — could land on one
branch and miss the other. Only the ack half is conditional now, and the order
the surrounding comments justify lives in one list.

Also: two comment corrections (SettlementEvent's doc claimed a nullability that
belongs to the function's return type; a rationale restated 120 lines from the
doc that owns it is now a pointer, keeping the clause that is not duplicated),
three comment reflows left over 100 cols by earlier edits, and three local
fixtures in the new describe block. `messageOf` deliberately keeps its cast so a
missing effect still throws instead of reading as an empty string, which is what
would vacate the not.toContain pins.
The per-effect `logWarn` guard reported its own failure through a bare
`console.error`, so a correlated console failure — VS Code patches the console
as one IPC family — still unwound `runEffects` from inside the guard meant to
contain it. On the drain `accept` arm that costs the WRITE and strands the write
lock for the panel's life.

Route every report made from a position where a throw must not unwind through
one owner, `reportContained`. It takes a thunk so argument evaluation is
contained too, and each site keeps its own console level and payload (the
per-site triage tokens are unchanged). Five sites: the `showError` and `logWarn`
per-effect guards, plus three that were never guarded and each needed only a
SINGLE console failure to strand the lock — `readVersionGuarded`'s and
`readCanWrite`'s catches (both evaluated while building the settlement event)
and the rejection arm's leading log, which sat outside that arm's own `try`.

Both guarded catches now carry `effect.message`, so triage keeps the identity of
the toast or log line that was lost.

Correct the `logWarn` header: the "ONLY effect with no try of its own" claim was
written in the present tense in the hunk that added the try, and the "fixes the
whole class at one seam" claim was wrong twice over — containment is best-effort,
and three effects still evaluate their injected builder outside every try
(`post` guards only `deps.send`). That gap is named where it stays findable.
…ests on

The write-lock recovery arm is outcome-blind, so "A later unsaved edit was
dropped." was an over-claim on the alive path: with no version advance the epoch
does not move, the next same-epoch Document replays the retained buffer, and the
bytes land. Split the clause by branch — definite post-dispose, where the stash
really was the edit's only carrier, and hedged alive with a withheld ack, using
the same "may not have been saved" phrase RESYNC_FAILURE_MESSAGE uses so the two
toasts cannot disagree on certainty. The alive branch also names the remedy that
preserves the bytes (copy before reloading), which composes with the resync
toast's reload as an order rather than a conflict, and stands alone because that
toast is latched per panel. Each branch carries its own closing instruction
instead of sharing an appended one.

Pin the clause on the SHARED `ackLabelObserved` gate: keying it on the raw
`event.settledVersion` was undetectable by the whole session suite, because the
two directions already covered keep the disjunction intact. The new test drives
the state that separates them — alive, stash present, unobserved settled version,
label already raised by a lock-held resync — and asserts BOTH clauses absent,
since an absence check on the definite wording alone stays green against the
hedge.

Close the fail-OPEN derive: `HostSessionInputEvent`'s `Exclude` returns the
original union when the excluded literal does not match, so a one-character typo
re-admitted the forbidden dispatch with tsc silent, while its sibling `Extract`
is fail-CLOSED. One pin in the only type-checked test location observes the
exclusion expression itself.

Comment corrections, all measured against the code: the effect ORDER is defence
in depth, not a consequence of an unguarded `logWarn` (7 sites); the widened loss
gate is NOT the settlement arm's gate, and the divergence is now stated outright
with its reason; the four-step loss chain's third step is conditional; the alive
stash's survival is bounded by the ack; `isEditApplied`'s heading is no longer a
biconditional its own tail breaks; and a cross-reference drops a line number that
went stale inside the PR that added it.
Self-review of the previous commit: `withholdAckEffects`' header quoted the
DEFINITE clause ("A later unsaved edit was dropped.") as what the recovery arm
says on this same withheld-ack condition. That path is now the HEDGED branch —
the definite wording belongs to post-dispose, where this pair is never built at
all. Name the hedge, and say why it reuses RESYNC_FAILURE_MESSAGE's phrasing.

The recovery arm's own note called both no-ack branches "losses" flatly, which is
what the hedge exists to qualify: they differ in certainty, and that difference is
exactly what the two wordings encode.
…n it

The three-way loss clause was a nested ternary, which the house style forbids and
which the same file already solves one arm up with an if/else chain. Unnesting it
also puts each branch's commentary on its branch instead of in a header list the
reader has to re-index.

Tests: the two logWarn-containment cases carried a verbatim-duplicated 20-line
fixture, so they could drift apart; `expectNoLossClaim` replaces three copies of
the same not-dropped / not-hedge / Reopen triple, which makes this cycle's rule
structural rather than remembered — checking only "dropped" reads the hedge as a
pass, and a future no-loss site can no longer half-apply the check.

Comments: two in-PR second derivations became pointers to the note that owns each
argument. The standing "this does NOT close the class" guard stays; the history of
how the claim was wrong belongs in the PR description, not the source.

Non-vacuity re-measured after the refactor: reverting each fix still reddens the
tests that were extracted, so the shared fixtures detect the original defects.
…ntained owner

`reportContained` already existed for reports made from a position where a
throw must not unwind, but six such positions still reported through a bare
console, so one console fault could unwind `runEffects` from inside the guard
meant to contain it:

- `sendEditRejected`'s three recovery dispatches. A throw here skipped
  `editRejectedDeliveryFailed` entirely (measured `dispatched: []`), leaving the
  rejection `pending` with the webview's single flight parked and visible-edge
  resync suppressed. The host makes no further attempt of its own; recovery
  waits on an external `documentChanged` or a webview reload.
- `post`'s sync-throw catch, which `case "postRejectedDraft"` follows
  immediately with `sendEditRejected` — so an escape took the banner delivery,
  its recovery dispatch and the arm's `Cannot save:` toast with it.
- the reseed build-failure guard, which additionally had the bare log AHEAD of
  the toast: with a throwing console it produced no toast attempt at all
  (`toastAttempts: 0`), making silent the exact state its own comment says must
  not be. The signal now goes first, matching the three reducer-side sites.
- `reportResyncFailure`'s own fallback, the most correlated position of all
  since it runs only because `showError` just threw.

The delivery-refused site also moves its two injected seam reads inside the
thunk; argument evaluation happens before the call, so leaving them out
contained only half the site.

Each new wrap gets its own test: an unpinned guard is the one that regresses,
measured on the `showError` fallback, which was the only `reportContained` site
without a pin and survived reversion to a bare console with the file green. That
gap is closed here too.

Also makes this increment's own claims match what is measured:

- the exclusion type pin was fail-OPEN on its own literal — `Extract<T, U>`
  answers `never` both when the union excludes the member and when the literal
  matches nothing. It now asks two questions of ONE shared literal, so the pair
  is a biconditional rather than a convention about keeping two copies in sync.
- `reportContained`'s header conflated two different limits: the throw is fully
  contained, the report is not. Its list of calls that bypass the owner is now a
  rule rather than a roster.
- the withhold pair's ordering rationale no longer rests on a failing fallback
  console call, which the guard absorbs; it rests on the guard being removable.
- exports `RESYNC_FAILURE_MESSAGE` and pins the cross-module wording contract
  the recovery arm's hedge depends on. Rewording it previously left everything
  green.
- corrects the `false`-arm count in `isEditApplied`'s contract, the ALIVE
  replay claim that a withheld ack falsifies, the "NO LOSS" branch that is
  really "no stash loss", and a toast-ordering claim that rested on a
  notification stack order this repo cannot verify.
`reportContained`'s header carries two standing claims — the best-effort limit
and the effect-order rule — and two call sites pointed back at it by the same
marker, so one of them resolved to the wrong paragraph. Naming the order rule
gives each pointer a target it cannot miss, which is the failure mode that
replacing duplication with pointers introduces.

Also: a group comment counted three tests where there are four, and two headers
were left mid-sentence by earlier edits. Comment-only — no statement, assertion,
or test was added or removed.

Measured while here: the header's roster of console calls that deliberately do
NOT route through the helper matches a grep of every console call in the module,
so that completeness claim is now verified rather than asserted.
…case with no stash

An alive `settlementTransitionFailed` recovery whose ack is withheld and whose
`pendingEdit` is null fell into the no-loss branch, because `lostStash` requires
a stash. That state told the user to "Reopen the file" — which destroys the
webview buffer still holding the at-risk bytes. The recovery arm is
outcome-blind, so those in-flight bytes sit under the same conditional loss as a
stash: no ack means no replay, the next lock-free advance bumps the epoch, and
edit-sync drops the buffer.

Re-key the no-loss branch on `disposed || ackLabelObserved` so every alive
recovery with an unobserved label takes the copy-first hedge, and generalise the
hedge's wording so it no longer presupposes a stash that may not exist.

Also correct three claims this branch introduced earlier and one type-level
fail-open:

- the `case "logWarn"` roster claimed ONE remaining unguarded position class
  "and it is not a console call". `case "openExternal"` is a second one, and its
  delegate's open positions ARE console calls (bare `console.warn` twice, plus
  bare `console.error` and `deps.showError` outside its own `try`).
- the named ORDER RULE was stated as a universal that two reducer arms in this
  same repo contradict; restated in the count form the file already uses, with
  both exceptions named.
- the withhold pair's order was justified partly as a defence against the
  unguarded builder positions. No effect list reaching that helper evaluates a
  builder, so the order cannot cover them; only the guard-removal half stands.
- `RecoveryEventType = never` degenerated both event-union assertions to
  trivially true. One assertion asks the alias about itself, closing it.

Pinned in both directions: restoring the old condition reddens the new no-stash
hedge test, and inverting the new conjunct reddens all three no-loss sites plus
the post-dispose definite claim.
Branch 1's header claimed exactly two states reach it, but its first bullet was
keyed on `ackLabelObserved` alone — which is computed independently of
`state.disposed`, so a disposed arm with an ack and a stash satisfied the bullet
while landing in branch 2. The bullet's stated reason was also false post-dispose,
where there is no replay buffer left to replay. Qualifying it as the ALIVE case
makes the two bullets the branch predicate verbatim.

Also rephrases "asserts no stash", which read as the opposite of its intent.
Comment-only, zero line delta.
…le claim

- effect-executor.ts's ORDER RULE comment (and its second, duplicate
  reference to the same set later in the same file) asserted an exact count
  of reducer-side sites that put a user-visible signal ahead of a triage
  log. The count under-counted in both directions: a disposed signal-first
  arm was missing from it, and it omitted deliberate log-first exceptions.
  Replaced both with wording that names the closed set of sites citing the
  rule by name and explicitly disclaims closure in either direction, rather
  than asserting a count that goes stale on the next edit.

- Narrowed two "the panel's `step` settles unconditionally" claims (also in
  effect-executor.ts) to name the one case where that does not hold:
  `isEditApplied` itself throwing before `runEffects` runs
  (host-session-step.ts:390).

- test/extension/session/host-session-core.test.ts: replaced a comment
  claiming a specific wording assertion is uniquely the one that fails
  against the old condition with one describing which assertion actually
  distinguishes right from wrong, since `expect` throws at the first
  failure and the earlier framing no longer held once measured.

Comment-only; no behaviour, assertion, or type change.
Pure reflow after the census de-quantification left one line at 93 columns
while its neighbours sat at 65-85. No word, claim, or code changed.
@mtskf
mtskf merged commit f50a96c into main Sep 10, 2026
2 checks passed
@mtskf
mtskf deleted the fix/release-write-lock-after-throwing-settlement branch September 10, 2026 20:54
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.

1 participant