Skip to content

Commit acc49b9

Browse files
test(utils): refuse a captured target that sits under another lock
The reject path of the captured-target check had no row: the handover test moves the alias A to B and stays there, so the recomputed key always agreed with the moved lock and the check never fired. Add the A to B to A case the review asks for: A is captured, B is captured as the publish target while A is held, and the alias is back at A before the key is recomputed, so the loop breaks on its first pass and only the captured-target check stands between this write and the commit rename. It refuses, before the merge read and before staging, both targets keep their bytes, and A is the only lock taken and the lock released. At blob e45bdc1, the head before the check read the captured target, the same row resolves instead of rejecting.
1 parent ff9c4eb commit acc49b9

1 file changed

Lines changed: 58 additions & 0 deletions

File tree

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,64 @@ describe("safeWriteJson lock key under a peer commit", () => {
215215
expect(JSON.parse(await fs.readFile(referentB, "utf8"))).toEqual({ id: "task-1" })
216216
expect(JSON.parse(await fs.readFile(referentA, "utf8"))).toEqual({ id: "a" })
217217
})
218+
it("refuses to publish when the captured target sits under a different lock than the one held", async () => {
219+
const order: string[] = []
220+
const dir = await makeDir("captured-")
221+
const referentA = path.join(dir, "history_item_a.json")
222+
const referentB = path.join(dir, "history_item_b.json")
223+
currentLink = path.join(dir, "link.json")
224+
await fs.writeFile(referentA, JSON.stringify({ id: "a" }), "utf8")
225+
await fs.writeFile(referentB, JSON.stringify({ id: "b" }), "utf8")
226+
227+
// A peer retargets the alias from A to B while this writer is taking A's lock, and moves it back
228+
// to A before the flow recomputes the key under that lock. The recomputation therefore agrees
229+
// with the key held, the loop that would move the lock to the current referent breaks on its
230+
// first pass, and the only thing standing between this write and the commit rename is the
231+
// target it captured while the alias pointed at B. A check that re-derives that target from a
232+
// second lookup of the alias sees A, agrees with the key it holds, and renames B's contents
233+
// away while still holding A's lock. A check that canonicalizes the captured target sees B,
234+
// disagrees with the key A, and refuses. The alias lookups below record the A to B to A rather
235+
// than narrating it, so the row shows the sequence it depends on.
236+
const aliasLookups: string[] = []
237+
mockedRealpath.mockImplementation(async (target) => {
238+
const spelling = String(target)
239+
if (spelling !== currentLink) {
240+
return spelling
241+
}
242+
const referent = aliasLookups.length === 1 ? referentB : referentA
243+
aliasLookups.push(referent)
244+
return referent
245+
})
246+
mockedLstat.mockImplementation(async (target) => symlinkStat(target))
247+
mockedReadlink.mockImplementation(async (target) =>
248+
target === currentLink ? referentA : Promise.reject(new Error("not a link")),
249+
)
250+
mockedAcquireFileLock.mockImplementation(async (key) => {
251+
order.push(key === referentA ? "acquire:A" : "acquire:other")
252+
return async () => {
253+
order.push(key === referentA ? "release:A" : "release:other")
254+
}
255+
})
256+
257+
await expect(safeWriteJson(currentLink, { id: "task-1" })).rejects.toThrow(
258+
"the resolved target kept changing while acquiring the lock",
259+
)
260+
261+
// The alias moved A to B while the lock was taken and back to A before the key recomputation.
262+
// The mismatch belongs to the captured target, not to the alias, which is why the guard has to
263+
// read the target this write is about to rename onto instead of looking the alias up again.
264+
expect(aliasLookups).toEqual([referentA, referentB, referentA])
265+
// The recomputation agreed with the held key, so no lock was moved: A is the only lock this
266+
// write acquired, and it is the lock the finally released.
267+
expect(order).toEqual(["acquire:A", "release:A"])
268+
expect(mockedAcquireFileLock.mock.calls.map((call) => call[0])).toEqual([referentA])
269+
// Refused before the merge read and before staging, so nothing was written beside either target.
270+
const left = await fs.readdir(dir)
271+
expect(left.filter((entry) => entry.includes(".new_") || entry.includes(".bak_"))).toEqual([])
272+
// Both targets still hold the bytes they had before this write tried.
273+
expect(JSON.parse(await fs.readFile(referentA, "utf8"))).toEqual({ id: "a" })
274+
expect(JSON.parse(await fs.readFile(referentB, "utf8"))).toEqual({ id: "b" })
275+
})
218276
})
219277

220278
it("does not log a cleanup error when the safety net finds the temp file already gone", async () => {

0 commit comments

Comments
 (0)