Latest Results
fix(genui): close TOCTOU in api-extractor lock (#2780)
## Summary
Fix the CI flake on `code-style-check` where
`@lynx-js/genui#api-extractor` fails with `TS7016: Could not find a
declaration file for module '@lynx-js/genui/<subpackage>'`. The root
cause is a TOCTOU bug in `acquireLock`, not a Turbo dependency gap.
## Evidence
Failing job:
https://github.com/lynx-family/lynx-stack/actions/runs/26868425349/job/79238017067
Tracing the events in the log (Turbo flushes a task's stdout at
completion, so timestamps cluster):
```
06:52:48.81 @lynx-js/genui-a2ui-prompt:build:api cache miss → rslib build
✓ "ready built in 0.17 s"
✓ "declaration files bundled successfully: dist/index.d.ts in 0.55 s"
✓ dist/index.js + dist/index.d.ts both written
06:53:01.63 @lynx-js/genui:api-extractor cache bypass, force executing
$ pnpm run clean && tsc --project tsconfig.build.json
✗ index.ts(70,8): TS7016: '@lynx-js/genui/a2ui-prompt'
dist/index.js implicitly has 'any' type
06:53:01.67 @lynx-js/genui-a2ui-prompt:api-extractor cache bypass, force executing
$ rslib build ← runs rslib AGAIN, rewriting dist/index.{js,d.ts}
```
So `build:api` finished and emitted both `.js` and `.d.ts` 13 s before
the failure. `a2ui-prompt#api-extractor`'s script then re-ran `rslib
build` at roughly the same wall-clock moment as `genui#api-extractor`'s
`tsc`. Turbo's dependency graph allows both `<sub>#api-extractor` and
`genui#api-extractor` to run in parallel (they only share `//#build`),
so `packages/genui/scripts/run-api-extractor.mjs` uses a shared file
lock to serialize them.
## Root cause
`acquireLock` had a TOCTOU bug:
```js
const file = await open(lockPath, 'wx'); // creates an EMPTY file
await file.writeFile(JSON.stringify(...)); // populates it (separate syscall)
```
A second process that hits `open(wx)` during this window gets `EEXIST`,
reads the lock file, finds `""`, `JSON.parse` throws, the `catch` block
calls `rm(lockPath)` and `continue`s — and on the next loop iteration
`open(wx)` succeeds. **Both processes now think they hold the lock.**
I reproduced this in isolation against the original code with 5
concurrent acquirers and a 50 ms simulated write delay:
```
maxConcurrent=5 (BUG: lock granted to all 5 simultaneously)
maxConcurrent=1 (FIXED: properly serialized)
```
With the bug, `<sub>`'s `rslib build` rewrites
`<sub>/dist/index.{js,d.ts}` while `genui`'s `tsc` reads it. Rslib emits
`.js` first (~0.14 s) and `.d.ts` later (~0.60 s), so `tsc` catches a
window where `dist/index.js` exists but `dist/index.d.ts` does not —
exactly the TS7016 the log shows.
## Fix
Wait on read/parse failures instead of deleting the lock file. Only
clear it when we can prove the holder's PID is dead.
```diff
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 {
- await rm(lockPath, { force: true });
- continue;
+ // Empty or unparseable — the holder is mid-write between
+ // open(wx) and writeFile. Wait instead of deleting.
}
+ if (staleHolder) {
+ await rm(lockPath, { force: true });
+ continue;
+ }
+ await sleep(retryDelayMs);
```
The redundant `rslib build` inside `<sub>#api-extractor` is preserved —
once the lock serializes correctly, it runs while `genui`'s tsc waits,
then `genui`'s tsc reads the freshly-emitted `dist`. It's wasted work
but no longer racy.
## Test plan
- [x] Concurrent acquireLock repro: 5 contenders / 50 ms write delay →
`maxConcurrent=1` (was 5)
- [x] `pnpm turbo run api-extractor --filter='@lynx-js/genui'` succeeds
locally (6/6, dist intact)
- [ ] CI `code-style-check` passes Latest Branches
0%
0%
p/deanjingshui/physical-exlusive +9%
enrich-unknown-property-error © 2026 CodSpeed Technology