Pinned sessions can be lost when multiple TUI instances write session.json · Issue #44736 · anomalyco/opencode · GitHub
Skip to content

Pinned sessions can be lost when multiple TUI instances write session.json #44736

Open

Description

@n2k3

Description

Each TUI reads the global session.json pin list once and keeps a private in-memory copy. Pinning, unpinning, or handling session.deleted rewrites the entire file without locking, rereading, merging, or serializing writes. A stale TUI can therefore erase pins added by another TUI.

Reproduction

  1. Start TUI A and TUI B with the same pin list.
  2. In B, pin a session. B writes the updated list.
  3. In stale A, pin/unpin another session or delete a session.
  4. Restart OpenCode.

The pin added in B is gone because A replaced the complete file with its stale snapshot.

Expected

Concurrent TUI instances should not erase unrelated pin updates.

Cause

createSession() loads the file once and each save() passes the full in-memory array to a fire-and-forget writeJsonAtomic() call:

Atomic rename prevents partial files but does not prevent stale writers. There is also no file watcher or close-time merge. Closing a TUI alone does not save; the last completed save wins.

I verified that the relevant persistence code is identical in v1.18.18, v1.18.19, v1.18.20, v1.18.21, and current dev.

Environment

  • macOS
  • Multiple OpenCode TUIs in WebStorm and system terminals
  • OpenCode v1.18.18 through v1.18.21

Activity

  1. rareboe commented on Aug 26, 2026

    @rareboe

    I'd like to take this one.

    Confirmed the analysis: createSession() in packages/tui/src/context/local.tsx reads session.json once into sessionStore.pinned, and save() hands the whole in-memory array to a fire-and-forget writeJsonAtomic(). Nothing rereads, locks, or serializes, so a stale instance replaces the file wholesale.

    I reproduced the loss directly against the current save() semantics — two writers starting from an empty snapshot, one pinning a and one pinning b, leaves the file as ["b"].

    Planned fix, following the pattern context/kv.tsx already uses for kv.json:

    • express each change as an intent (sessionID + pin/unpin) instead of a snapshot, and apply it to the file's current contents inside Flock.withLock
    • chain writes off the initial read so ordering is guaranteed and the pending flag goes away
    • keep the optimistic in-memory update so the UI still responds immediately, then reconcile the store with whatever the merged file ended up containing

    Worth noting kv.tsx locks but still writes its own snapshot, so kv.json has the same stale-writer shape. I'm leaving that alone here to keep the change focused — happy to follow up if you want it covered too.

    PR shortly, with regression tests for the concurrent case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions