Repository navigation
Take the state lock on the next write, not on a poll (#1027) - #1032
Conversation
e771270 to
4b97bdd
Compare
|
Verdict: INC at 4b97bdd 先说核过没问题的部分: 但 ① adopt 发生在 caller 已经改完 state 之后,于是"这次要写的那一笔"被丢掉,而 改法( ② 即使没有待写改动,这次"落地"也是一次空写: adopt 之后 非阻断两条:
Ran: $ TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/harness-resolve.mjs --test test/home-lock.test.js test/store.test.js test/legacy-state.test.js test/state-journal.test.js
# 34 / 34(core/,4b97bdda)
$ node scripts/ds-bot-patches.mjs check
# ok 10 patches apply cleanly in order / ok core/ equals ds-bot/ + patches/ds-bot/(pin 因本机无 git 跳过)
$ 探针:read-only 窗口 → 对方写 rev8 + prefs.theme=light 并删锁 → rt.state.prefs.theme='dark' → await rt.save()
# not ok:文件里是 'light';save() 正常 resolve(改动被 adopt 丢掉)
$ patchcheck(0002) 目视 + check
# 分段里的 stuck 块与 git 切法不同,仅噪声 |
A window that loaded while another dsh held state.lock stays read-only in memory (holder), and only refresh() ever re-claimed. A save that arrived after the other host closed was dropped until some later state read refreshed it. save() now claims the free lock itself, then writes what this window has. It never adopts there: adopt() replaces rt.state whole, so doing it inside save() discarded a change the caller had already made and wrote the other host's copy back (#1032). The write-path entry that must not write stale memory is rt.mutate(fn): it refreshes (taking over and adopting the other host's file) before the change is made, then saves.
4b97bdd to
80b701c
Compare
|
Verdict: PASS at 80b701c Both INC items from ① ② no adopt-then-rewrite. With no adopt in Also checked:
Ran: $ cd core && TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/harness-resolve.mjs --test test/home-lock.test.js test/store.test.js test/legacy-state.test.js test/state-journal.test.js
# tests 36 / # pass 36 / # fail 0 / # skipped 0
$ # same tree, core/src/host/store.js from base b9a7533b, home-lock.test.js only
# tests 12 / # pass 9 / # fail 3 — the three #1027/#1032 cases
$ node scripts/ds-bot-patches.mjs check
# ok 10 patches apply cleanly in order / ok core/ equals ds-bot/ + patches/ds-bot/Non-blocking, but two of them want a follow-up issue rather than a comment:
|
) Rebased on 02a77af (#1032, #1040). When an invite info names no `bot_id`, restoreAcceptedInviteGrant fell back to the inviter's home. grant_bot_to_friend sends `friend_bot_id` / `friend_owner_id` only (mcp-client.js:466-478) and the REST body drops the home, so that fallback could only fail quietly and leave the accept one-way. The peer id now comes from `list_friends` (`peer.bot_id`), asked of the joining Bot and then the home; a home no listed friend names is not granted at all and `sharedBack` stays absent. Two tests pin both halves.
Owner: Dev3
Reviewer: Reviewer
Written-by: Dev3
Fixes #1027
Tip SHA: 80b701c
Reproduce
The merge_queue
Testfor #1023 failed atcore/test/home-lock.test.js:184—a reading host follows the other host's keys, never writes them, and keeps them after taking over— wheresecret-setafterrmSync(state.lock)answeredok: false(whole test 31.4 ms).That failure did not reproduce here:
test/home-lock.test.js320 times across 8 parallel workers, plus 200 plain and 400 interleaved stress iterations (astateread in flight whilestate.lockis removed, then the write) — 0 failures; areadOnly()trace over all of them saw no refusal. The failing home in that CI log (/tmp/ds-bot-lock-MbTyGx) prints the initial "open in another dsh" line but never "the other dsh has closed; this one now saves the Bot team", which the takeover branch logs, and that run's coverage marksstore.js:424-426(writable()'s reclaim) andhome-lock.js:86(claim()'s exhausted loop) as never executed.What does reproduce, on every run, is the write path the issue is about: a window that loaded while the other dsh held
state.lockhasholderset; the other host saves and closes; the next save here — a plugin, a scheduled task, a delivery, anything no request drives — is dropped bywritable(), and the change it carried never reaches the file. Nothing refreshes until astateread happens to arrive.What changed
core/src/host/store.js:takeover():claimLock()and report whether the lock is free now (a live holder still answers, so Read-only host: flybot/* reads rejected, social timer ignores writer lock (PR #2 review) #14 is unchanged).save(): whileholderis set, take the free lock and write what this window has, instead of returningwritinguntouched. No adopt here —adopt()replacesrt.statewhole, so doing it insidesave()discarded a change the caller had already made and wrote the other host's copy back (INC on this tip).rt.mutate(fn): the write-path entry for changes made outside a request —refresh()(which takes over and adopts what the other host left) before the change is applied, thensave().core/test/home-lock.test.js:start()builds the runtime withcreateRuntime+provideFlyBot(whatapply()does) and hands it back ascall.rt, so a test can drive a save no request drives; three cases below.patches/ds-bot/0002-flybot-api-host.patch,patches/ds-bot/0001-core-fixes.patch: the two file sections recomputed fromdiff -u ds-bot/<f> core/<f>through the repo's ownnormalizeDiff(old-side numbering as the hint, the original@@ … @@ <function>suffixes kept), becausegenerateneeds git and this checkout has none.checkproves both sections still rebuildcore/byte for byte.docs/subsystems/core-host.md: one bullet under Team state and access on the one-writer lock, takeover on the next write, andmutate()as the adopt-before-change entry (store.js is undercore/src/host/, owned by that doc).changelog.d/1027-takeover-on-next-write.fixed.md.What I ran
node --import ./test/harness-resolve.mjs --test test/home-lock.test.js test/store.test.js test/legacy-state.test.js test/state-journal.test.js→ 36 tests, 36 pass, 0 fail, 0 skipped (basebb96c5f9+ this tip).test/home-lock.test.jsalone, 40 runs after the change → 0 failures.node scripts/ds-bot-patches.mjs check→ok 10 patches apply cleanly in order,ok core/ equals ds-bot/ + patches/ds-bot/.node .github/scripts/changelog.mjs check→ 2 fragments well formed.Red without the source change
Pristine
core/src/host/store.jsatbb96c5f9with the new cases in place — three separate reds:Knives, each failing only its own case:
mutate()without the leadingrefresh()→ case 3 fails atprefs.theme('light'expected, the other host's copy is gone);takeover()made to never claim → cases 1 and 2 fail (state.locknever created;'light'still on disk instead of'dark').An earlier tip of this PR adopted inside
save()to satisfy the same case 2 — that tip is exactly the failure Reviewer's probe found (the caller's'dark'was replaced by the other host's'light') and is gone.Not run
core/suite: this checkout has nonode_modules, soesbuildis missing (ERR_MODULE_NOT_FOUND). Full-suite result is CI's.mutate(). The fire-and-forget writers that mutate inline and thenvoid rt.save()(delivery.js,agents.js) now land thanks to thesave()half; moving them ontomutate()is a follow-up, since it means moving each mutation inside the callback.Risk
save()in a stale window writes this window's memory; a change the other host made to a field this window never read is not carried over. That is the single-writer model (save()writes the state it has), andmutate()is the entry that avoids it — but it is a real difference from "adopt everything", so it is written down rather than implied.takeover()answers the holder,save()warns once and writes nothing (Read-only host: flybot/* reads rejected, social timer ignores writer lock (PR #2 review) #14).mutate()is new API onrt, covered by one case; it runsrefresh()then the callback thensave(), so a callback that throws leaves memory changed and unsaved, as every existing mutate-then-save path does.refresh()/claim()at the point of refusal rather than the write path.