Repository navigation
Description
Found by Codex adversarial delta passes during the review of #520 (bridge-owned pending messages), on head d76fb24. The single-tab paths were checked and hold; what is left needs two tabs on the same pane, or storage that fails part-way. Reported here so it is not lost. It does not block #520.
Background
The browser keeps a backup of each pending row in localStorage (src/lib/pendingMessages.ts). A row restored as held after a reload is no longer the bridge's: its Send now is a fresh immediate submission with no pending id and no server receipt (actOnPending in src/components/PaneTerminal.tsx). To keep a reload from offering it twice, unconfirm() saves the row as not confirmed before the submission leaves, and fail() / removeCopy() settle it afterwards. The busy flag and the unconfirmed set are per tab, in memory.
Gaps
1. A tab that still owns the row on the bridge can write held over another tab's mark
- Tab A owns the server-held row
h(serverOwned, stateheld). - Tab B restored
has a copy, presses Send now, and saves it as not confirmed. - Tab A disconnects or changes pane and runs
suspend(). Itsrefresh()reads B's mark but keeps A's own in-memory row (the merge keeps this tab's server-owned and busy rows), then writesheld.
B's submission may already have been typed; a reload offers h again. A tab whose owner is unsaved skips refresh() altogether and can overwrite the mark later, when storage works again.
2. Two tabs can claim the same held copy at once
Both tabs read h as held and both call begin(). Each refresh() keeps the tab's own busy row, so each saves its mark, unconfirm() returns true in both, and both submit. Nothing on the server can tell the two submissions are the same message.
3. A failed read counts as "no backup"
When the mark cannot be saved, unconfirm() re-reads storage and refuses only if the row is still saved as held. If that read throws as well, it returns true and the send goes out, although a held backup written earlier may still be there when storage recovers.
4. A send that never left the browser ends as not confirmed
HerdrSocket.submit() (src/lib/ws.ts) waits for the connection's snapshot. If the connection closes during that wait it answers disconnected before any frame was sent. actOnPending treats disconnected as possibly typed, so the row is saved as not confirmed and offers only Copy and Discard, although nothing can have reached the pane.
Possible fix
- Serialize the claim across tabs (a Web Lock on the row), and read the saved row again inside the lock before marking and submitting.
- Keep "a resend is in flight" as its own saved field, separate from the row's state, and clear it only with that resend's outcome; apply it in every merge, also for an
unsavedowner. - Return false from
unconfirm()when storage cannot be read. - Give the pre-transmission disconnect its own code and list it in
submitNotTyped.
Tests missing
Overlapping claims from two stores, a live owner's suspend() after another store's mark, a storage port whose reads throw, and the skipped submission plus cleanup in the component when unconfirm() returns false.
Activity
feat(chat): simplify send controls and queued follow-ups (#520)
fix(chat): send a held copy once across tabs (#1088)
devswha commentedon Oct 16, 2026
Gaps 1-3 fixed by #1088 (cde82e5): the bridge now dedupes held-copy resends across tabs. Remaining: gap 4. A send that drops before any frame leaves the browser (
DISCONNECTED) still marks the row "Not confirmed" instead of keeping it held.fix(chat): keep a held message held when its Send now dropped before …