Repository navigation
home-lock.test.js:184 flake: a write after the other host releases state.lock can still answer read-only #1027
Description
Owner: Dev3
Owner note: filed by Triage from Reviewer's report on #1023; Reviewer suggested Dev3 because #1012 was the same shape (a wait that gave up early).
What happened
The merge_queue Test run for #1023 (40125343913, entry 7a13f7f1) failed in core/test/home-lock.test.js:158 — a reading host follows the other host's keys, never writes them, and keeps them after taking over — at :184:
true !== false ← actual: false, expected: true
at core/test/home-lock.test.js:184 assert.equal((await call('secret-set', { name: 'MINE', value: 'mine-value-1' })).ok, true)
The whole test took 31.4 ms, so this is not a slow runner. #1023 changes plugins/social only; core/ is byte-identical across the queue entry, the PR head and main 7225692e (db9be7d5 in all three), and the same core/ was green on the PR head at 19:20 — so it is a race in the takeover path, not that pull request. It was requeued once and passed.
The two candidate mechanisms (separate them before fixing)
Every endpoint call refreshes first: core/src/host/channel.js:276 await rt.refresh(), then refuses with bot/read-only when rt.readOnly() is set (:277-279). So a failed secret-set can come from either side, and the assertion cannot tell them apart:
- Endpoint gate —
refresh()(core/src/host/store.js:381-407) did not retake the lock, soreadOnly()(:64-69) still names the other host. The retake lives only inrefresh()'shadHolderbranch (:385-392), never inwritable()(:416-427), which for a cachedholderjust returnswarnReadOnly()— its comment says "retake it when nobody else holds it (its file was deleted)" but the code only handlesholder === undefined. - Save path —
commit()→rt.save()returnsfalsebecausewritable()sawholder !== undefinedand never re-claimed;lock.owns()is false afterrmSync(state.lock)at:183.
Both are timing-dependent on the lock file's presence during the two reads inside homeLock.claim() (core/src/host/home-lock.js:60-90: owns() → writeFileSync(..., { flag: 'wx' }) → on EEXIST read() → live()), and live() has a 60 s staleMs for another host — a leftover lock whose mtime the test never bumps is live by that rule, which is a second way to stay read-only.
Expected
A window that is read-only because the other dsh holds state.lock becomes writable on its next write once that lock file is gone — not on some later poll, and not only when a state call happens to adopt. If the design really is "takeover is refresh-driven", then say so in docs/subsystems/core-host.md and name the poll that drives it; a user closing the other window and getting "open in another dsh" is a bug either way.
Acceptance
- Instrument first: make the failing call tell the two mechanisms apart (log whether
readOnly()was set at the gate orsave()returned false), then reproduce: runcore/test/home-lock.test.jsin a high-frequency loop (≥300×) and, if it does not fail, add the interleaving the CI saw —rmSync(state.lock)between thestaterefresh and thesecret-set— with a delay sweep; record the rate and the mechanism. - Fix the mechanism the measurement names, in product code if it is (1)/(2): a write that finds the lock free must re-claim and adopt before saving, so the first write after takeover succeeds rather than a later poll landing it. Keep the reader semantics (
#14): while the other host is live this host still never writes. - Test: the new case fails on today's code and passes after — state which line it hits.
- No
changelog.d/fragment unless the fix changes user-visible behaviour (then one,fixed);Docs-not-needed:in the commit message ifarch-docsfires without a doc change.
Not the same as
- merge_group test run fails in follow-through.test.js openRoomTurn ("condition not reached") and kicks an unrelated PR out of the queue #1012 (closed,
#1014):waitForgave up after 20000 rounds on a real clock — fixed by a deadline. This one is not a wait running out: the test's own call returnedok: false. - Read-only host: flybot/* reads rejected, social timer ignores writer lock (PR #2 review) #14 / the
home-lockunit tests: those pinclaim()itself and are green.
Activity
Birfy commentedon Oct 24, 2026
Claiming as Dev3. Branch
dev3/fix/1027-home-lock-reclaim, cut from current maindb56b817.Order Triage asked for, and why it matters: the two ways
secret-setcan answerok:falseare still indistinguishable in the log, so instrumenting comes first.channel.js:276rt.refresh()then the entry refusal (:277-279) versusstore.jswritable()returning false insidesave(). One run of the failing case names which one happened.rmSyncofstate.lockbetween two reads, so the takeover is the trigger and not the wall clock.writable()(store.js:416-427) is where the comment promises a re-claim that the code only does forholder === undefined; claiming lives inrefresh()(:381-407). The next write should re-claim, not wait for a poll.Red first, then the smallest change that makes the same loop pass; knife = put the old
writable()back and the loop goes red again.Take the state lock on the next write, not on a poll (#1027)
Take the state lock on the next write, and adopt before a change (#1027)
Birfy commentedon Oct 25, 2026
done — close-out checked on merge commit
02a77af6(current main).#1027isclosed/completed;#1032merged with merge commit02a77af6, head is still the PASS'd tip80b701ce.changelog.d/1027-takeover-on-next-write.fixed.mdis on main.docs/subsystems/core-host.md:116carries the "one writer per home" bullet in the new wording: takeover happens on the next write, not on a poll;rt.mutate(fn)is the adopt-before-change write entry;save()itself never adopts.core/src/host/store.js:434takeover()(claims the lock, adopts nothing),:450save()claims then writes this window's memory,:494mutate(change)= refresh (take over + adopt) → change → save, exported at:578;core/test/home-lock.test.jscarries the three cases (+65);patches/ds-bot/0001/0002carry the mirror.agent-workingremoved from#1027,agent-prremoved from#1032.The two non-blocking findings from the review are now issues instead of comment-only: #1041 (a
save()takeover logs nothing) and #1042 (the changelog promisesmutate(), which has no production caller yet).Announce a state-lock takeover from save(), not only from refresh() (#…