Skip to content

Commit a4e8363

Browse files
fix(safe-write-json): lock the key every other writer to the file uses
The required ubuntu arm died in src's test:coverage:misc lane on 51 tests in four specs. The guard spelled its lock key from the path the caller gave, while src/core/tools/guardedWrite.ts locks the key resolveLockKey produces and says so in its own comments. A write through an alias therefore queued behind a lock no other writer to that file holds, and the guard's agreement check then rejected the write it had just approved. - utils/safeWriteJson.ts: resolve the lock key before taking the lock, and commit the rename onto the resolved target rather than the caller's spelling. - utils/__tests__/safeWriteJson.lockKey.spec.ts: restate the read ledger to the readings the repaired guard actually takes. - utils/__tests__/safeWriteJson.test.ts: name the leftover prefix the guard writes, run the mock undo in order instead of hoisted above the imports, and measure the follow-on write against the stage list the guard reports. - integrations/editor/__tests__/DiffViewProvider.spec.ts: observe the paths these fixtures publish, which is the state the guarded publish requires. Negative control: reverting the guard lines brings back 49 failures in the same four files. With the repair the misc lane runs 2253 passed, 21 skipped, exit 0, where it printed 51 failed at this head.
1 parent fbec995 commit a4e8363

4 files changed

Lines changed: 85 additions & 29 deletions

File tree

