You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The QWP store-and-forward recovery path treated a damaged .symbol-dict as
unrecoverable in two places, stranding slots that are in fact replayable.
Both stem from keying on the dictionary FILE rather than on what the
surviving frames themselves define.
CursorWebSocketSendLoop ran the torn-dictionary guard unconditionally but
gated accumulateSentDict, the reconnect catch-up and the mirror seed on
engine.isDeltaDictEnabled(). That flag goes false exactly when a disk
slot's dictionary fails to open -- fd exhaustion, a read-only remount,
ENOSPC -- which is precisely when the frames already on disk are still
deltas. With the mirror gated off, sentDictCount froze at 0 while the
ungated guard kept comparing against it, so the frame at deltaStart=1
latched a terminal and the background drainer quarantined the slot behind
a .failed sentinel. Yet replaying that sequence from id 0 rebuilds the
dictionary on the server contiguously and drains perfectly. The loop now
always mirrors, always catches up and always guards: the mirror is its
model of what the SERVER holds, so it must track every mode.
isDeltaDictEnabled() now governs only what the producer emits and
persists.
seedGlobalDictionaryFromPersisted threw whenever the surviving frames
out-reached the persisted dictionary. That bricked Sender.build() for
slots the drainer replays fine, and permanently: build()'s catch releases
the slot lock, but the sender that would have hosted the orphan drainer is
the one that just failed, so the retry recovers the same slot and throws
again. Relaxing the guard would have been worse than the bug -- with an
empty dictionary the producer hands its next symbol id 0, an id the frames
already define, and accumulateSentDict skips it, so the next catch-up
replays the stale symbol and misattributes it silently. The producer now
seeds from the dictionary's intact prefix AND THEN from the surviving
frames' own delta sections, through collectReplaySymbolsAbove
(MmapSegment -> SegmentRing -> CursorSendEngine). Those are the same two
sources, in the same order, that the send loop's mirror is built from, so
the producer's delta baseline and the mirror's coverage land on the same
number by construction.
A genuine gap -- ids introduced by frames since acked and trimmed away --
still fails clean, and now on exactly the condition the send loop's replay
guard trips on, so producer and drainer agree on which slots are
recoverable.
The two cap-gap settle-budget tests derived their retriable loop bound
from MAX_CATCHUP_CAP_GAP_ATTEMPTS itself, so a regression of that constant
to 1 -- the very bug they name -- ran the loop zero times and let the test
pass green. They now pin the budget against a literal.
Tests: an untrimmed unopenable-dictionary slot must replay gap-free; a
torn (subset) and a wholly lost dictionary must rebuild from the surviving
frames and place a new symbol above the recovered tip. The two
watermark-stamped siblings still fail clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0 commit comments