Skip to content

Commit 27f156a

Browse files
committed
fix(dev-scripts): make gen:docs writes to docs/package.json atomic; run unit tests once in CI
Two concurrent gen:docs processes were racing on the non-atomic read-modify-write of the shared, tracked docs/package.json in addDependenciesToExample, so one process truncated the file to 0 bytes while another read it -> JSON.parse('') 'Unexpected end of JSON input' (crashed on the backend/s3 example). Two changes: - writeFileAtomic(): write to a unique temp file then rename() (atomic on POSIX), and skip the write entirely when content is unchanged. Readers now always observe complete content, never an empty file, so a plain read+JSON.parse is safe. Used for the docs/package.json write in addDependenciesToExample. - CI 'Run unit tests' now calls 'vp run test' (the root test script, run once) instead of 'vp run -r test'. The recursive form ran every package's test AND re-spawned the root test script's filtered graph concurrently, launching gen:docs twice. 'vp run test' runs it once and also preserves the intended !@blocknote/xl-ai exclusion.
1 parent 41df112 commit 27f156a

4 files changed

Lines changed: 35 additions & 9 deletions

File tree

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
run: vp lint
3939

4040
- name: Run unit tests
41-
run: vp run -r test
41+
run: vp run test
4242

4343
- name: Run Next.js integration test (production build)
4444
run: NEXTJS_TEST_MODE=build vp test run src/unit/nextjs/serverUtil.test.ts

.github/workflows/fresh-install-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ jobs:
8484

8585
- id: run_unit_tests
8686
name: Run unit tests
87-
run: vp run -r test
87+
run: vp run test
8888

8989
- name: Notify Slack on workflow failure
9090
if: ${{ failure() }}

packages/dev-scripts/examples/genDocs.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getProjectFiles,
99
groupProjects,
1010
Project,
11+
writeFileAtomic,
1112
writeGeneratedFile,
1213
} from "./util.js";
1314

@@ -165,8 +166,7 @@ async function addDependenciesToExample(project: Project, written: string[]) {
165166
Object.keys(devDependencies).length > 0
166167
) {
167168
const packageJson = path.join(DOCS_DIR, "package.json");
168-
const packageJsonContent = fs.readFileSync(packageJson, "utf-8");
169-
const packageJsonObject = JSON.parse(packageJsonContent);
169+
const packageJsonObject = JSON.parse(fs.readFileSync(packageJson, "utf-8"));
170170
packageJsonObject.dependencies = {
171171
...packageJsonObject.dependencies,
172172
...dependencies,
@@ -186,11 +186,13 @@ async function addDependenciesToExample(project: Project, written: string[]) {
186186
packageJsonObject.devDependencies[key] = "workspace:*";
187187
}
188188
});
189-
writeGeneratedFile(
190-
packageJson,
191-
JSON.stringify(packageJsonObject, null, 2),
192-
written,
193-
);
189+
// Atomic write so a concurrent `gen:docs` process can never read a
190+
// truncated/empty docs/package.json (which previously caused
191+
// `JSON.parse('')` to throw "Unexpected end of JSON input"). The merged
192+
// content is deterministic, so skip-if-unchanged keeps this idempotent.
193+
const target = path.resolve(packageJson);
194+
writeFileAtomic(target, JSON.stringify(packageJsonObject, null, 2));
195+
written.push(target);
194196
}
195197
}
196198

packages/dev-scripts/examples/util.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,30 @@ export function writeGeneratedFile(
2222
written.push(absolute);
2323
}
2424

25+
/**
26+
* Atomically writes a file by writing to a unique temp file in the same
27+
* directory and renaming it into place (rename is atomic on POSIX). This
28+
* prevents readers from ever observing a truncated/empty file, which matters
29+
* for shared files (e.g. docs/package.json) that may be read and written by
30+
* concurrent `gen:docs` processes. Skips the write entirely when the on-disk
31+
* content already matches, avoiding an unnecessary truncation window.
32+
*/
33+
export function writeFileAtomic(absolute: string, content: string) {
34+
try {
35+
if (fs.readFileSync(absolute, "utf-8") === content) {
36+
return;
37+
}
38+
} catch {
39+
// File does not exist yet (or could not be read) — fall through to write.
40+
}
41+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
42+
const tmp = `${absolute}.${process.pid}.${Date.now()}.${Math.random()
43+
.toString(36)
44+
.slice(2)}.tmp`;
45+
fs.writeFileSync(tmp, content);
46+
fs.renameSync(tmp, absolute);
47+
}
48+
2549
/**
2650
* Formats the given files in-place using `vp fmt`. Files that are excluded by
2751
* oxfmt ignore rules (e.g. *.mdx) are tolerated. Runs in chunks to avoid argv

0 commit comments

Comments
 (0)