From 617d45874ad376ff715a88febed18a23b370332b Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Wed, 3 Jun 2026 15:40:17 +0800 Subject: [PATCH 01/12] fix(genui): wait for subpackage .d.ts before running tsc `@lynx-js/genui#api-extractor` runs the root `tsc` after Turbo has scheduled `#build:api` for each subpackage. On CI the cached artifacts have been observed to land `.js` ahead of `.d.ts`, so the root `index.ts` import of `@lynx-js/genui/` would resolve to a typeless `.js` and the build would fail with TS7016. Read the subpackage `.d.ts` targets from the package.json `exports` and wait for each before invoking `tsc`. --- .../fix-genui-api-extractor-build-order.md | 5 +++ packages/genui/scripts/run-api-extractor.mjs | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 .changeset/fix-genui-api-extractor-build-order.md diff --git a/.changeset/fix-genui-api-extractor-build-order.md b/.changeset/fix-genui-api-extractor-build-order.md new file mode 100644 index 0000000000..3bdc42f5f0 --- /dev/null +++ b/.changeset/fix-genui-api-extractor-build-order.md @@ -0,0 +1,5 @@ +--- + +--- + +Fix CI flake in `@lynx-js/genui#api-extractor` where the root `tsc` would race a subpackage's `.d.ts` emission and fail with TS7016; the script now waits for each subpackage's declaration file to land on disk before invoking `tsc`. diff --git a/packages/genui/scripts/run-api-extractor.mjs b/packages/genui/scripts/run-api-extractor.mjs index 0c9649e786..e4be6f091c 100644 --- a/packages/genui/scripts/run-api-extractor.mjs +++ b/packages/genui/scripts/run-api-extractor.mjs @@ -12,6 +12,12 @@ const lockPath = join(genuiRoot, '.api-extractor.lock'); const lockTimeoutMs = 10 * 60 * 1000; const entryPointTimeoutMs = 5 * 1000; const retryDelayMs = 500; +// The genui root's `index.ts` imports from `@lynx-js/genui/`, so +// tsc needs each subpackage's emitted `.d.ts` on disk before it runs. Turbo +// schedules `#build:api` first, but cache restoration on the CI +// runner has been observed to land the `.js` before the `.d.ts`. Wait so the +// follow-up `tsc` does not flake with TS7016. +const subpackageDtsTimeoutMs = 30 * 1000; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -127,9 +133,39 @@ const ensureMainEntryPoint = async () => { ); }; +const collectSubpackageDtsTargets = async () => { + const rootPkgPath = join(genuiRoot, 'package.json'); + const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8')); + const targets = new Set(); + for (const [key, value] of Object.entries(rootPkg.exports ?? {})) { + if (key === '.' || key === './package.json') continue; + const types = typeof value === 'string' ? null : value?.types; + if (typeof types !== 'string' || !types.endsWith('.d.ts')) continue; + // Only subpackage paths (`.//dist/...`) need waiting; in-tree assets + // (e.g. `./a2ui/styles/theme.css`) are filtered out by the `.d.ts` check. + targets.add(join(genuiRoot, types)); + } + return [...targets]; +}; + +const waitForSubpackageDts = async () => { + if (process.cwd() !== genuiRoot) return; + const targets = await collectSubpackageDtsTargets(); + const start = Date.now(); + for (const abs of targets) { + while (!existsSync(abs)) { + if (Date.now() - start > subpackageDtsTimeoutMs) { + throw new Error(`Timed out waiting for ${abs}`); + } + await sleep(retryDelayMs); + } + } +}; + await acquireLock(); try { + await waitForSubpackageDts(); run('pnpm', ['run', 'build']); await ensureMainEntryPoint(); run('api-extractor', ['run', '--verbose']); From e640d4413f387453243c8e2657ab00ee72de28b0 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Wed, 3 Jun 2026 17:02:01 +0800 Subject: [PATCH 02/12] Revert "fix(genui): wait for subpackage .d.ts before running tsc" This reverts commit 617d45874ad376ff715a88febed18a23b370332b. --- .../fix-genui-api-extractor-build-order.md | 5 --- packages/genui/scripts/run-api-extractor.mjs | 36 ------------------- 2 files changed, 41 deletions(-) delete mode 100644 .changeset/fix-genui-api-extractor-build-order.md diff --git a/.changeset/fix-genui-api-extractor-build-order.md b/.changeset/fix-genui-api-extractor-build-order.md deleted file mode 100644 index 3bdc42f5f0..0000000000 --- a/.changeset/fix-genui-api-extractor-build-order.md +++ /dev/null @@ -1,5 +0,0 @@ ---- - ---- - -Fix CI flake in `@lynx-js/genui#api-extractor` where the root `tsc` would race a subpackage's `.d.ts` emission and fail with TS7016; the script now waits for each subpackage's declaration file to land on disk before invoking `tsc`. diff --git a/packages/genui/scripts/run-api-extractor.mjs b/packages/genui/scripts/run-api-extractor.mjs index e4be6f091c..0c9649e786 100644 --- a/packages/genui/scripts/run-api-extractor.mjs +++ b/packages/genui/scripts/run-api-extractor.mjs @@ -12,12 +12,6 @@ const lockPath = join(genuiRoot, '.api-extractor.lock'); const lockTimeoutMs = 10 * 60 * 1000; const entryPointTimeoutMs = 5 * 1000; const retryDelayMs = 500; -// The genui root's `index.ts` imports from `@lynx-js/genui/`, so -// tsc needs each subpackage's emitted `.d.ts` on disk before it runs. Turbo -// schedules `#build:api` first, but cache restoration on the CI -// runner has been observed to land the `.js` before the `.d.ts`. Wait so the -// follow-up `tsc` does not flake with TS7016. -const subpackageDtsTimeoutMs = 30 * 1000; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -133,39 +127,9 @@ const ensureMainEntryPoint = async () => { ); }; -const collectSubpackageDtsTargets = async () => { - const rootPkgPath = join(genuiRoot, 'package.json'); - const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8')); - const targets = new Set(); - for (const [key, value] of Object.entries(rootPkg.exports ?? {})) { - if (key === '.' || key === './package.json') continue; - const types = typeof value === 'string' ? null : value?.types; - if (typeof types !== 'string' || !types.endsWith('.d.ts')) continue; - // Only subpackage paths (`.//dist/...`) need waiting; in-tree assets - // (e.g. `./a2ui/styles/theme.css`) are filtered out by the `.d.ts` check. - targets.add(join(genuiRoot, types)); - } - return [...targets]; -}; - -const waitForSubpackageDts = async () => { - if (process.cwd() !== genuiRoot) return; - const targets = await collectSubpackageDtsTargets(); - const start = Date.now(); - for (const abs of targets) { - while (!existsSync(abs)) { - if (Date.now() - start > subpackageDtsTimeoutMs) { - throw new Error(`Timed out waiting for ${abs}`); - } - await sleep(retryDelayMs); - } - } -}; - await acquireLock(); try { - await waitForSubpackageDts(); run('pnpm', ['run', 'build']); await ensureMainEntryPoint(); run('api-extractor', ['run', '--verbose']); From 723981b99564ab0d35e54719c7d5c1bf08aba9e0 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Wed, 3 Jun 2026 17:04:28 +0800 Subject: [PATCH 03/12] fix(genui): close TOCTOU in api-extractor lock `acquireLock` deleted the lock file whenever it failed to read/parse its contents. That window is normal: between `open(wx)` (atomically creating an empty file) and `writeFile` (populating the JSON payload), the holder hasn't written anything yet, so a contender reads `""` and `JSON.parse` throws. The old catch then `rm`-d the lock, letting both processes "acquire" it. In CI, Turbo schedules `#api-extractor` and `genui#api-extractor` concurrently (they share only `//#build` in their dependency graph), so both invoke this script. With the bug, ``'s `rslib build` rewrites `/dist/index.{js,d.ts}` while `genui`'s `tsc` reads it, and tsc catches the window where `dist/index.js` exists but `dist/index.d.ts` does not, failing with TS7016. Wait on read/parse failures instead of deleting; only clear the lock if we can prove the holder's PID is dead. A standalone concurrency repro (5 acquirers, 50ms simulated write delay) goes from `maxConcurrent=5` to `maxConcurrent=1` with this change. --- .changeset/fix-genui-api-extractor-lock-race.md | 5 +++++ packages/genui/scripts/run-api-extractor.mjs | 12 +++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-genui-api-extractor-lock-race.md diff --git a/.changeset/fix-genui-api-extractor-lock-race.md b/.changeset/fix-genui-api-extractor-lock-race.md new file mode 100644 index 0000000000..9569be3ea0 --- /dev/null +++ b/.changeset/fix-genui-api-extractor-lock-race.md @@ -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`. diff --git a/packages/genui/scripts/run-api-extractor.mjs b/packages/genui/scripts/run-api-extractor.mjs index 0c9649e786..91bd50894c 100644 --- a/packages/genui/scripts/run-api-extractor.mjs +++ b/packages/genui/scripts/run-api-extractor.mjs @@ -42,17 +42,23 @@ const acquireLock = async () => { throw error; } + // The holder is between `open(wx)` and `writeFile`, so the lock file + // briefly exists with empty contents. We must never delete it on a + // read/parse failure — that lets the holder and us both "acquire" the + // lock. Only clear when we can prove the holder is dead. + let staleHolder = false; try { const current = JSON.parse(await readFile(lockPath, 'utf8')); if (typeof current.pid === 'number' && !isProcessAlive(current.pid)) { - await rm(lockPath, { force: true }); - continue; + staleHolder = true; } } catch { + // Empty or unparseable — assume the holder is mid-write and wait. + } + if (staleHolder) { await rm(lockPath, { force: true }); continue; } - await sleep(retryDelayMs); } } From 3fdd9d2abcf5a718cc6e428875bbbeee1ff558bd Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 4 Jun 2026 15:43:18 +0800 Subject: [PATCH 04/12] fix(genui): correct typo unparseable -> unparsable --- packages/genui/scripts/run-api-extractor.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/genui/scripts/run-api-extractor.mjs b/packages/genui/scripts/run-api-extractor.mjs index 91bd50894c..b77d539fdb 100644 --- a/packages/genui/scripts/run-api-extractor.mjs +++ b/packages/genui/scripts/run-api-extractor.mjs @@ -53,7 +53,7 @@ const acquireLock = async () => { staleHolder = true; } } catch { - // Empty or unparseable — assume the holder is mid-write and wait. + // Empty or unparsable — assume the holder is mid-write and wait. } if (staleHolder) { await rm(lockPath, { force: true }); From 872617f220a4e065567d1054f96c4cb6c192425a Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 4 Jun 2026 16:14:21 +0800 Subject: [PATCH 05/12] fix(genui): publish api-extractor lock atomically via link() Stage the fully-written lock in a per-process temp file and publish it with link(2) instead of open(wx)+writeFile. Linking is atomic and fails with EEXIST when the lock exists, so the lock always has complete contents the moment it appears -- closing the window where a holder that dies between open() and writeFile() leaves an empty lock that wedges every later run until the 10-minute timeout. An unparsable lock can now only be a corrupt file, so it is reaped instead of waited on. Addresses Codex review on #2780. --- packages/genui/scripts/run-api-extractor.mjs | 76 +++++++++++--------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/packages/genui/scripts/run-api-extractor.mjs b/packages/genui/scripts/run-api-extractor.mjs index b77d539fdb..ac4740401f 100644 --- a/packages/genui/scripts/run-api-extractor.mjs +++ b/packages/genui/scripts/run-api-extractor.mjs @@ -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'; @@ -25,45 +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(); - // The holder is between `open(wx)` and `writeFile`, so the lock file - // briefly exists with empty contents. We must never delete it on a - // read/parse failure — that lets the holder and us both "acquire" the - // lock. Only clear when we can prove the holder is dead. - let staleHolder = false; + 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; } - } catch { - // Empty or unparsable — assume the holder is mid-write and wait. - } - if (staleHolder) { - await rm(lockPath, { force: true }); - continue; + if (staleHolder) { + 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) => { From ccb08458677fc57644bdb95c1656d3734dbbb301 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 4 Jun 2026 16:55:36 +0800 Subject: [PATCH 06/12] fix(genui): drop redundant a2ui-prompt build:api to stop dist-rewrite race genui-a2ui-prompt's build:api was identical to build (both 'rslib build') and ran right after it, rewriting the same dist/. With no turbo edge between build:api and genui-cli#build (both only depend on #build), they ran concurrently: rslib cleans dist and emits index.js before index.d.ts, so genui-cli's tsc could read dist/ in the window where the .d.ts was missing -> TS2307/TS7016 'Cannot find module @lynx-js/genui-a2ui-prompt'. Point the genui api-extractor task at #build instead and remove the duplicate build:api. dist is now written once, before its consumers, so the race is gone. This also subsumes #2794: with no second 'rslib build' there is no concurrent rspack cache transaction to conflict. Verified locally: 'turbo api-extractor --filter=@lynx-js/genui* --force -- --local' reproduced the TS error before, and passes 3/3 after. --- packages/genui/a2ui-prompt/package.json | 3 +-- packages/genui/a2ui-prompt/turbo.json | 19 ------------------- packages/genui/turbo.json | 2 +- 3 files changed, 2 insertions(+), 22 deletions(-) diff --git a/packages/genui/a2ui-prompt/package.json b/packages/genui/a2ui-prompt/package.json index c98ad3045d..3b9b1c7690 100644 --- a/packages/genui/a2ui-prompt/package.json +++ b/packages/genui/a2ui-prompt/package.json @@ -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:", diff --git a/packages/genui/a2ui-prompt/turbo.json b/packages/genui/a2ui-prompt/turbo.json index c56f63f014..fa17b63f01 100644 --- a/packages/genui/a2ui-prompt/turbo.json +++ b/packages/genui/a2ui-prompt/turbo.json @@ -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/**" - ] } } } diff --git a/packages/genui/turbo.json b/packages/genui/turbo.json index f25fb8c151..5d6532af58 100644 --- a/packages/genui/turbo.json +++ b/packages/genui/turbo.json @@ -41,7 +41,7 @@ "//#build", "@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 From 47c19ee6c1e57310383c5adcb76ce7b6c21fa979 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 4 Jun 2026 17:29:31 +0800 Subject: [PATCH 07/12] fix(genui): build via turbo, not in api-extractor script, to end dist race The real cause of the flaky 'Cannot find module @lynx-js/genui-a2ui-prompt' (TS2307/TS7016) in test-api: run-api-extractor.mjs ran 'pnpm run build' in-script. For a2ui-prompt that is 'rslib build', which cleans and rewrites dist/ (emitting index.js before index.d.ts). genui-cli#build is pulled into the api-extractor graph (via a2ui#api-extractor, whose build:catalog needs the genui CLI) and reads a2ui-prompt/dist with no turbo edge ordering it against that in-script rebuild -> its tsc could observe dist/ mid-rewrite with the .d.ts missing. Fix: stop building inside the script and make the api-extractor task depend on 'build' so turbo builds each package exactly once, before both api-extractor and every consumer build. dist/ now has a single writer with all readers ordered after it, so the race cannot occur regardless of timing. ensureMainEntryPoint stays as a last-resort build. Verified: 'turbo build' then 4x forced 'turbo api-extractor -- --local' over the genui graph -> 0 TS errors (reproduced reliably on CI before). --- packages/genui/scripts/run-api-extractor.mjs | 7 ++++++- packages/genui/turbo.json | 1 + turbo.json | 3 ++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/genui/scripts/run-api-extractor.mjs b/packages/genui/scripts/run-api-extractor.mjs index ac4740401f..ae4c8c7693 100644 --- a/packages/genui/scripts/run-api-extractor.mjs +++ b/packages/genui/scripts/run-api-extractor.mjs @@ -148,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 + // 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 { diff --git a/packages/genui/turbo.json b/packages/genui/turbo.json index 5d6532af58..27b59b0538 100644 --- a/packages/genui/turbo.json +++ b/packages/genui/turbo.json @@ -39,6 +39,7 @@ "api-extractor": { "dependsOn": [ "//#build", + "build", "@lynx-js/genui-a2ui#build:api", "@lynx-js/genui-a2ui-catalog-extractor#build:api", "@lynx-js/genui-a2ui-prompt#build", diff --git a/turbo.json b/turbo.json index ec7fd32168..42f73131c1 100644 --- a/turbo.json +++ b/turbo.json @@ -14,7 +14,8 @@ "tasks": { "api-extractor": { "dependsOn": [ - "//#build" + "//#build", + "build" ], "cache": false }, From 41d7c87427d92addb09feea2361c37e8ee9bd09e Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 4 Jun 2026 17:44:02 +0800 Subject: [PATCH 08/12] refactor(genui): remove now-unneeded api-extractor lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock existed only to serialize the in-script 'pnpm run build' calls (concurrent rslib builds corrupting rspack's cache, #2794). That in-script build is gone — turbo now builds each package before its api-extractor task — so the api-extractor script only runs the extractor binary, which writes per-package outputs with no shared mutable state. Concurrent runs need no mutual exclusion, so the link/reap lock (and its stale-reaping TOCTOU flagged in review) is removed entirely. Verified: forced 'turbo api-extractor -- --local' across the genui graph -> 26/26 tasks, all 'API Extractor completed successfully', 0 TS errors. --- packages/genui/scripts/run-api-extractor.mjs | 98 +++----------------- 1 file changed, 13 insertions(+), 85 deletions(-) diff --git a/packages/genui/scripts/run-api-extractor.mjs b/packages/genui/scripts/run-api-extractor.mjs index ae4c8c7693..65c2a29455 100644 --- a/packages/genui/scripts/run-api-extractor.mjs +++ b/packages/genui/scripts/run-api-extractor.mjs @@ -3,81 +3,14 @@ // LICENSE file in the root directory of this source tree. import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { link, readFile, rm, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; -const genuiRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const lockPath = join(genuiRoot, '.api-extractor.lock'); -const lockTimeoutMs = 10 * 60 * 1000; const entryPointTimeoutMs = 5 * 1000; const retryDelayMs = 500; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const isProcessAlive = (pid) => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -}; - -const acquireLock = async () => { - // 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(), - }), - ); - - try { - const start = Date.now(); - - while (Date.now() - start < lockTimeoutMs) { - try { - 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 }); - continue; - } - await sleep(retryDelayMs); - } - } - - throw new Error(`Timed out waiting for ${lockPath}`); - } finally { - await rm(tmpPath, { force: true }); - } -}; - const run = (command, args) => { const result = spawnSync(command, args, { shell: process.platform === 'win32', @@ -145,19 +78,14 @@ const ensureMainEntryPoint = async () => { ); }; -await acquireLock(); - -try { - // Do NOT build here. Turbo's task graph already builds this package (the - // `api-extractor` task depends on `build`), and rebuilding in-script would - // 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 { - if (existsSync(lockPath)) { - await rm(lockPath, { force: true }); - } -} +// No lock is needed: turbo's task graph builds each package before its +// `api-extractor` task (which depends on `build`) and before every consumer +// build, so api-extractor only ever reads a finished `dist/`. Do NOT build +// here — a rebuild would 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` (TS2307/TS7016). +// `ensureMainEntryPoint` stays only as a last-resort build if the entry point +// is somehow missing. Concurrent api-extractor runs across packages touch only +// their own per-package outputs, so they need no mutual exclusion. +await ensureMainEntryPoint(); +run('api-extractor', ['run', '--verbose']); From 45d7648df422631c5f46f17432894b668a7bc97d Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 4 Jun 2026 17:47:26 +0800 Subject: [PATCH 09/12] fix(genui): add build to a2ui api-extractor deps (root override gap) packages/genui/a2ui/turbo.json defines its own api-extractor.dependsOn, and turbo replaces (not merges) array fields, so the root-level 'build' dep did not apply to @lynx-js/genui-a2ui#api-extractor. With the in-script build removed, a forced a2ui#api-extractor could run against a stale dist/index.d.ts. Add 'build' to a2ui's local api-extractor deps so its own package is rebuilt first too. Caught in review (Codex). Verified: every genui #api-extractor task now depends on its own #build, and forced 'turbo api-extractor -- --local' passes 26/26. --- packages/genui/a2ui/turbo.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/genui/a2ui/turbo.json b/packages/genui/a2ui/turbo.json index d639cc17d9..bd10f7eeb8 100644 --- a/packages/genui/a2ui/turbo.json +++ b/packages/genui/a2ui/turbo.json @@ -32,6 +32,7 @@ "api-extractor": { "dependsOn": [ "//#build", + "build", "@lynx-js/genui-a2ui-catalog-extractor#build", "@lynx-js/genui-cli#build" ], From 8dedae8827cdd4612bad00ba94ec229ffdb2df92 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 4 Jun 2026 18:47:33 +0800 Subject: [PATCH 10/12] fix(genui): scope api-extractor build dep to rust-free a2ui-prompt only The previous commit added 'build' to the root api-extractor task, which made every package's api-extractor build its package -- including react packages whose '^build' pulls 'react-transform#build:wasm' / 'swc-plugin-reactlynx#build' (cargo build-wasi). Those failed in the code-style-check / test-api jobs, which never built Rust before (the old graph only depended on '//#build', i.e. tsc). That is why CI went from the genui-cli TS2307 race to a cargo failure. Revert the broad 'build' deps (root, genui meta, a2ui) and instead scope a 'build' dep to a2ui-prompt's api-extractor only -- a2ui-prompt is Rust-free (no @lynx-js/react dep), so this orders a2ui-prompt#api-extractor after its single rslib build without pulling any cargo task. The genui-cli race is fixed by the already-removed in-script build (a2ui-prompt#build is now the sole dist writer, and genui-cli#build depends on it). a2ui/openui/meta dists build via the kept ensureMainEntryPoint fallback (single package, no turbo chain) when absent. Verified: full 'turbo api-extractor --dry=json' graph has zero cargo/react #build tasks; forced genui api-extractor runs pass 3/3 with 0 TS errors, 0 cargo invocations. --- packages/genui/a2ui-prompt/turbo.json | 7 +++++++ packages/genui/a2ui/turbo.json | 1 - packages/genui/turbo.json | 1 - turbo.json | 3 +-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/genui/a2ui-prompt/turbo.json b/packages/genui/a2ui-prompt/turbo.json index fa17b63f01..6adbe71600 100644 --- a/packages/genui/a2ui-prompt/turbo.json +++ b/packages/genui/a2ui-prompt/turbo.json @@ -22,6 +22,13 @@ "outputs": [ "dist/**" ] + }, + "api-extractor": { + "dependsOn": [ + "//#build", + "build" + ], + "cache": false } } } diff --git a/packages/genui/a2ui/turbo.json b/packages/genui/a2ui/turbo.json index bd10f7eeb8..d639cc17d9 100644 --- a/packages/genui/a2ui/turbo.json +++ b/packages/genui/a2ui/turbo.json @@ -32,7 +32,6 @@ "api-extractor": { "dependsOn": [ "//#build", - "build", "@lynx-js/genui-a2ui-catalog-extractor#build", "@lynx-js/genui-cli#build" ], diff --git a/packages/genui/turbo.json b/packages/genui/turbo.json index 27b59b0538..5d6532af58 100644 --- a/packages/genui/turbo.json +++ b/packages/genui/turbo.json @@ -39,7 +39,6 @@ "api-extractor": { "dependsOn": [ "//#build", - "build", "@lynx-js/genui-a2ui#build:api", "@lynx-js/genui-a2ui-catalog-extractor#build:api", "@lynx-js/genui-a2ui-prompt#build", diff --git a/turbo.json b/turbo.json index 42f73131c1..ec7fd32168 100644 --- a/turbo.json +++ b/turbo.json @@ -14,8 +14,7 @@ "tasks": { "api-extractor": { "dependsOn": [ - "//#build", - "build" + "//#build" ], "cache": false }, From f51a0a6f265ebe60f968cd542966d6e097b2468f Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 4 Jun 2026 19:14:54 +0800 Subject: [PATCH 11/12] fix(genui): order a2ui-catalog-extractor api-extractor after its build Same fix as a2ui-prompt: a2ui-catalog-extractor is rust-free, so add an explicit api-extractor -> build edge to extract from a freshly built dist instead of relying on the removed in-script build. Not applied to ui-judge: its build transitively pulls @lynx-js/react -> react-transform#build:wasm (cargo), which must not enter the api-extractor graph used by code-style-check; ui-judge relies on the ensureMainEntryPoint fallback. Verified: api-extractor dry-run graph has zero react/cargo #build tasks. --- packages/genui/a2ui-catalog-extractor/turbo.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/genui/a2ui-catalog-extractor/turbo.json b/packages/genui/a2ui-catalog-extractor/turbo.json index 294effadb6..0e27bb3695 100644 --- a/packages/genui/a2ui-catalog-extractor/turbo.json +++ b/packages/genui/a2ui-catalog-extractor/turbo.json @@ -33,6 +33,13 @@ "outputs": [ "dist/**" ] + }, + "api-extractor": { + "dependsOn": [ + "//#build", + "build" + ], + "cache": false } } } From 5eeb6cd3f88932d79d1eb6c3a5da16b40efc4818 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 4 Jun 2026 19:19:46 +0800 Subject: [PATCH 12/12] chore(changeset): update genui api-extractor changeset to match dist-race fix Rewrite the stale lock-TOCTOU description to the actual root cause (in-script rebuild racing consumer builds) and fix. Frontmatter stays empty: build-tooling only, no @lynx-js/genui API/runtime change. --- .changeset/fix-genui-api-extractor-dist-race.md | 5 +++++ .changeset/fix-genui-api-extractor-lock-race.md | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-genui-api-extractor-dist-race.md delete mode 100644 .changeset/fix-genui-api-extractor-lock-race.md diff --git a/.changeset/fix-genui-api-extractor-dist-race.md b/.changeset/fix-genui-api-extractor-dist-race.md new file mode 100644 index 0000000000..92a5362ede --- /dev/null +++ b/.changeset/fix-genui-api-extractor-dist-race.md @@ -0,0 +1,5 @@ +--- + +--- + +Fix a flaky CI failure in genui API extraction where `@lynx-js/genui-cli`'s `tsc` (and `@lynx-js/genui#api-extractor`) could fail with `TS2307` / `TS7016` ("Cannot find module `@lynx-js/genui-a2ui-prompt`"). `run-api-extractor.mjs` rebuilt each package in-script (`pnpm run build`), rewriting its `dist/` while turbo-scheduled consumer builds read the same `dist/`, so `tsc` could observe `index.js` without its freshly-cleaned `index.d.ts`. The script no longer builds — turbo's task graph builds each package, and the api-extractor task now depends on the package build for the rust-free genui packages — and the file lock that only existed to serialize those in-script builds is removed. diff --git a/.changeset/fix-genui-api-extractor-lock-race.md b/.changeset/fix-genui-api-extractor-lock-race.md deleted file mode 100644 index 9569be3ea0..0000000000 --- a/.changeset/fix-genui-api-extractor-lock-race.md +++ /dev/null @@ -1,5 +0,0 @@ ---- - ---- - -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`.