codeburn/app/scripts/tray-settings-xproc-worker.ts
iamtoruk a1b4b22f51 fix(windows): close three holes in the cross-process preference lock
writeDockEnabled still did its own read, mutate and atomic write of
windows-dock.json, outside the lock the rest of the module takes. It is
the Node side of the dock's enabled switch, reached from the desktop
app's Sidebar toggle, so a toggle there could still land between the
tray app's read and its rename and erase whatever it had just stored,
which in practice is the rail's placement. It now merges through
patchTrayFile, which takes the lock for the whole cycle; enabled is
already an accepted dock key, so nothing is dropped on the way.

Stale-lock recovery could hand out two owners at once. Both sides did
"if abandoned, unlink and retry", and nothing stood between the check
and the unlink: contender A could already have removed the stale lock
and created a fresh one of its own, and B's unlink then deleted A's
fresh lock, leaving both of them holding the file. Windows' share mode
hides this most of the time, because a lock a live holder still has open
will not unlink at all, but the same protocol runs on Linux and macOS,
where the unlink always succeeds. Removing an abandoned lock is now
itself arbitrated, by the same exclusive create the lock uses, on a
sibling <lock>.takeover file. Only its winner may unlink, and only after
re-checking staleness under that right, so a rival that reclaimed first
keeps the lock it now holds. A takeover file is dropped on every path
out, never held across the wait, and one left behind by a reclaimer that
died is freed by the same staleness rule as the lock itself.

The cross-process test asserted only each side's final value, so it
noticed a lost update only when the last writes happened to collide: on
a deliberately broken lock it passed about one run in three, which is
weak evidence for the property it exists to prove. Each side now writes
a counter that only rises, and a reader thread watches the file for the
whole run. A separate reader is the point: neither writer can see its
own counter rolled back, because in the unlocked cycle each carries the
other side's value forward itself, so what it reads back is never older
than what it last read. With the lock both counters only ever rise in
the file; without it one falls. It now fails on every unlocked run.

Also: a placement that cannot be saved is logged instead of discarded
silently, since losing the lock under contention is a new way for the
rail to revert at the next launch; and the Codex sameLogin comment now
states outright that a replacement document carrying none of the three
identifying fields reads as the same login and is written over, which is
the deliberate side to err on.
2026-09-03 10:28:05 -07:00

56 lines
2.3 KiB
TypeScript

// One party in the cross-process lock test. The Rust side (windows/src-tauri/src/dock.rs, test
// `concurrent_node_and_rust_patches_to_the_dock_file_both_survive`) spawns this with a shared
// home directory, so a real Node process and a real Rust process hammer windows-dock.json at
// once. Each patches its OWN key with a counter that only ever rises; the Rust side watches the
// file from a reader thread, and a counter seen to fall there is a write the other side erased.
//
// It lives in scripts/ rather than electron/ so it is neither bundled into the app nor picked up
// as a vitest test, only run on demand by the Rust test.
//
// argv: <home> <key> <iterations> <barriersDir>
// CB_TRAY_XPROC_NOLOCK=1 replays the same read/modify/write WITHOUT the lock, which is the bug
// the test catches; the Rust side reads the same variable so one env toggles both parties.
import { existsSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { patchTrayFile, readTrayFile, writeFileAtomic } from '../electron/tray-settings'
const [home, key, itersRaw, barriers] = process.argv.slice(2)
if (!home || !key || !itersRaw || !barriers) {
console.error('usage: tray-settings-xproc-worker <home> <key> <iterations> <barriersDir>')
process.exit(2)
}
const iters = Number(itersRaw)
const nolock = process.env['CB_TRAY_XPROC_NOLOCK'] === '1'
const dockPath = join(home, '.config', 'codeburn', 'windows-dock.json')
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
function sleepSync(ms: number): void {
Atomics.wait(sleepBuffer, 0, 0, ms)
}
function waitFor(path: string): void {
const deadline = Date.now() + 60_000
while (!existsSync(path)) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`)
sleepSync(10)
}
}
// Started, but do not race ahead: wait for the go barrier so both loops overlap.
writeFileSync(join(barriers, 'node.ready'), '')
waitFor(join(barriers, 'go'))
for (let i = 0; i < iters; i++) {
if (nolock) {
const current = readTrayFile('dock', home)
// Widen the read/modify/write window so the lost update is easy to provoke.
sleepSync(1)
writeFileAtomic(dockPath, `${JSON.stringify({ ...current, [key]: i }, null, 2)}\n`)
} else {
patchTrayFile('dock', { [key]: i }, home)
}
if (i % 7 === 0) sleepSync(1)
}
writeFileSync(join(barriers, 'node.done'), '')