Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
5 changes: 5 additions & 0 deletions .changeset/fix-genui-api-extractor-lock-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---

---

Fix CI flake in `@lynx-js/genui#api-extractor` caused by a TOCTOU bug in `acquireLock` that let two concurrent invocations of `run-api-extractor.mjs` both enter the critical section; one would `rslib build` the subpackage dist while the other's tsc was reading it, producing TS7016 ("Could not find a declaration file"). The lock now waits on read/parse failures instead of deleting the file, since an unparseable lock usually means the holder is mid-write between `open(wx)` and `writeFile`.
3 changes: 1 addition & 2 deletions packages/genui/a2ui-prompt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@
],
"scripts": {
"api-extractor": "node ../scripts/run-api-extractor.mjs",
"build": "rslib build",
"build:api": "rslib build"
"build": "rslib build"
},
"devDependencies": {
"@microsoft/api-extractor": "catalog:",
Expand Down
19 changes: 0 additions & 19 deletions packages/genui/a2ui-prompt/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,6 @@
"outputs": [
"dist/**"
]
},
"build:api": {
"dependsOn": [
"build"
],
"inputs": [
"src/**",
"../server/agent/a2ui-catalog.ts",
"../server/agent/a2ui-examples.ts",
"../server/agent/a2ui-prompt.ts",
"../server/agent/catalog/**/*.json",
"package.json",
"rslib.config.ts",
"tsconfig.build.json",
"tsconfig.json"
],
"outputs": [
"dist/**"
]
}
}
}
75 changes: 49 additions & 26 deletions packages/genui/scripts/run-api-extractor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// LICENSE file in the root directory of this source tree.
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { open, readFile, rm } from 'node:fs/promises';
import { link, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

Expand All @@ -25,39 +25,57 @@ const isProcessAlive = (pid) => {
};

const acquireLock = async () => {
const start = Date.now();
// Stage the fully-written lock in a per-process temp file, then publish it
// with `link()`: linking is atomic and fails with `EEXIST` when the lock
// already exists, so the lock file always has complete contents the moment
// it appears. There is no empty/partial window for another process to
// observe, which means an unparsable lock can only be a corrupt file.
const tmpPath = `${lockPath}.${process.pid}`;
await writeFile(
tmpPath,
JSON.stringify({
cwd: process.cwd(),
pid: process.pid,
startedAt: new Date().toISOString(),
}),
);

while (Date.now() - start < lockTimeoutMs) {
try {
const file = await open(lockPath, 'wx');
await file.writeFile(JSON.stringify({
cwd: process.cwd(),
pid: process.pid,
startedAt: new Date().toISOString(),
}));
await file.close();
return;
} catch (error) {
if (error?.code !== 'EEXIST') {
throw error;
}
try {
const start = Date.now();

while (Date.now() - start < lockTimeoutMs) {
try {
const current = JSON.parse(await readFile(lockPath, 'utf8'));
if (typeof current.pid === 'number' && !isProcessAlive(current.pid)) {
await link(tmpPath, lockPath);
return;
} catch (error) {
if (error?.code !== 'EEXIST') {
throw error;
}

// Someone else holds the lock. Reap it only when we can prove the
// holder is gone: a dead pid, or a corrupt (unparsable) lock that no
// healthy holder could have produced.
let staleHolder = false;
try {
const current = JSON.parse(await readFile(lockPath, 'utf8'));
if (typeof current.pid === 'number' && !isProcessAlive(current.pid)) {
staleHolder = true;
}
} catch {
staleHolder = true;
}
if (staleHolder) {
await rm(lockPath, { force: true });
Comment thread
upupming marked this conversation as resolved.
Outdated
continue;
}
} catch {
await rm(lockPath, { force: true });
continue;
await sleep(retryDelayMs);
}

await sleep(retryDelayMs);
}
}

throw new Error(`Timed out waiting for ${lockPath}`);
throw new Error(`Timed out waiting for ${lockPath}`);
} finally {
await rm(tmpPath, { force: true });
}
};

const run = (command, args) => {
Expand Down Expand Up @@ -130,7 +148,12 @@ const ensureMainEntryPoint = async () => {
await acquireLock();

try {
run('pnpm', ['run', 'build']);
// Do NOT build here. Turbo's task graph already builds this package (the
// `api-extractor` task depends on `build`), and rebuilding in-script would
Comment thread
upupming marked this conversation as resolved.
Outdated
// re-clean and rewrite `dist/` while turbo-scheduled consumer builds (e.g.
// `genui-cli#build`) read the same `dist/`, transiently removing the
// `.d.ts` and breaking their `tsc` with TS2307/TS7016. `ensureMainEntryPoint`
// stays only as a last-resort build if the entry point is somehow missing.
await ensureMainEntryPoint();
run('api-extractor', ['run', '--verbose']);
} finally {
Expand Down
3 changes: 2 additions & 1 deletion packages/genui/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@
"api-extractor": {
"dependsOn": [
"//#build",
"build",
Comment thread
upupming marked this conversation as resolved.
Outdated
"@lynx-js/genui-a2ui#build:api",
"@lynx-js/genui-a2ui-catalog-extractor#build:api",
"@lynx-js/genui-a2ui-prompt#build:api",
"@lynx-js/genui-a2ui-prompt#build",
"@lynx-js/genui-openui#build:api"
],
"cache": false
Expand Down
3 changes: 2 additions & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"tasks": {
"api-extractor": {
"dependsOn": [
"//#build"
"//#build",
"build"
],
"cache": false
},
Expand Down
Loading