diff --git a/.github/actions/rust-build-cache/source-mtimes.mjs b/.github/actions/rust-build-cache/source-mtimes.mjs index 31fbf8d0e..f2f7799ec 100644 --- a/.github/actions/rust-build-cache/source-mtimes.mjs +++ b/.github/actions/rust-build-cache/source-mtimes.mjs @@ -4,6 +4,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, statSync, utimesSync, writeFileSync, } from 'node:fs'; import path from 'node:path'; +import { performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; function sourceFiles(root) { @@ -46,24 +47,84 @@ export function captureSourceTimes(roots, destination) { return sources.reduce((total, files) => total + files.length, 0); } +function refreshedTimestamp(current, wallClockMs) { + return Math.max(wallClockMs, current.mtimeMs + 0.001) / 1000; +} + +function refreshSourceTimes(roots, wallClockMs) { + for (const root of roots) { + for (const relative of sourceFiles(root)) { + const absolute = regularSource(root, relative); + if (!absolute) continue; + const stat = statSync(absolute); + utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockMs)); + } + } +} + +function validSnapshot(snapshot, rootCount) { + const validSource = (file) => file && typeof file === 'object' + && typeof file.path === 'string' && typeof file.hash === 'string' + && Number.isInteger(file.executable) && Number.isFinite(file.mtimeMs); + const validSymlink = (link) => link && typeof link === 'object' + && typeof link.path === 'string' && typeof link.target === 'string'; + return snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot) + && snapshot.version === 2 + && Array.isArray(snapshot.sources) && snapshot.sources.length === rootCount + && snapshot.sources.every((files) => Array.isArray(files) && files.every(validSource)) + && Array.isArray(snapshot.symlinks) && snapshot.symlinks.length === rootCount + && snapshot.symlinks.every((links) => Array.isArray(links) && links.every(validSymlink)); +} + export function restoreSourceTimes(roots, source) { - if (!existsSync(source)) return 0; - const snapshot = JSON.parse(readFileSync(source, 'utf8')); - if (snapshot.version !== 2 || snapshot.sources?.length !== roots.length - || snapshot.symlinks?.length !== roots.length) return 0; + // Date.now() can precede a freshly written file on filesystems retaining + // submillisecond timestamps. Use the high-resolution epoch clock and advance + // by one microsecond only when the input is already newer. + const wallClockMs = performance.timeOrigin + performance.now(); + if (!existsSync(source)) { + refreshSourceTimes(roots, wallClockMs); + return 0; + } + let snapshot; + try { + snapshot = JSON.parse(readFileSync(source, 'utf8')); + } catch { + refreshSourceTimes(roots, wallClockMs); + return 0; + } + if (!validSnapshot(snapshot, roots.length)) { + refreshSourceTimes(roots, wallClockMs); + return 0; + } let restored = 0; roots.forEach((root, index) => { const files = sourceFiles(root); // Cargo follows symlinks when checking dependency mtimes. Restoring a new // target's old timestamp could otherwise hide changed include_str! bytes. - if (JSON.stringify(sourceSymlinks(root, files)) !== JSON.stringify(snapshot.symlinks[index])) return; + if (JSON.stringify(sourceSymlinks(root, files)) !== JSON.stringify(snapshot.symlinks[index])) { + for (const file of files) { + const absolute = regularSource(root, file); + if (!absolute) continue; + const stat = statSync(absolute); + utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockMs)); + } + return; + } const tracked = new Set(files); - for (const file of snapshot.sources[index]) { - if (!tracked.has(file.path) || !Number.isFinite(file.mtimeMs)) continue; - const absolute = regularSource(root, file.path); - if (!absolute || contentHash(absolute) !== file.hash) continue; + const previous = new Map(snapshot.sources[index].map((file) => [file.path, file])); + for (const relative of tracked) { + const file = previous.get(relative); + const absolute = regularSource(root, relative); + if (!absolute) continue; const stat = statSync(absolute); - if ((stat.mode & 0o111) !== file.executable) continue; + if (!file || !Number.isFinite(file.mtimeMs) + || contentHash(absolute) !== file.hash || (stat.mode & 0o111) !== file.executable) { + // A fallback target archive can be newer than a fresh checkout. Make + // changed and newly tracked inputs newer than the restored artifacts + // so Cargo cannot accept stale fingerprints before rustc runs. + utimesSync(absolute, stat.atime, refreshedTimestamp(stat, wallClockMs)); + continue; + } utimesSync(absolute, stat.atime, file.mtimeMs / 1000); restored += 1; } diff --git a/.github/actions/rust-build-cache/source-mtimes.test.mjs b/.github/actions/rust-build-cache/source-mtimes.test.mjs index a2debd8ef..7c63f3c8d 100644 --- a/.github/actions/rust-build-cache/source-mtimes.test.mjs +++ b/.github/actions/rust-build-cache/source-mtimes.test.mjs @@ -63,9 +63,11 @@ test('content-qualified timestamps retain Cargo freshness but edits and removals const changed = path.join(root, 'src/value.rs'); writeFileSync(changed, 'pub const VALUE: u32 = 2;\n'); + const submillisecondFuture = Date.now() / 1000 + 0.0009999; + utimesSync(changed, submillisecondFuture, submillisecondFuture); const changedTime = statSync(changed).mtimeMs; assert.equal(restoreSourceTimes([root], snapshot), 2); - assert.equal(statSync(changed).mtimeMs, changedTime); + assert.ok(statSync(changed).mtimeMs > changedTime); result = build(root); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /Compiling freshness_fixture/); @@ -170,7 +172,36 @@ for (const directory of [false, true]) { }); } -test('executable mode changes retain checkout timestamps and legacy snapshots are ignored', () => { +test('changed source cannot look older than a restored Cargo artifact', () => { + const root = mkdtempSync(path.join(tmpdir(), 'hypercolor-stale-cargo-')); + try { + fixture(root); + let result = build(root); + assert.equal(result.status, 0, result.stderr); + const snapshot = path.join(root, 'source-times.json'); + captureSourceTimes([root], snapshot); + const recorded = JSON.parse(readFileSync(snapshot, 'utf8')) + .sources[0].find((file) => file.path === 'src/value.rs').mtimeMs; + const changed = path.join(root, 'src/value.rs'); + writeFileSync(changed, 'pub const VALUE: u32 = 2;\n'); + utimesSync(changed, recorded / 1000, recorded / 1000); + + result = build(root); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Fresh freshness_fixture/); + assert.doesNotMatch(result.stderr, /Compiling freshness_fixture/); + + assert.equal(restoreSourceTimes([root], snapshot), 2); + assert.ok(statSync(changed).mtimeMs > recorded); + result = build(root); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Compiling freshness_fixture/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('new files and executable mode changes receive fresh timestamps', () => { const root = mkdtempSync(path.join(tmpdir(), 'hypercolor-source-mode-')); try { fixture(root); @@ -181,14 +212,59 @@ test('executable mode changes retain checkout timestamps and legacy snapshots ar utimesSync(file, 2000, 2000); chmodSync(file, 0o755); assert.equal(restoreSourceTimes([root], snapshot), 2); - assert.equal(statSync(file).mtimeMs, 2000000); + assert.ok(statSync(file).mtimeMs > 2000000); + + const added = path.join(root, 'src/added.rs'); + writeFileSync(added, 'pub const ADDED: bool = true;\n'); + git(root, 'add', 'src/added.rs'); + utimesSync(added, 1000, 1000); + assert.equal(restoreSourceTimes([root], snapshot), 2); + assert.ok(statSync(added).mtimeMs > 1000000); const legacy = JSON.parse(readFileSync(snapshot, 'utf8')); legacy.version = 1; delete legacy.symlinks; writeFileSync(snapshot, JSON.stringify(legacy)); + utimesSync(file, 1000, 1000); assert.equal(restoreSourceTimes([root], snapshot), 0); + assert.ok(statSync(file).mtimeMs > 1000000); } finally { rmSync(root, { recursive: true, force: true }); } }); + +for (const [unavailable, contents] of [ + ['missing', undefined], + ['malformed JSON', '{'], + ['null', 'null'], + ['malformed source entry', JSON.stringify({ version: 2, sources: [[null]], symlinks: [[]] })], +]) { + test(`${unavailable} timestamp metadata invalidates restored Cargo artifacts`, () => { + const root = mkdtempSync(path.join(tmpdir(), 'hypercolor-missing-source-times-')); + try { + fixture(root); + let result = build(root); + assert.equal(result.status, 0, result.stderr); + const snapshot = path.join(root, 'source-times.json'); + if (contents !== undefined) writeFileSync(snapshot, contents); + const source = path.join(root, 'src/value.rs'); + utimesSync(source, 1000, 1000); + + result = build(root); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Fresh freshness_fixture/); + + assert.equal(restoreSourceTimes([root], snapshot), 0); + assert.ok(statSync(source).mtimeMs > 1000000); + result = build(root); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Compiling freshness_fixture/); + result = build(root); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Fresh freshness_fixture/); + assert.doesNotMatch(result.stderr, /Compiling freshness_fixture/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}