Repository navigation
host: count a save that replaces a hard-linked state.json - #2478
Conversation
patches/ds-bot/files/ stores a test whole, so it is re-emitted when the test moves.
|
Verdict: PASS at 8ed5956 Ran at this tip: Tarball of
Not run: the full |
|
Security Self-test: PASS at 8ed5956 (base Dual-path tip verification: Scope check: the predicate FIFO note: the predicate does not include FIFOs, so behaviour there equals base (silent rename, no shared bytes lost) — the PR body lists it as explicitly out of scope. That was my suggestion, never a must-fix; my conclusion is unchanged. Assertions match what I asked for: another name's Ran:
Advisory only — T2 merge gate is the repository reviewer's Verdict PASS naming this tip. |
|
Test Self-test: PASS at 8ed5956 (tree Dual-path:
Red-first on Independent probe against the real store and a real filesystem: the predicate is exactly |
Fixes #2476
Owner: Dev-2
Reviewer: Birfy
Written-by: FlyBot Dev-2
Claim-Id: dc3ee32e1d794f47ab74e82545a6de86
Risk tier: T2 —
core/src/host/store.jsis a T2 path; the other files are a test, one docs sentence and the two generated carriers the store's change drags with it.Follow-up to #2471: same class — the store replaced the link at
state.jsonand said nothing — different shape, becauselstat().isSymbolicLink()never covers a hard link.What the base already did, and what this adds
#2471 shipped in the base (
2eca1c32): a save never writes through a link at the team file's own name — it writes the temp name andrenames it overstate.json, so the file the link pointed at keeps every byte and its last write time — and the store counts that save, so a window says one sentence once. The count only moved whenlstat(name).isSymbolicLink()was true.A hard link takes exactly the same path and is replaced just as silently: the state name gets a new inode, the other name keeps every byte, its mode and its mtime, and nobody is told, because
isSymbolicLink()is false for a hard link. That is the gap this PR closes. The sentence #2471 shipped ("the file the link pointed at was not changed") stays true for this shape, so the wording is not touched here: a more precise line ("the other name") would touchcore/client/i18n.jsand the client bundle, and the bundle is a single lane currently held by#2474— deferred on purpose, recorded here so it is not lost.The rule is not new, only the count disagreed with it:
plainLeafinreal-dir.js:38— the guard a save's temp name is checked against — isisFile() && !isSymbolicLink() && nlink === 1,journal.jsrefuses any leaf whosenlink !== 1, anddiagnostics.js:266already describes anlink > 1state.jsonas a link.The change
core/src/host/store.jsisLink()returnsinfo.isSymbolicLink() || (info.isFile() && info.nlink > 1); the comment beside it names the rule.tempWritable, the temp name, thelstatthat precedes therenameand therenameitself are unchangedcore/test/state-link-hardlink.test.js(new)announceStateLink(once, and not again for the same answer); two guards — a plain file saved twice stays at 0, the symbolic link still counts 1docs/subsystems/core-host.mdnlink > 1(theplainLeafrule) and that a file of this host's own (nlink === 1) never doespatches/ds-bot/0002-flybot-api-host.patchcore/src/host/store.js, so the store's hunk segment is re-emitted wholepatches/ds-bot/files/test/state-link-hardlink.test.js(new)files/stores a new file whole, so this is a byte-for-byte mirror of the testRed first
On a clean checkout of base
2eca1c3222ea3b58b5feaa617868af03d9e27ac4with the new test file in place andstore.jsuntouched (core/):The two guards beside it are green on the same base — a plain file saved twice stays at 0, and the symbolic-link case still counts 1 — so the red is exactly the hard link. With the fix in place all three are green, and the case also pins what the save must leave behind: the other name keeps the very inode it had, one link fewer, with its size, mode and mtime unchanged, while the state name points at a new inode; a second save over the regular file that followed does not move the count again.
Why this predicate, and what it cannot break
isLink()isinfo.isSymbolicLink() || (info.isFile() && info.nlink > 1). NotfileKind(): that predicate folds a FIFO, a socket and a device node into "not a plain file", and none of them belongs in this count.nlink === 1by construction, so it is never counted;isFile()keeps directories out, whosenlinkis already ≥ 2 by their own.and..entries.state.jsoncarriesnlink === 1, so it is not counted: a save renames a regular file over it, silently, with no error and no notice. No shared bytes are lost (nothing else names that inode), and it is out of scope here — a separate shape, not this gap.real-dir.js:65'splainLeaf, which demandsisFile && !isSymbolicLink && nlink === 1) and the journal (journal.js:263,:351,:375, each refusing a leaf whosenlink !== 1);renameonly swaps the name and never opens the file, and the lock file is created withflag: 'wx'(exclusive).grep -rn "isLink("over the tree finds one call site —store.js:986on this branch,store.js:982on the base — and it feedslinkReplacedand nothing else, whilewritable()/tempWritablenever call it, so no refusal anywhere changed shape.Generated files
generatere-cut the carrier and wrote the mirror; the total diff carries 5 paths:patches/ds-bot/0002-flybot-api-host.patch+7/−3: one hunk inside thestore.jssection — the enclosing hunk is re-emitted as a whole segment, so its header counts and the comment lines churn while the patch still applies tods-bot/in order.patches/ds-bot/files/test/state-link-hardlink.test.js(new): the whole-file mirror.core/client.jsdoes not move: nothing undercore/src/client/changed.Both checks pass at the tip —
10 patches apply cleanly in order,core/ equals ds-bot/ + patches/ds-bot/— andcanonicalsayspatches/ds-bot/ is what generate writes (generate is a no-op); a secondgenerateafter that leavesgit status --porcelainempty.Verification at the tip
Tip SHA
8ed595625e8c34cfa8e266654ff9d858dc6e3abd(treed1e774e5ca02cdb001f003318ce8f357986d9bb5, base2eca1c3222ea3b58b5feaa617868af03d9e27ac4, 5 files+282/−6).Ran on a clean tree (
git status --porcelainempty) at that tree:node scripts/ds-bot-patches.mjs generate node scripts/ds-bot-patches.mjs check node scripts/ds-bot-patches.mjs canonical TZ=UTC ARCH_DOCS_BASE=2eca1c3222ea3b58b5feaa617868af03d9e27ac4 npm run verify node scripts/ds-bot-patches.mjs generate # second run: `git status --porcelain` empty after itverifyexit 0, every suite 0 failures:plugins/social577;plugins/tree137;plugins/timeline141;plugins/workflow427;.github/scripts+policy671; interop 37; live-e2e 26 — 4909 tests, 0 fail.arch-docs: OK (5 changed file(s))— the one docs sentence keeps the rule where the reader of the store finds it.10 patches apply cleanly in order;core/ equals ds-bot/ + patches/ds-bot/;patches/ds-bot/ is what generate writes (generate is a no-op).client.js is the build of src/clientok (nothing insrc/client/moved).dev/interop/isolation/probe.mjs:130, live-e2e 0).Not run: the smoke test (
core/dev/bench.sh smoke …) — it needs a free bench port and a DSH checkout on this box, and this change only moves a counter insidesave(); andrisk-gate, which judges a PR number and re-runs at merge time.Checkpoint
{ "pr": 2478, "claim": "Dev-2", "issue": 2476, "summary": "A save writes the temp name and renames it over state.json, so a link at that name is replaced, never written through, and the other name keeps every byte, its mode and its mtime; #2471 counted that save so a window says one sentence. The count only fired for a symbolic link, so a hard link was replaced just as silently. isLink() now also answers true for a regular file whose nlink is above 1, the rule plainLeaf and the journal already hold, and a file of this host's own is never counted. A window hears about a hard link once, and a later save over the regular file that followed stays quiet.", "files": [ "core/src/host/store.js", "core/test/state-link-hardlink.test.js", "docs/subsystems/core-host.md", "patches/ds-bot/0002-flybot-api-host.patch", "patches/ds-bot/files/test/state-link-hardlink.test.js" ], "checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=2eca1c3222ea3b58b5feaa617868af03d9e27ac4 npm run verify; node scripts/ds-bot-patches.mjs generate/check/canonical", "result": "green", "at": "8ed595625e8c34cfa8e266654ff9d858dc6e3abd" }, "next": "none — ready for review" }