mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
db51d5479d
* fix(build): stop generated files from being read half-written during the build Two unrelated pull requests kept failing CI for reasons that had nothing to do with their changes. Typecheck failed with "TS1002: Unterminated string literal" pointing at a generated file. The root build compiles several packages at the same time, and more than one of them regenerates files under a package's src/generated while a sibling's tsc is already reading them. A plain write empties the file and then refills it, so for a few milliseconds what is on disk is the first half of the new file. Whichever build read it in that gap saw a truncated file and stopped. It never happened locally because locally nothing else is reading. Every generator now writes the new version under a temporary name and then renames it over the target, which the filesystem does in one step. A reader either gets the whole previous version or the whole new one; there is no moment where it can see half of either. Content that has not changed is not rewritten at all, so repeat builds no longer touch these files. Four generators were affected and all four now go through one shared helper, which is where the rule lives from now on. The build also rebuilt the core package a second time, in parallel with the packages that read it. That second rebuild was already redundant, and removing it takes the writers out of the window entirely. Renaming is what makes the file safe; dropping the duplicate build makes the build shorter as well. Separately, a linter performance test failed on four of the last ten failed runs on main. It timed a scan with a stopwatch and demanded the result come in under two seconds; one run took 3.7s. That is a statement about how busy the shared runner was, not about the code, because a stopwatch also counts the time the machine spent running someone else's job. The test now counts only the processor time this process actually used, and checks that the cost grows in step with the input rather than against a fixed number of milliseconds. Measured on a machine under load, the stopwatch ratio for the same input reached 45x while the processor-time ratio stayed at 20x. Both fixes come with a check that fails if the fix is removed. * fix(build): keep the shared write helper inside the core package The helper the generators call had been placed in the repo-root scripts folder. Every container image that builds a package copies the packages folders whole but cherry-picks root scripts one file at a time, so the image builds failed on a missing module. The repo already shows both halves of that convention: the one root script a package build imports has a matching copy line in the image, and cross-package imports into the core package need none. The helper now lives with the core package's other build scripts, and the one generator outside that package reaches it the way the engine package already reaches core. * test(engine): keep the probe cache bound test off the filesystem The eviction test wrote, stat'ed and deleted 129 temp files to exercise an in-memory LRU rule. Its runtime tracked filesystem contention rather than the code under test, and on a busy Windows runner the file churn alone pushed a ~300ms test past the 5s timeout, failing unrelated pull requests. Cache identity comes from stat, so the test now synthesises stat results and never touches disk. While here, the test also asserts the LRU touch: re-probing an entry before the bound is hit must keep it resident and evict the next-oldest one instead. The previous shape never hit the cache during the fill, so that branch was untested. * test(lint): keep the scaling check inside the scanner's linear range The CPU-ratio version sampled 320k characters, where the output string's own growth dominates and the whole test cost seconds of CPU; on a shared runner that tripped the default test timeout, the failure this change exists to remove. Sample 10k and 80k characters instead, where 8x input measures ~8x and a quadratic scan still measures 60x or more, and give the test an explicit timeout so a slow runner reports the ratio. * test(core): snapshot file identity through a descriptor The before/after inode and mtime checks read the file through a path stat and then exercised the writer on the same path, which reads as a check-then-use race to static analysis. Snapshot through an open descriptor instead; the assertions are unchanged.
86 lines
2.8 KiB
TypeScript
86 lines
2.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import {
|
|
closeSync,
|
|
fstatSync,
|
|
mkdtempSync,
|
|
openSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
utimesSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
// Identity is read through a descriptor, not a path, so the snapshot cannot be mistaken
|
|
// for a check that the following write is then trusted to still hold (the pattern
|
|
// CodeQL's file-system-race query looks for). The write under test is the point.
|
|
const snapshot = (path: string) => {
|
|
const fd = openSync(path, "r");
|
|
try {
|
|
return fstatSync(fd);
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
};
|
|
import { after, describe, it } from "node:test";
|
|
import { writeGeneratedFile } from "./writeGeneratedFile.js";
|
|
|
|
const workDir = mkdtempSync(join(tmpdir(), "write-generated-"));
|
|
after(() => rmSync(workDir, { recursive: true, force: true }));
|
|
|
|
const target = (name: string) => join(workDir, `${name}.ts`);
|
|
|
|
describe("writeGeneratedFile", () => {
|
|
it("publishes the whole file and leaves no temp behind", () => {
|
|
const out = target("published");
|
|
const contents = `export const BIG = ${JSON.stringify("x".repeat(50_000))};\n`;
|
|
|
|
assert.equal(writeGeneratedFile(out, contents), true);
|
|
|
|
assert.equal(readFileSync(out, "utf8"), contents);
|
|
assert.deepEqual(
|
|
readdirSync(workDir).filter((f) => f.endsWith(".tmp")),
|
|
[],
|
|
);
|
|
});
|
|
|
|
it("leaves the target untouched when the content is byte-identical", () => {
|
|
const out = target("unchanged");
|
|
const contents = "export const A = 1;\n";
|
|
writeGeneratedFile(out, contents);
|
|
// Backdated so a republish cannot coincidentally land on the same mtime. Read the
|
|
// stamp back rather than trusting the one just set: filesystems round it.
|
|
const backdated = new Date(Date.now() - 60_000);
|
|
utimesSync(out, backdated, backdated);
|
|
const before = snapshot(out).mtimeMs;
|
|
|
|
assert.equal(writeGeneratedFile(out, contents), false);
|
|
|
|
assert.equal(statSync(out).mtimeMs, before);
|
|
});
|
|
|
|
it("replaces the target rather than rewriting it in place", () => {
|
|
const out = target("replaced");
|
|
writeFileSync(out, "export const A = 1;\n", "utf8");
|
|
const before = snapshot(out).ino;
|
|
|
|
assert.equal(writeGeneratedFile(out, "export const A = 2;\n"), true);
|
|
|
|
assert.equal(readFileSync(out, "utf8"), "export const A = 2;\n");
|
|
// A new inode is the observable proof the target was swapped in whole. Rewriting
|
|
// in place keeps the inode and exposes a truncated file to a concurrent reader.
|
|
assert.notEqual(statSync(out).ino, before);
|
|
});
|
|
|
|
it("creates the directory when it does not exist yet", () => {
|
|
const out = join(workDir, "nested", "deeper", "created.ts");
|
|
|
|
assert.equal(writeGeneratedFile(out, "export const A = 1;\n"), true);
|
|
|
|
assert.equal(readFileSync(out, "utf8"), "export const A = 1;\n");
|
|
});
|
|
});
|