Repository navigation
Description
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
- Start TUI A and TUI B with the same pin list.
- In B, pin a session. B writes the updated list.
- In stale A, pin/unpin another session or delete a session.
- 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:
opencode/packages/tui/src/context/local.tsx
Lines 411 to 493 in 826d9ad
function createSession() { const [sessionStore, setSessionStore] = createStore<{ ready: boolean pinned: string[] }>({ ready: false, pinned: [], }) const filePath = path.join(paths.state, "session.json") const state = { pending: false, } function save() { if (!sessionStore.ready) { state.pending = true return } state.pending = false void writeJsonAtomic(filePath, { pinned: sessionStore.pinned, }) } readJson<unknown>(filePath) .then((x) => { if (!x || typeof x !== "object") return const pinned = (x as Record<string, unknown>).pinned if (Array.isArray(pinned)) setSessionStore( "pinned", pinned.filter((item): item is string => typeof item === "string"), ) }) .catch(() => {}) .finally(() => { setSessionStore("ready", true) if (state.pending) save() }) const slots = createMemo(() => { const existing = new Set(sync.data.session.filter((x) => x.parentID === undefined).map((x) => x.id)) return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9) }) function prune(sessionID: string) { batch(() => { if (sessionStore.pinned.includes(sessionID)) { setSessionStore( "pinned", sessionStore.pinned.filter((x) => x !== sessionID), ) } save() }) } event.on("session.deleted", (evt) => { prune(evt.properties.info.id) }) return { get ready() { return sessionStore.ready }, pinned() { return sessionStore.pinned }, slots, isPinned(sessionID: string) { return sessionStore.pinned.includes(sessionID) }, togglePin(sessionID: string) { batch(() => { const exists = sessionStore.pinned.includes(sessionID) const next = exists ? sessionStore.pinned.filter((x) => x !== sessionID) : [...sessionStore.pinned, sessionID] setSessionStore("pinned", next) save() }) }, opencode/packages/tui/src/util/persistence.ts
Lines 22 to 33 in 826d9ad
export async function writeJsonAtomic(filePath: string, value: unknown) { await mkdir(path.dirname(filePath), { recursive: true }) const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp` await Bun.write(temporary, JSON.stringify(value)).catch(async (error) => { await rm(temporary, { force: true }).catch(() => undefined) throw error }) await rename(temporary, filePath).catch(async (error) => { await rm(temporary, { force: true }).catch(() => undefined) throw error }) }
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
rareboe commentedon Aug 26, 2026
I'd like to take this one.
Confirmed the analysis:
createSession()inpackages/tui/src/context/local.tsxreadssession.jsononce intosessionStore.pinned, andsave()hands the whole in-memory array to a fire-and-forgetwriteJsonAtomic(). 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 pinningaand one pinningb, leaves the file as["b"].Planned fix, following the pattern
context/kv.tsxalready uses forkv.json:sessionID+ pin/unpin) instead of a snapshot, and apply it to the file's current contents insideFlock.withLockpendingflag goes awayWorth noting
kv.tsxlocks but still writes its own snapshot, sokv.jsonhas 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.
github-actions commentedon Oct 25, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.