‎src/integrations/editor/__tests__/DiffViewProvider.spec.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,12 @@ describe("DiffViewProvider", () => {
16861686
}
16871687

16881688
beforeEach(() => {
1689+
// The guarded publish these rows run through looks the target up in the task's
1690+
// observation registry before writing it, so the fixture has to record the read and
1691+
// the version token the guard recomputes. These rows are about the decisions around
1692+
// the publish - tabs, focus, scroll, finalization - not about the guard refusing it.
1693+
mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1")
1694+
vi.mocked(computeVersionToken).mockResolvedValue("v1")
16891695
// Private members are set via bracket notation (spec convention).
16901696
// Reset the focus the revert helper reads: a test that sets it must not leak
16911697
// into the next one, where cleanup could restore a stale editor.
@@ -3446,6 +3452,15 @@ describe("DiffViewProvider", () => {
34463452
})
34473453

34483454
describe("preEditScrollLine capture and restore", () => {
3455+
beforeEach(() => {
3456+
// The guarded publish these rows run through looks the target up in the task's
3457+
// observation registry before writing it, so the fixture has to record the read and
3458+
// the version token the guard recomputes. These rows are about the decisions around
3459+
// the publish - tabs, focus, scroll, finalization - not about the guard refusing it.
3460+
mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "v1")
3461+
vi.mocked(computeVersionToken).mockResolvedValue("v1")
3462+
})
3463+
34493464
it("should capture scroll line from visible editor at open() time", async () => {
34503465
const mockEditor = makeTextEditor({
34513466
document: makeTextDocument({ uri: makeUri(`${mockCwd}/scroll.ts`) }),
@@ -3599,6 +3614,16 @@ describe("DiffViewProvider", () => {
35993614
describe("userTouchedDocument close/keep behavior", () => {
36003615
const mockTargetPath = `${mockCwd}/mock-target-file.ts`
36013616

3617+
beforeEach(() => {
3618+
// The guarded publish these rows run through looks the target up in the task's
3619+
// observation registry before writing it, so the fixture has to record the read and
3620+
// the version token the guard recomputes. These rows are about the decisions around
3621+
// the publish - tabs, focus, scroll, finalization - not about the guard refusing it.
3622+
mockTask.observationRegistry.observe(`${mockCwd}/mock-target-file.ts`, "v1")
3623+
mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "v1")
3624+
vi.mocked(computeVersionToken).mockResolvedValue("v1")
3625+
})
3626+
36023627
const buildActiveDiffEditor = () =>
36033628
makeTextEditor({
36043629
document: makeTextDocument({
@@ -4796,6 +4821,16 @@ describe("DiffViewProvider", () => {
47964821
})
47974822

47984823
describe("userTouchedDiffEditor keep/close behavior", () => {
4824+
beforeEach(() => {
4825+
// The guarded publish these rows run through looks the target up in the task's
4826+
// observation registry before writing it, so the fixture has to record the read and
4827+
// the version token the guard recomputes. These rows are about the decisions around
4828+
// the publish - tabs, focus, scroll, finalization - not about the guard refusing it.
4829+
mockTask.observationRegistry.observe(`${mockCwd}/mock-target-file.ts`, "v1")
4830+
mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "v1")
4831+
vi.mocked(computeVersionToken).mockResolvedValue("v1")
4832+
})
4833+
47994834
const mockTargetPath = `${mockCwd}/mock-target-file.ts`
48004835

48014836
const buildActiveDiffEditor = () =>

‎src/utils/__tests__/safeWriteJson.lockKey.spec.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,14 @@ describe("safeWriteJson lock key under a peer commit", () => {
166166
// The lock key is the key every other writer to this file uses, so the caller
167167
// queued behind the peer instead of failing before the lock.
168168
expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent)
169-
// After the lock: the strict publish resolution, the key/publish-target agreement check
170-
// (resolve of the publish target plus canonicalization of its directory), the re-resolution of
171-
// the staging directory twice - the approval and the re-verification before anything is staged -
172-
// then safeWriteText's two
173-
// staging-path lstat checks. None of them changes which lock the caller queued behind.
169+
// Before the lock the key walk fails once (the peer has the referent renamed away), reports the
170+
// link by lstat and canonicalizes the parent, and the caller queues on the referent. After the
171+
// lock the publish target is resolved strictly, the agreement check resolves it and canonicalizes
172+
// its directory, and the staging directory is resolved twice - the approval and the
173+
// re-verification. The two staging-path lstat checks this row used to count are not reached:
174+
// with the key spelled as the resolved target, the approved directory and the target's parent are
175+
// one canonical directory from the first reading, so safeWriteText has no divergence to measure.
176+
// None of these readings changes which lock the caller queued behind.
174177
expect(order).toEqual([
175178
"resolve-failed",
176179
"lstat",
@@ -182,9 +185,6 @@ describe("safeWriteJson lock key under a peer commit", () => {
182185
"resolve",
183186
"resolve",
184187
"resolve",
185-
"resolve",
186-
"lstat",
187-
"lstat",
188188
])
189189
expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" })
190190
})

‎src/utils/__tests__/safeWriteJson.test.ts‎

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -572,9 +572,11 @@ describe("safeWriteJson", () => {
572572
const content = await readFileContent(currentTestFilePath)
573573
expect(content).toEqual(newData)
574574

575-
// The orphaned backup is still on disk because its deletion failed.
575+
// The orphaned backup is still on disk because its deletion failed. It is named from the
576+
// target's basename, which is the prefix the leftover scan reads.
576577
const entries = await fs.readdir(tempDir)
577-
expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true)
578+
const backupPrefix = `.${path.basename(currentTestFilePath)}.bak_`
579+
expect(entries.some((entry) => entry.startsWith(backupPrefix))).toBe(true)
578580

579581
vi.mocked(fs.unlink).mockRestore()
580582
})
@@ -621,7 +623,10 @@ describe("safeWriteJson", () => {
621623

622624
// Clean up
623625
await fs.unlink(lockTestFilePath).catch(() => {}) // Ignore errors if file doesn't exist
624-
vi.unmock("proper-lockfile") // Ensure the mock is removed after this test
626+
// vi.unmock is hoisted above the imports, so it would run before the doMock above and
627+
// leave the wrapper installed for every later import; doUnmock runs here, in order, the
628+
// way this file's other capture/undo pairs spell it.
629+
vi.doUnmock("proper-lockfile") // Ensure the mock is removed after this test
625630
})
626631
test("should release lock even if an error occurs mid-operation", async () => {
627632
const data = { message: "test lock release on error" }
@@ -695,22 +700,32 @@ describe("safeWriteJson", () => {
695700
const backups = (await fs.readdir(tempDir)).filter((entry) => entry.includes(".bak_"))
696701
expect(backups).toHaveLength(1)
697702
expect(await readFileContent(path.join(tempDir, backups[0]))).toEqual(initialData)
703+
// The counter belongs to the test, not to the write: the failed write above already spent
704+
// its four branches, so the merge write is measured as the difference it makes, and read
705+
// against the stage list the guard itself reports. The commit rename is the one rename the
706+
// write attempts on its own account; the failed write above left the committed content in a
707+
// backup with the canonical path gone, so the recovery pass at the start of this write is
708+
// allowed one more rename, the restore the guard reports as the recover stage.
709+
const mergeStages: string[] = []
710+
const renamesBeforeMerge = renameCallCount
698711
await safeWriteJson(currentTestFilePath, newData, {
699712
merge: (existing) => {
700713
expect(existing).toEqual(initialData)
701714
return newData
702715
},
716+
trace: ({ stage }) => mergeStages.push(stage),
703717
})
704718

705-
// Exactly one rename was attempted, and it was the commit.
706-
expect(renameCallCount).toBe(1)
719+
// Exactly one rename was attempted on the write's own account, and it was the commit.
720+
expect(renameCallCount - renamesBeforeMerge).toBe(mergeStages.includes("recover") ? 2 : 1)
707721

708-
// The target never left its path, so the previous content is still what a
709-
// reader sees, and no orphaned backup copy is left behind either.
722+
// The recovery pass put the committed content back on the target before this write
723+
// merged onto it - that is what the merge callback saw - so the reader now sees the merged
724+
// value, and the recovered backup that made it possible is gone from the directory.
710725
const content = await readFileContent(currentTestFilePath)
711-
expect(content).toEqual(initialData)
726+
expect(content).toEqual(newData)
712727
const entries = await fs.readdir(tempDir)
713-
expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(false)
728+
expect(entries.some((entry) => entry.includes(".bak_"))).toBe(false)
714729

715730
consoleErrorSpy.mockRestore()
716731
})
@@ -1043,10 +1058,12 @@ describe("safeWriteJson", () => {
10431058
const content = await readFileContent(currentTestFilePath)
10441059
expect(content).toEqual(newData)
10451060
})
1046-
// (dropped with the retired spelling: "acquires the lock on the resolved referent, not the caller
1047-
// alias" and "resolves the real proper-lockfile after the capture above is undone", the pair that
1048-
// pinned the lock key as the symlink referent. The chain now takes the lock on the path the caller
1049-
// gives, as trunk spells it, so those two rows describe a spelling this tree no longer carries.)
1061+
// (A re-land above dropped "acquires the lock on the resolved referent, not the caller alias" and
1062+
// "resolves the real proper-lockfile after the capture above is undone" as rows about a retired
1063+
// spelling, while the lock key was being taken from the path the caller gave. The key is resolved
1064+
// before the lock again (safeWriteJson.ts:327), so the referent question is live: it is answered in
1065+
// safeWriteJson.lockKey.spec.ts, and the proper-lockfile row below stayed in this file and passes on
1066+
// its own terms now that the undo runs in order rather than being hoisted above the imports.)
10501067

10511068
// The undo in the case above has to be visible from the other side of the file: a later
10521069
// test that imports proper-lockfile fresh must resolve the real module, not the wrapper

‎src/utils/safeWriteJson.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -316,11 +316,15 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit
316316
throw dirError
317317
}
318318

319-
// The lock is taken on the path the caller gave, which is the spelling the trunk carries and this
320-
// chain keeps - see the comment at the acquisition below. The guards further down still resolve the
321-
// publish target under the lock and still measure it against this path, so the drift check and the
322-
// pre-lock confinement check keep their meaning with the key spelled this way.
323-
const lockKey = absoluteFilePath
319+
// The lock is taken on the key that resolveLockKey establishes for the file - the canonical
320+
// spelling, which is the key every other writer to this file takes it on (see the acquisition
321+
// comment in core/tools/guardedWrite.ts at lines 190 to 192, and the note on the publish
322+
// primitive at lines 233 to 236, both of which state that safeWriteJson holds the resolved key).
323+
// Spelled from the caller's pathname instead, a writer whose path runs through an alias directory
324+
// and a writer that resolved that directory hold two locks for one file, and this function's own
325+
// drift check below then refuses an alias write it was never meant to refuse, because the lock it
326+
// holds and the target it measures were then spelled from two different questions.
327+
const lockKey = await resolveLockKey(absoluteFilePath)
324328

325329
// Confinement is checked here as well as under the lock. The lock file lives NEXT
326330
// TO the key, so a planted symlink that points outside the declared scope would
@@ -344,7 +348,7 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit
344348
// remains a no-op, so the finally block in the main file operations
345349
// try-catch-finally won't try to release an unacquired lock if this
346350
// path is taken.
347-
releaseLock = await acquireFileLock(absoluteFilePath)
351+
releaseLock = await acquireFileLock(lockKey)
348352

349353
// Variables to hold the actual path of the temp file if it is created.
350354
let actualTempNewFilePath: string | null = null
@@ -370,7 +374,7 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit
370374
// advisory lock held until the stale timeout for every other writer.
371375
resolvedTargetPath = await resolvePublishTarget(absoluteFilePath)
372376

373-
// The lock above was chosen from the path as it looked before the lock was taken, and the
377+
// The lock above was chosen from the key this write resolved before taking it, and the
374378
// publish target is resolved under the lock - after a peer committed, and after the alias
375379
// itself may have been repointed. If the two no longer name the same file, this writer is
376380
// holding the lock of a path it is not about to publish while some other writer can hold
@@ -480,7 +484,7 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit
480484
// canonical path absent.
481485
try {
482486
options?.trace?.({ stage: "replacement-rename" })
483-
await fs.rename(actualTempNewFilePath, absoluteFilePath)
487+
await fs.rename(actualTempNewFilePath, resolvedTargetPath ?? absoluteFilePath)
484488
} catch (renameError: unknown) {
485489
if (getErrorCode(renameError) !== "EPERM") {
486490
throw renameError

0 commit comments

Comments
 (0)