Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 71 additions & 10 deletions .github/actions/rust-build-cache/source-mtimes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down
82 changes: 79 additions & 3 deletions .github/actions/rust-build-cache/source-mtimes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
}
});
}
Loading