Skip to content

fix(build): one builder per package, so the required gates stop racing - #464

Merged
blove merged 1 commit into
mainfrom
blove/fix-workspace-build-race
Aug 17, 2026
Merged

fix(build): one builder per package, so the required gates stop racing#464
blove merged 1 commit into
mainfrom
blove/fix-workspace-build-race

Conversation

@blove

@blove blove commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

The workspace graph was declared twice: once in dependencies, and again by hand inside the script strings. pnpm already orders -r runs topologically, so the second copy bought nothing — and it let two processes build the same package at the same time.

The overlap, as measured

At the base I measured (fca7a789), @pretable/core and @pretable-internal/renderer-dom were topological siblings — both depended on grid-core, neither on the other — so pnpm -r started them at the same instant. A cold pnpm -r --filter './packages/*' build shows it directly:

20:33:43  grid-core build$ tsc -b
20:33:45  grid-core build: Done            <- pnpm already built it, topologically
20:33:45  core build$ pnpm --filter grid-core build && tsup
20:33:45  renderer-dom build$ pnpm --filter text-core build && ... layout-core ...
20:33:46  renderer-dom build: > layout-core > tsc -b     <- concurrent with core's

Two tsc -b processes emitting one dist/ is unsafe by construction: writeFileSync truncates before it writes, so a concurrent reader can see layout-core/dist/index.d.ts empty and report "is not a module".

I could not catch the corrupted read on this machine (0 failures in 10 cold races — APFS writes a 1 KB .d.ts too fast). So I measured the precondition instead. Two cold tsc -b runs launched together emitted that file twice in 2 of 3 runs, once in the third. That split is the race: whether the second process's up-to-date check lands before or after the first process's write is a coin flip, and on a 2-core runner with a cold page cache the window is wider than here.

Correction, after rebasing

That particular sibling pair no longer exists. #448 landed while this branch was open and made renderer-dom depend on @pretable/core, which linearizes the tail of the graph:

LEVEL 2: @pretable/core
LEVEL 3: @pretable-internal/renderer-dom
LEVEL 4: @pretable/react

Levels 2–4 are now single-occupancy, and the two packages that share level 1 (grid-core, bench-runner) have disjoint project references. So on today's main the race is dormant, not fixed — it was real and measured at the commit I measured it on, and what made it possible is still in the tree. It comes back the moment a second package joins any level, which is an ordinary thing to do and carries no warning that it re-arms a flake in a required gate.

I'd rather say that plainly than leave the PR claiming a live failure.

The change

Each package builds only itself. The root test and typecheck build the packages once, up front, before running either.

Apps keep their standalone prepare:depspnpm --filter @pretable/app-bench dev still works from an empty tree — but derive the closure with pnpm's <pkg>^... filter instead of listing siblings, so it stays correct when the graph changes. (It already has: this branch needed no edit to track #448's rewiring, while the hand-written chain it replaced named @pretable/react and would have kept working only by luck.) The two CI jobs that build only the published packages now ask for their dependency closures (<pkg>...).

Wall clock locally is unchanged (8s both ways) — the redundant builds were mostly up-to-date no-ops here. This is a correctness fix, not a speed one.

The guard

scripts/__tests__/workspace-scripts-own-one-package.test.mjs holds the invariant: a workspace script builds its own package and nothing else. It discovers the workspace rather than listing it, so a package added tomorrow is covered with no edit.

It is mutation-tested. Re-adding the exact prefix this PR removes fails it; so does a filter naming a package that does not exist; and the sanctioned derived form still passes. Its first draft passed both mutations — it skipped any --filter argument containing /, which is every scoped package name, so it was checking nothing. That blind spot is now called out in the code so the next person doesn't reintroduce it.

Verification

Cold, rebased onto cf3ff2b4: typecheck, test (react 1227, website 556, bench 156, renderer-dom 127), build including the Next site, lint, format, api:check, lint:packaging, typecheck:public, typecheck:performance, the rewritten CI build command, and a standalone prepare:deps from an empty tree — all exit 0.

One note for reviewers pulling this branch: #448 added a workspace dependency, so a node_modules predating it will fail renderer-dom's build with Cannot find module '@pretable/core'. That is a stale install, not this change — pnpm install clears it.

No changeset: this changes how the workspace builds, not what any published package does.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
pretable Ignored Ignored Aug 17, 2026 4:18am

Request Review

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-s4l0sopmb-cacheplane.vercel.app
Commit: a4b8c950213c6ecbeced46836bd7742870c12122

Updated automatically by the deploy-preview job.

`@pretable/core` and `@pretable-internal/renderer-dom` are topological
siblings -- both depend on `grid-core`, neither on the other -- so
`pnpm -r` starts them at the same instant. Both scripts then rebuilt
packages pnpm had already built: core ran `pnpm --filter grid-core
build`, renderer-dom ran three such prefixes. A cold
`pnpm -r --filter './packages/*' build` shows it directly:

    20:33:43  grid-core build$ tsc -b
    20:33:45  grid-core build: Done            <- pnpm already built it
    20:33:45  core build$ pnpm --filter grid-core build && tsup
    20:33:45  renderer-dom build$ pnpm --filter text-core build && ...
    20:33:46  renderer-dom build: > layout-core > tsc -b

Two `tsc -b` processes emitting one `dist/` is unsafe by construction:
`writeFileSync` truncates before it writes, so a concurrent reader can
see `layout-core/dist/index.d.ts` empty and report "is not a module".
Measured directly, two cold `tsc -b` runs launched together emitted that
file twice in 2 of 3 runs and once in the third -- the nondeterminism is
the race. It failed the required `typecheck` gate intermittently, which
is the corrosive kind of flake: it teaches people to re-run until green.

The graph was declared twice -- in `dependencies`, and again by hand
inside the script strings. pnpm already orders `-r` runs topologically,
so the second copy bought nothing and was what created the overlap.

This deletes it. Each package builds only itself; the root `test` and
`typecheck` scripts build the packages once, up front, before running
either. Apps keep their standalone `prepare:deps` but derive the closure
with pnpm's `<pkg>^...` filter instead of listing siblings, so it stays
correct when the graph changes. The two CI jobs that build only the
published packages now ask for their dependency closures (`<pkg>...`).

After the fix the same cold build shows every package built exactly once,
by one process, with no nested `pnpm --filter` at all.

`scripts/__tests__/workspace-scripts-own-one-package.test.mjs` holds the
invariant. It discovers the workspace rather than listing it, and it is
mutation-tested: re-adding the exact prefix this commit removes fails it,
and so does a filter naming a package that does not exist. Its first
draft passed both mutations -- it skipped any argument containing "/",
which is every scoped package name -- so the blind spot is now called out
in the code.

Verified cold, on this branch: typecheck, test (react 1216, website 554,
row-model 326, renderer-dom 127, grid-core 124, bench 155), build
including the Next site, lint, format, api:check, lint:packaging,
typecheck:public, typecheck:performance, the rewritten CI build command,
and a standalone `prepare:deps` from an empty tree -- all exit 0.

No changeset: this changes how the workspace builds, not what any
published package does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@blove
blove force-pushed the blove/fix-workspace-build-race branch from 83a4485 to a4b8c95 Compare August 17, 2026 04:18
@blove
blove merged commit 4e1a93a into main Aug 17, 2026
20 checks passed
@blove
blove deleted the blove/fix-workspace-build-race branch August 17, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant