Skip to content

Commit c6eb8fb

Browse files
feat(file-safety): file version token for the guarded-write path (A1, #1375) (#1383)
* feat(file-safety): add version token for the guarded-write path (A1, #1375) Introduces the version token - dev:ino:size:mtimeNs:ctimeNs derived from a single fs.stat - a pure function of a file's on-disk state that every process computing from the same state agrees on. The compare-and-swap write guard (A2/A3) will compare the token observed at read time against the token recomputed before a write to detect stale or replaced files. No production callers yet: this is infrastructure for the file-write safety series (plan: easonLiangWorldedtech#33), part of upstream epic #1375. * docs(file-safety): correct ino precision bounds in version token (A1, #1375) Review finding: 'ino is an exact integer' was overstated. Node exposes ino as a float64 number: exact for small POSIX inode numbers, but on modern Windows the file ID exceeds 2^53 so Node's own value is already rounded (verified on node v25: non-zero ino, isSafeInteger=false). It remains deterministic per file (same file -> same token), so the token contract is unchanged; change detection rests on exact dev/size plus the mtime/ctime ns fields. Document the bound instead of claiming exactness. * fix(file-safety): derive the version token from exact BigInt stats (A1, #1375) CodeRabbit finding on this PR: the default numeric fs.stat() loses precision (values above 2^53 are rounded, including Windows file IDs) and the ms->ns derivation introduced a double-precision quantum. Fixed by fetching the stat with { bigint: true }: all five token fields (dev, ino, size, mtimeNs, ctimeNs) are exact BigInt values rendered as decimal strings, with no float anywhere. The sub-ms test now asserts an exact 1_000 ns delta instead of bounded drift, and a regression test pins a size of 10^16+1 (> Number.MAX_SAFE_INTEGER). * chore(ci): empty commit — re-trigger CI and the CodeRabbit current-head review gate (no code change) --------- Co-authored-by: Eason Liang <easonliang28@gmail.com>
1 parent 294c5ff commit c6eb8fb

2 files changed

Lines changed: 153 additions & 0 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import * as fs from "fs/promises"
2+
import * as os from "os"
3+
import * as path from "path"
4+
import type { BigIntStats } from "fs"
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
6+
7+
import { computeVersionToken, versionTokenOfStat } from "../versionToken"
8+
9+
// BigIntStats is a class-backed interface without a public constructor, so a
10+
// plain-object test double is the only practical way to pin the token format
11+
// without real files. Last-resort double assertion (test-local, per AGENTS.md).
12+
function makeStats(overrides: Partial<BigIntStats> = {}): BigIntStats {
13+
// This repo's @types/node models every StatsBase field (including the *Ms
14+
// fields) as the parameter type T, so all values here are bigint literals;
15+
// the token only reads the *Ns fields. Single-step downcast from Partial to
16+
// the full type (BigIntStats has no public constructor).
17+
const base: Partial<BigIntStats> = {
18+
dev: 7n,
19+
ino: 4242n,
20+
size: 1234n,
21+
atimeMs: 1_700_000_000_000n,
22+
mtimeMs: 1_700_000_000_123n,
23+
ctimeMs: 1_700_000_000_789n,
24+
birthtimeMs: 1_700_000_000_000n,
25+
atimeNs: 1_700_000_000_000_000_000n,
26+
mtimeNs: 1_700_000_000_123_456_789n,
27+
ctimeNs: 1_700_000_000_789_999_999n,
28+
birthtimeNs: 1_700_000_000_000_000_000n,
29+
}
30+
return { ...base, ...overrides } as BigIntStats
31+
}
32+
33+
describe("versionTokenOfStat (A1, epic #1375)", () => {
34+
it("is deterministic for an identical stat", () => {
35+
expect(versionTokenOfStat(makeStats())).toBe(versionTokenOfStat(makeStats()))
36+
})
37+
38+
it("matches the documented dev:ino:size:mtimeNs:ctimeNs format with exact decimal fields", () => {
39+
expect(versionTokenOfStat(makeStats())).toBe("7:4242:1234:1700000000123456789:1700000000789999999")
40+
})
41+
42+
it("distinguishes size changes at identical timestamps", () => {
43+
expect(versionTokenOfStat(makeStats({ size: 1235n }))).not.toBe(versionTokenOfStat(makeStats()))
44+
})
45+
46+
it("distinguishes a one-nanosecond mtime change", () => {
47+
expect(versionTokenOfStat(makeStats({ mtimeNs: 1_700_000_000_123_456_790n }))).not.toBe(
48+
versionTokenOfStat(makeStats()),
49+
)
50+
})
51+
52+
it("distinguishes a replaced file (dev/ino change) with identical content state", () => {
53+
const replaced = makeStats({ dev: 8n, ino: 999n })
54+
expect(versionTokenOfStat(replaced)).not.toBe(versionTokenOfStat(makeStats()))
55+
})
56+
57+
it("renders nanosecond resolution exactly (no float quantization)", () => {
58+
const base = versionTokenOfStat(makeStats())
59+
const plusOneMicrosecond = versionTokenOfStat(makeStats({ mtimeNs: 1_700_000_000_123_457_789n }))
60+
// 1_000 ns apart — the BigInt derivation must keep the delta exact.
61+
const baseNs = BigInt(base.split(":")[3])
62+
const microNs = BigInt(plusOneMicrosecond.split(":")[3])
63+
expect(microNs - baseNs).toBe(1_000n)
64+
})
65+
66+
it("handles sizes beyond Number.MAX_SAFE_INTEGER without precision loss", () => {
67+
const size = 10_000_000_000_000_001n // 10^16 + 1 > 2^53
68+
const token = versionTokenOfStat(makeStats({ size }))
69+
expect(token).toBe(`7:4242:${size}:1700000000123456789:1700000000789999999`)
70+
})
71+
})
72+
73+
describe("computeVersionToken (A1, epic #1375)", () => {
74+
let tmpDir: string
75+
let file: string
76+
77+
beforeEach(async () => {
78+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "version-token-"))
79+
file = path.join(tmpDir, "seed.txt")
80+
await fs.writeFile(file, "seed content", "utf8")
81+
})
82+
83+
afterEach(async () => {
84+
await fs.rm(tmpDir, { recursive: true, force: true })
85+
})
86+
87+
it("derives the token from the on-disk state (single bigint stat)", async () => {
88+
const token = await computeVersionToken(file)
89+
expect(token).toBe(versionTokenOfStat(await fs.stat(file, { bigint: true })))
90+
})
91+
92+
it("changes when the file content changes", async () => {
93+
const before = await computeVersionToken(file)
94+
// Different size + a new mtime — both must move the token.
95+
await fs.writeFile(file, "seed content, extended", "utf8")
96+
await new Promise((resolve) => setTimeout(resolve, 5))
97+
expect(await computeVersionToken(file)).not.toBe(before)
98+
})
99+
100+
it("rejects with ENOENT for an absent file", async () => {
101+
await expect(computeVersionToken(path.join(tmpDir, "absent.txt"))).rejects.toMatchObject({
102+
code: "ENOENT",
103+
})
104+
})
105+
})

‎src/utils/versionToken.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { stat } from "fs/promises"
2+
import type { BigIntStats } from "fs"
3+
4+
/**
5+
* Version token for the compare-and-swap write guard (upstream epic #1375, phase A1).
6+
*
7+
* A token is a pure function of a file's on-disk state, derived from a single
8+
* `fs.stat(path, { bigint: true })`, so every process that observes the same file
9+
* state (a second VS Code window, the CLI, the user's own editor tooling) computes
10+
* the same token. The downstream guard phases (A2/A3) compare the token observed at
11+
* read time with the token recomputed just before a write to detect "the file
12+
* changed since the read" (stale) or "the file was replaced by a different file"
13+
* (dev/ino change).
14+
*
15+
* Format: `dev:ino:size:mtimeNs:ctimeNs`
16+
*
17+
* Precision: the stat is fetched in `bigint` mode, so all five fields are exact
18+
* `BigInt` values rendered as decimal strings — no float is involved anywhere.
19+
* There is therefore no precision loss for large sizes or inodes (a Windows file ID
20+
* exceeds 2^53 and is still exact), and the ns timestamps are the kernel's exact
21+
* nanosecond values rather than a ms→ns derivation (no ~256 ns double-precision
22+
* quantum). Guarantee: same disk state → same token, deterministic across
23+
* processes; any change to size, file identity, or mtime/ctime → a different token.
24+
*
25+
* Platform note: on POSIX `ctime` is the last file-status change; on Windows it is
26+
* the file creation time. The token only requires it to move when the file's
27+
* metadata is replaced, which holds on both.
28+
*/
29+
30+
/**
31+
* Build the version token from an already-fetched `BigIntStats` — no I/O.
32+
*
33+
* Exported separately from {@link computeVersionToken} so tests can pin the exact
34+
* format against synthetic stats.
35+
*/
36+
export function versionTokenOfStat(stats: BigIntStats): string {
37+
return [stats.dev, stats.ino, stats.size, stats.mtimeNs, stats.ctimeNs].map((value) => value.toString()).join(":")
38+
}
39+
40+
/**
41+
* Compute the version token for a file (one `fs.stat` in bigint mode).
42+
*
43+
* Rejects with the underlying ENOENT (or equivalent) error when the file is absent;
44+
* how an unobservable target is treated is decided by the guard layer (A3).
45+
*/
46+
export async function computeVersionToken(filePath: string): Promise<string> {
47+
return versionTokenOfStat(await stat(filePath, { bigint: true }))
48+
}

0 commit comments

Comments
 (0)