Skip to content

Pending messages: sending a held copy again is not coordinated between tabs, and its not-confirmed mark can be lost #541

Closed

Description

@devswha

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, state held).
  • Tab B restored h as a copy, presses Send now, and saves it as not confirmed.
  • Tab A disconnects or changes pane and runs suspend(). Its refresh() reads B's mark but keeps A's own in-memory row (the merge keeps this tab's server-owned and busy rows), then writes held.

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 unsaved owner.
  • 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

  1. devswha commented on Oct 16, 2026

    @devswha
    OwnerAuthor

    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.

  2. added a commit that references this issue on Oct 17, 2026
    bb9582d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions