Skip to content

build: rebuild the PCH in the same run that regenerates a codegen header it includes - #37992

Open
robobun wants to merge 4 commits into
mainfrom
farm/f8e9f635/pch-codegen-depfile-spelling
Open

robobun wants to merge 4 commits into
mainfrom
farm/f8e9f635/pch-codegen-depfile-spelling

Conversation

@robobun

@robobun robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • On a warm debug tree, adding a builtin JS module (any new file under src/js/) makes the first bun bd fail and the second one succeed. First run, while compiling the unified TU that holds InternalModuleRegistry.cpp:
    codegen/InternalModuleRegistry+createInternalModuleById.h:120:17: error: no member named 'InternalNetServerAbortSignal' in 'Bun::InternalModuleRegistry::Field'
    fatal error: file '.../build/debug/codegen/InternalModuleRegistry+enum.h' has been modified since the precompiled header 'pch/root-pch.h.hxx.pch' was built
    
    The run regenerated InternalModuleRegistry+enum.h but did not rebuild the PCH that includes it; the second run does ([1/2] pch, [2/2] cxx).
  • Cause: the codegen headers are order-only inputs of the PCH (scripts/build/bun.ts, PCH step), and the depfile is what is supposed to re-dirty the PCH when one changes. The depfile does not do that, because the flags say -I/abs/build/debug/codegen (bun.ts:257-258 before this change), so clang records /abs/build/debug/codegen/InternalModuleRegistry+enum.h, while ninja.ts declares the codegen edge's output as codegen/InternalModuleRegistry+enum.h. Ninja matches the two by string: the absolute entry is a node no edge produces, stat'd once at startup, so the PCH keeps its startup verdict while codegen rewrites the file later in the same run. ninja -t deps pch/root-pch.h.hxx.pch on the unfixed tree shows the absolute entries; ninja -t query on the absolute path shows a node with no producing edge.
  • The pch rule is the only compile rule that bypasses ccache (deliberately, compile.ts), and ccache's CCACHE_BASEDIR rewriting is what had been relativizing the -I flags of every other rule, so under bun bd only the PCH lagged. Running ninja directly (no ccache env), or building on a host without ccache, gives every TU the same one-build lag: reproduced below by compiling one TU through plain ninja and then regenerating its headers, after which ninja reports no work to do for both the PCH and the TU.
  • Second, smaller gap with the same symptom: BunBuiltinNames+extras.h (written by bundle-functions.ts, included by BunBuiltinNames.h, which root-pch.h reaches) was not a declared output of any edge at all, so even a correctly spelled depfile entry could not follow a change to it. It is one of the seven codegen/ headers in the PCH's depfile; the other six are declared.
  • The same shape exists one level down (found in review): the direct deps that include another dep's generated headers (libarchive, libspng and lsquic include zlib's zlib.h/zconf.h out of deps/zlib/) compiled with -I/abs/build/debug/deps/zlib and had those headers only as order-only inputs (source.ts, emitDirect), so a zlib rebuild reached their 192 objects one build late under the same conditions. emitNestedCmake and bun's own compiles already take cross-dep outputs as implicit inputs; emitDirect was the odd one out.

Fix

  • compile.ts: new includeFlags() spells include dirs inside buildDir buildDir-relative (-Icodegen, -Ideps/zlib, -I.), via the same n.rel() ninja.ts uses for the outputs, so a depfile entry for a generated header is the declared output's string. Dirs outside buildDir stay absolute: nothing found through them is a declared output (dep headers are tracked as implicit inputs, see below), and a relative spelling of the shared cache dir would vary with the checkout's depth and stop ccache entries from being shared across worktrees.
  • compile.ts: cc()/cxx()/pch() reject an absolute -I into the build dir at configure time, so the spelling is enforced where the depfile is consumed rather than by convention; the whole current graph (1221 objects + PCH) passes, and the check is not measurable in configure time (the emitBun phase stays at ~120 ms either way).
  • source.ts (emitDirect): DirectBuild.includes goes through includeFlags() (libspng moves its hand-written -I from cflags into includes so it takes the same path), and the fetchDeps producers' outputs (their generated headers + source stamp) become implicit inputs of every object (nasm() gained the implicitInputs option so the .asm objects get the same list as the .c/.cpp ones), as in emitNestedCmake; only the dep's own source stamp stays order-only. With that, a cross-dep header is tracked both ways: touch build/debug/deps/zlib/zlib.h now schedules exactly zlib's 49 objects plus libarchive's 123, lsquic's 69 and libspng's 1 (ninja -n).
  • bun.ts: the flag assembly moves into bunCompileFlags() (used for the PCH, cxx and cc edges alike) and goes through includeFlags(); the comments that claimed codegen outputs "don't change mid-build" now state the actual invariant.
  • codegen.ts: emitJsModules declares BunBuiltinNames+extras.h (it lands in cppHeaders/cppAll like the other headers of that step; bundle-functions.ts already writes it with writeIfNotChanged, so restat prunes it as usual). The "undeclared outputs" comment now says what undeclared means for tracking.
  • Why this is the right layer: the build's documented design is order-only + depfile for codegen headers. The depfile already names exactly the generated headers a compile reaches; the only thing missing was that its entries and the declared outputs were different strings. Matching the spelling makes the design hold for every rule, with or without ccache, without making the PCH depend on every generated file (which would rebuild all of C++ on every .classes.ts or builtin edit) and without a hand-maintained list of the headers root-pch.h happens to reach. Restat still prunes: touching a JS module that changes no header runs bundle-modules and nothing else (checked below).
  • Cost: the -I spelling changes the command line of every C/C++ edge (bun's and the direct deps'), so existing trees recompile once (the same as any flag change). ccache'd compiles already saw these flags in relative form, so the hashing of source paths is unchanged; the dep objects came back from ccache here.
  • Verified:
    • test/internal/build-codegen-header-tracking.test.ts (passes with bun bd test; fails with scripts/ stashed). Each part has its own test that fails when just that part is reverted: bunCompileFlags (bun's -I spelling), the compile constructors (the configure-time check, including the quoted form emitDirect emits), emitDirect (a synthetic producer/consumer pair resolved through resolveDep inside a scratch build dir: the generated header is an implicit input of the consumer's object and its -I is the declared spelling), and emitJsModules (BunBuiltinNames+extras.h declared). No compiler, ninja or subprocess involved; each test takes milliseconds (what a debug build spends on the file is loading the build scripts).
    • Unfixed, probe module added: ninja <registry TU> plans codegen + cxx only and fails as above; the second invocation rebuilds the PCH. Fixed: one invocation runs codegen, pch, cxx, for adding the module and for removing it. Transcripts in the details block.
    • Fixed, ninja -t deps for the PCH and for a TU compiled through plain ninja (no ccache env) both list codegen/... entries; all seven headers the PCH reaches are now declared outputs.
    • Fixed, touch of a JS module with no header impact: bundle-modules runs, pch/cxx are restat-pruned.
    • Full bun bd on the changed flags links and passes its smoke test, and a second one is a no-op; build.ninja differs from before in the -I spellings and in the direct-dep objects' implicit inputs (e.g. spng.c.o: cc ... | deps/zlib/zlib.h deps/zlib/zconf.h ../../vendor/zlib/.ref || ...). Existing test/internal/build-* tests still pass, including the bindgen declared-outputs tests that landed in the meantime (build: declare the bindgen and bindgenv2 headers as outputs of their codegen edges #38035, whose codegen.ts comment this merges with).
    • The first CI run (before the dep-side commit) built on every platform, which covers clang-cl and darwin taking the relative -I.

Background

  • Order-only input (|| in ninja): must exist before the edge runs, but its mtime never dirties the edge. Implicit input (|): also dirties the edge. The build uses order-only for the ~50 codegen headers so a compile is only rebuilt for the headers it really includes, as recorded by the depfile.
  • Depfile (deps = gcc): clang's -MD/-MMD output listing every file the compile read; ninja stores it and adds each entry as an implicit input of the edge on later runs. Entries are plain path strings, spelled exactly as clang constructed them from the -I directory and the #include name; ninja does not resolve two spellings of one file to one node. (Windows uses deps = msvc instead, where ninja itself normalizes /showIncludes paths to buildDir-relative, so the lag described here is a linux/darwin problem and the relative -I is simply equivalent there.)
  • Dirtiness is computed once per run: ninja stats every input before building. An output of another edge is re-evaluated after that edge runs (and restat can un-dirty it); a file that no edge declares keeps its startup stat for the whole run, so a change made to it mid-run is seen by the next run.
  • PCH (pch/root-pch.h.hxx.pch): root-pch.h precompiled once; every C++ TU has an implicit dep on it and gets the PCH's copy of every header it contains. When a header inside it has changed on disk since, a TU either fails clang's PCH validation (the "has been modified since the precompiled header was built" error in the report) or, as in the runs below, is compiled with the PCH's stale copy and fails on the first inconsistency with the freshly generated headers it includes itself; a change that stayed consistent would go into the binary stale. Seven codegen/ headers are inside it, so a change to any of them must rebuild the PCH before any TU compiles.
  • Direct deps and fetchDeps (source.ts): most vendored libraries are compiled straight into bun's ninja graph (emitDirect, one cc edge per file; WebKit is the one nested cmake build). A dep's fetchDeps names the deps whose headers it includes at compile time; resolveDep turns them into the producers' outputs, which for a direct producer are its generated headers (zlib's zlib.h/zconf.h are substituted from .in templates by dep_subst edges into deps/zlib/) plus its source stamp (the .ref written by the fetch, or the source dir for in-tree deps). depHeaderSignal in bun.ts is the same set used as implicit inputs of bun's own compiles.
  • CCACHE_BASEDIR (configure.ts): ccache rewrites absolute paths under the repo root to cwd-relative ones before hashing and before invoking the compiler, which is why depfiles of ccache'd bun bd compiles already contained codegen/X.h. The pch rule skips ccache because a cached .pch carries another worktree's absolute header paths.
Probe transcripts (linux-x64, warm build/debug, TU = obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o, the bundle holding InternalModuleRegistry.cpp)

Unfixed, echo 'export default {};' > src/js/internal/zz_probe.ts, reconfigure:

$ ninja -C build/debug obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o
[1/2] gen JS modules (bundle-modules)
[2/2] cxx obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o
.../codegen/InternalModuleRegistry+createInternalModuleById.h:381:17: error: no member named 'InternalZzProbe' in 'Bun::InternalModuleRegistry::Field'
ninja: build stopped: subcommand failed.
$ ninja -C build/debug obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o
[1/2] pch pch/root-pch.h.hxx.pch
[2/2] cxx obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o

Unfixed, after that TU had been compiled by plain ninja (no ccache env; its deps log now holds absolute codegen/ paths), regenerate the headers by removing the probe and touching a surviving module:

$ ninja -C build/debug obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o
[1/1] gen JS modules (bundle-modules)            <- enum.h changed; neither the PCH nor the TU is rebuilt
$ ninja -C build/debug -n obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o
[1/2] pch pch/root-pch.h.hxx.pch                 <- the next run would do it
[2/2] cxx obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o

Unfixed, deps recorded for the PCH (the TU compiled through bun bd, i.e. through ccache, recorded codegen/... instead):

$ ninja -C build/debug -t deps pch/root-pch.h.hxx.pch | grep codegen/
    /workspace/bun/build/debug/codegen/ZigGeneratedClasses+DOMClientIsoSubspaces.h
    /workspace/bun/build/debug/codegen/ZigGeneratedClasses+DOMIsoSubspaces.h
    /workspace/bun/build/debug/codegen/BunBuiltinNames+extras.h
    /workspace/bun/build/debug/codegen/SyntheticModuleType.h
    /workspace/bun/build/debug/codegen/InternalModuleRegistry+numberOfModules.h
    /workspace/bun/build/debug/codegen/InternalModuleRegistry+enum.h
    /workspace/bun/build/debug/codegen/ZigGeneratedClasses+lazyStructureHeader.h
$ ninja -C build/debug -t query /workspace/bun/build/debug/codegen/InternalModuleRegistry+enum.h
/workspace/bun/build/debug/codegen/InternalModuleRegistry+enum.h:
  outputs:                                       <- no "input:" edge: a source file to ninja
$ ninja -C build/debug -t query codegen/InternalModuleRegistry+enum.h
codegen/InternalModuleRegistry+enum.h:
  input: codegen

Fixed (same plain ninja, no ccache env). Baseline rebuild from the flag change, then add the probe, then remove it (+ touch a surviving module), then touch a module with no header impact:

$ ninja -C build/debug obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o      # probe added
[1/3] gen JS modules (bundle-modules)
[2/3] pch pch/root-pch.h.hxx.pch
[3/3] cxx obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o
$ ninja -C build/debug obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o
ninja: no work to do.
$ ninja -C build/debug obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o      # probe removed
[1/3] gen JS modules (bundle-modules)
[2/3] pch pch/root-pch.h.hxx.pch
[3/3] cxx obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o
$ ninja -C build/debug obj/unified/UnifiedSource-src_jsc_bindings-7.cpp.o      # touch src/js/internal/fifo.ts
[1/3] gen JS modules (bundle-modules)                                           <- outputs unchanged, pch/cxx pruned by restat
$ ninja -C build/debug -t deps pch/root-pch.h.hxx.pch | grep codegen/
    codegen/ZigGeneratedClasses+DOMClientIsoSubspaces.h
    codegen/ZigGeneratedClasses+DOMIsoSubspaces.h
    codegen/BunBuiltinNames+extras.h
    codegen/SyntheticModuleType.h
    codegen/InternalModuleRegistry+numberOfModules.h
    codegen/InternalModuleRegistry+enum.h
    codegen/ZigGeneratedClasses+lazyStructureHeader.h
$ ninja -C build/debug -t query codegen/BunBuiltinNames+extras.h
codegen/BunBuiltinNames+extras.h:
  input: codegen

build.ninja diff from the change, on the PCH edge and every compile edge: -I/workspace/bun/build/debug/codegen -> -Icodegen, -I/workspace/bun/build/debug -> -I., -I/workspace/bun/build/debug/deps/{zlib,libjpeg-turbo,cares} -> -Ideps/...; all other -I flags unchanged.

One neighbouring gap found while reproducing is filed separately and not changed here: deleting a globbed codegen input does not re-run its step at all (the edge's input set is not tracked). The other one, the undeclared bindgen/bindgenv2 Generated*.h headers, has since landed as #38035.


[stamp-90s] gate passed · iteration 1 · 8 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/internal/build-codegen-header-tracking.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/internal/build-codegen-header-tracking.test.ts
bun test v1.4.0 (900fb0a84)

test/internal/build-codegen-header-tracking.test.ts:
(pass) includeFlags > directories inside buildDir are spelled the way ninja spells the outputs in them [100.30ms]
(pass) includeFlags > the depfile entry for a codegen header is the string its edge declares [14.22ms]
(pass) bunCompileFlags > the PCH/cxx/cc flag lists spell codegen/ and buildDir-local dep includes relative [520.64ms]
(pass) compile constructors > cc, cxx and pch reject a build-dir include spelled absolutely [257.91ms]
(pass) compile constructors > the includeFlags() spelling and source-tree includes are accepted as they are [114.69ms]
(pass) emitDirect > a fetchDeps producer's generated headers are implicit inputs, included via the declared spelling [346.24ms]
(pass) emitJsModules > declares BunBuiltinNames+extras.h, which root-pch.h reaches [99.25ms]

 7 pass
 0 fail
 31 expect() calls
Ran 7 tests across 1 file. [7.88s]
Exit: 0
diff hotspot
scripts/build/CLAUDE.md                            |   4 +-
 scripts/build/bun.ts                               |  60 ++--
 scripts/build/codegen.ts                           |  18 +-
 scripts/build/compile.ts                           |  89 +++++-
 scripts/build/deps/libarchive.ts                   |   3 +-
 scripts/build/deps/libspng.ts                      |   6 +-
 scripts/build/source.ts                            |  31 +-
 .../internal/build-codegen-header-tracking.test.ts | 332 +++++++++++++++++++++
 8 files changed, 497 insertions(+), 46 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                                 reads  edits  tests
scripts/build/CLAUDE.md                                  4      5      0
scripts/build/bun.ts                                     6     11      0
scripts/build/codegen.ts                                 6      7      0
scripts/build/compile.ts                                 4     11      0
scripts/build/deps/libarchive.ts                         1      1      0
scripts/build/deps/libspng.ts                            1      1      0
scripts/build/source.ts                                  7      6      0
test/internal/build-codegen-header-tracking.test.ts      4     12      0

@coderabbitai

coderabbitai Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6caa09b2-a737-4e7f-88c4-e4c8f7d0104b

📥 Commits

Reviewing files that changed from the base of the PR and between 2a3b9c5 and d81933d.

📒 Files selected for processing (5)
  • scripts/build/CLAUDE.md
  • scripts/build/bun.ts
  • scripts/build/codegen.ts
  • scripts/build/compile.ts
  • test/internal/build-codegen-header-tracking.test.ts

Walkthrough

Changes

Build header dependency tracking

Layer / File(s) Summary
Relative compile flag generation
scripts/build/compile.ts, scripts/build/bun.ts, test/internal/build-codegen-header-tracking.test.ts
includeFlags() now emits build-directory-relative include paths. bunCompileFlags() centralizes C and C++ flag assembly. Tests verify relative and absolute path handling.
Declared generated-header outputs
scripts/build/codegen.ts, scripts/build/CLAUDE.md, test/internal/build-codegen-header-tracking.test.ts
emitJsModules is exported and declares BunBuiltinNames+extras.h as an output. Documentation and tests cover PCH dependency tracking.
Build tracking guidance and validation setup
scripts/build/CLAUDE.md, test/internal/build-codegen-header-tracking.test.ts
Documentation explains depfile path matching and PCH dependency categories. Tests inspect emitted Ninja rules without invoking compilers.

Suggested reviewers: jarred-sumner, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main fix: rebuilding the PCH in the same run as codegen header regeneration.
Description check ✅ Passed The description explains the problem, implementation, rationale, and verification in detail, although it does not use the template headings exactly.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:02 AM PT - Aug 13th, 2026

✅ @robobun, your commit 900fb0a84a3fec112338a593cb9fd9a4af29318d passed in Build #94508! 🎉


🧪   To try this PR locally:

bunx bun-pr 37992

That installs a local version of the PR into your bun-37992 executable, so you can run:

bun-37992 --bun

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fixed, waiting on CI (current head 900fb0a).

  • Reproduced on a warm linux-x64 build/debug tree: after adding a file under src/js/internal/, the first ninja run regenerates InternalModuleRegistry+enum.h and compiles the registry TU against the old PCH (fails); the second run rebuilds the PCH. ninja -t deps shows the PCH's codegen entries recorded as absolute paths, which ninja does not connect to the codegen/... outputs.
  • With this branch the same probe converges in one run (codegen, pch, cxx), in both directions, and a no-op JS touch is still pruned by restat. Transcripts are in the PR description.
  • Second revision (after review): the same spelling rule is applied to the direct deps that include zlib's generated headers (libarchive, libspng, lsquic), those objects take the producer's outputs as implicit inputs like emitNestedCmake already did, and cc()/cxx()/pch() now reject an absolute build-dir -I at configure time; 900fb0a extends the implicit inputs to the .asm objects as well (review). Rebased onto main to merge the codegen.ts comment with build: declare the bindgen and bindgenv2 headers as outputs of their codegen edges #38035.
  • Test: test/internal/build-codegen-header-tracking.test.ts (one test per part; each fails when only its part is reverted).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — focused build-system fix that makes depfile entries string-match declared codegen outputs so the PCH rebuilds in the same run.

What was reviewed:

  • includeFlags() in compile.ts: verified n.rel() semantics (returns "" for buildDir, native-sep relative otherwise); the isAbsolute(rel) || rel === ".." || rel.startsWith(".."+sep) guard correctly keeps out-of-tree/cache dirs absolute and handles the ..odd-named-subdir edge case the test pins.
  • BunBuiltinNames+extras.h declaration: confirmed bundle-functions.ts writes it unconditionally with writeIfNotChanged, so declaring it as an output is safe for restat and won't fail on a fresh build.
  • compile_commands.json still resolves: entries use directory: cfg.buildDir, so relative -Icodegen is interpreted correctly by clangd.
  • Test follows the existing test/internal/build-*.test.ts pattern (pure ninja-emission, no compiler/subprocess).
Extended reasoning...

Overview

This PR fixes a two-build-convergence bug in the ninja build graph: on a warm tree, regenerating a codegen header that root-pch.h includes did not rebuild the PCH in the same run, because the PCH's depfile recorded the header under an absolute path (-I/abs/build/debug/codegen) while the codegen edge declares it buildDir-relative (codegen/X.h), and ninja matches the two by string. The fix introduces includeFlags() in compile.ts to spell buildDir-internal -I dirs the way ninja.ts spells declared outputs, routes bunCompileFlags() in bun.ts through it, and declares the previously-undeclared BunBuiltinNames+extras.h output in emitJsModules. Comments in bun.ts/codegen.ts/compile.ts/CLAUDE.md are updated to state the actual invariant. A pure ninja-emission test pins all three properties.

Files touched: scripts/build/{compile.ts, bun.ts, codegen.ts, CLAUDE.md} and a new test/internal/build-codegen-header-tracking.test.ts. No runtime or user-facing code.

Security risks

None. This is entirely build-graph/flag-assembly logic in scripts/build/; it touches no runtime, network, auth, or user-input code paths.

Level of scrutiny

Medium — build-system correctness, not shipped runtime code. The blast radius is developer/CI incremental-rebuild behavior. The worst realistic failure mode is a mis-spelled -I breaking a compile, which CI catches immediately; the more subtle risk (over- or under-invalidation) is exactly what the PR is fixing, and the description shows ninja -t deps/-t query transcripts demonstrating one-build convergence and restat pruning both still hold. Windows uses deps: msvc (/showIncludes) rather than gcc-style depfiles, so the string-match mechanism differs there — but relative -I paths are still valid for clang-cl running from buildDir, so the change is at worst a no-op on that platform.

Other factors

  • The refactor is behavior-preserving apart from the -I spelling: bunCompileFlags() produces the same include set (bunIncludes(cfg) + buildDir + depIncludes) and defines as before, only routed through includeFlags(). ComputedFlags was already exported from flags.ts.
  • Verified n.rel() in ninja.ts returns relative(buildDir, path) ("" for buildDir itself), matching the -I. special case; and that bundle-functions.ts:841 writes BunBuiltinNames+extras.h inside an unconditional block, so declaring it as an output cannot leave ninja waiting on a file that isn't produced.
  • compile_commands.json entries carry directory: cfg.buildDir, so clangd resolves the now-relative -Icodegen/-I. correctly.
  • The new test follows the four existing test/internal/build-*.test.ts files' pattern (mock Toolchain, resolveConfig with a fake sysroot, direct Ninja construction, no subprocess/compiler), and the PR description states it fails with each half of the fix reverted independently.
  • Documented cost (one full C++ recompile on warm trees due to the flag-line change) is unavoidable and clearly called out.

Jarred-Sumner pushed a commit that referenced this pull request Aug 13, 2026
…codegen edges (#38035)

### Problem
- On a warm tree, editing a `.bind.ts` or `.bindv2.ts` file so that its
generated header changes leaves every hand-written TU that includes the
header unbuilt for one `bun bd`; the second `bun bd` compiles it. Probe
on `build/debug` (full transcript below): append a `fn` to
`src/runtime/api/BunObject.bind.ts`, build
`obj/src/jsc/bindings/BunObject.cpp.o`, and the run is `[1/1] gen
.bind.ts -> GeneratedBindings.cpp` while `ninja -n` afterwards still
lists `cxx obj/src/jsc/bindings/BunObject.cpp.o`. The first build
therefore links a `BunObject.cpp.o` compiled against the old
`GeneratedBunObject.h` (old struct layouts, old function set) with a
fresh `GeneratedBindings.cpp.o`.
- Cause: none of the `Generated*.h` files is a declared output of any
ninja edge. `emitBindgen` (`scripts/build/codegen.ts`) declares only
`GeneratedBindings.cpp`, while `bindgen.ts` also writes one
`Generated<Name>.h` per `.bind.ts` (`src/codegen/bindgen.ts:1665`);
bindgenv2's `list-outputs` (`src/codegen/bindgenv2/script.ts:56`)
reports only `cppSourcePath`, while `generate()` also writes
`cppHeaderPath` for every type, and `emitBindgenV2` asserted that
nothing but `.cpp` comes back. On a warm `build/debug`, `ninja -t query
codegen/<name>` shows no producing edge for all 7 bindgen headers and
all 9 bindgenv2 headers, and `ninja -t deps
obj/src/jsc/bindings/BunObject.cpp.o` lists
`codegen/GeneratedBunObject.h`.
- Why that makes the TU lag: compiles are order-only on codegen and rely
on the depfile to be re-dirtied. Ninja re-checks a depfile entry after
the edge that produces it ran only when the entry names a declared
output; an entry no edge declares is a source file to ninja, stat'd once
at startup, so the header rewritten later in the same run is only seen
by the next run (the invariant `bun.ts` states for `cppAll`, "those
headers ARE declared ninja outputs with restat, so depfile tracking is
exact", did not hold for these).
- Affected includers: `BunObject.cpp` and `ZigGlobalObject.cpp`
(`GeneratedBunObject.h`), `NodeModuleModule.cpp`
(`GeneratedNodeModuleModule.h`), `GeneratedBindings.cpp`
(`GeneratedBunObject.h`, `GeneratedNodeOs.h`, `GeneratedFmtJsc.h`), and
each generated bindgenv2 `.cpp`, which includes its own header and,
through it, the header-only union types (`GeneratedSSLConfig.cpp` ->
`GeneratedSSLConfigFile.h`, `GeneratedALPNProtocols.h`); a union has no
`.cpp`, so until now it had no declared output at all.

### Fix
- `src/codegen/bindgenv2/script.ts`: `list-outputs` reports
`cppHeaderPath` for every type with a header, mirroring what
`generate()` writes (`hasCppHeader` is the cheap twin of `cppHeader`
that `base.ts` defines for this purpose).
- `scripts/build/codegen.ts`, `emitBindgenV2`: accepts `.h` entries from
`list-outputs` (still rejecting anything else), declares them on the
edge with the `.cpp` files, and pushes them into `cppHeaders`, so they
reach `cppAll` like the other generated headers.
- `scripts/build/codegen.ts`, `emitBindgen`: declares
`Generated<Stem>.h` for every file in `sources.bindgen` next to
`GeneratedBindings.cpp` and pushes them into `cppHeaders`. The stem
transform is the one `bindgen.ts` uses (`pascal()` in
`bindgen-lib-internal.ts`, `node_os` -> `NodeOs`), derived at configure
time like the node-fallbacks and string-map outputs are; the header set
is a function of the file list the build already globs, so a
configure-time spawn is not needed (bindgenv2 needs one because its set
depends on what the files export). The test below pins the two sides to
each other against the real `bindgen.ts`.
- The file's "Undeclared outputs" comment now states the rule (a file
that gets compiled or included has to be declared) and what is still
legitimately undeclared. `emitBindgen`/`emitBindgenV2` are exported for
the test.
- Why this is the right fix: the build's documented design for generated
headers is declared output + `restat` + order-only + depfile; these
headers were the ones outside it, and declaring them makes them follow
that design. Both scripts write with `writeIfNotChanged`, so `restat`
still prunes: a rerun that leaves the headers unchanged recompiles
nothing (checked below). No compile command changes, so existing trees
do not recompile anything; each of the two gen steps runs once more
(their new outputs have no build-log entry yet) and is pruned from
there. If a future `.bind.ts` were named so that its header collided
with another step's output, configure would now fail with ninja.ts's
duplicate-output error instead of two steps silently writing the same
file.
- Verified:
- `test/internal/build-codegen-declared-outputs.test.ts`:
`emitBindgenV2` run on a probe `.bindv2.ts` (one union, one enumeration)
declares exactly `{GeneratedProbeEnum.cpp, GeneratedProbeEnum.h,
GeneratedProbeUnion.h}` on one edge with the headers in `cppHeaders`;
`list-outputs` on the probe names exactly what `generate` writes;
`emitBindgen` declares `GeneratedNodeOs.h`/`GeneratedBunObject.h` next
to `GeneratedBindings.cpp`; and for the repo's real `.bind.ts` files its
declared set equals exactly the files `bindgen.ts` writes into a scratch
dir. With `src/` stashed the two bindgenv2 tests fail (2 headers
missing); with `scripts/` stashed configure fails on the old `.cpp`-only
assertion; removing either `cppHeaders.push` fails the corresponding
emission test.
- Probe above on the unfixed tree: gen only, cxx deferred to the next
run. Fixed tree: same edit runs gen + cxx in one invocation, `ninja -n`
is then empty, reverting the edit does the same, and a `touch` of the
`.bind.ts` runs gen alone (restat prunes the cxx). Transcript below.
- After the change `ninja -t query` shows a producing edge for every
`codegen/Generated*.h`; what remains undeclared in `build/debug/codegen`
is `.d.ts` files, `JSSink.lut.txt`, `bake_empty_file`, the `eval/` dir,
the configure-time writes, and `BunBuiltinNames+extras.h` (the PCH case
#37992 handles; the two changes are independent).
- `build.ninja` before/after differs only in the two gen edges, the
`codegen` phony and the order-only lists that carry `cppAll`;
`test/internal/build-post-link-ordering.test.ts` and
`build-debug-info-flags.test.ts` still pass; `tsc -p scripts/build`
reports the same pre-existing diagnostics as main.

### Background
- **Order-only input** (`||` in ninja): has to exist before the edge
runs, but its mtime never dirties the edge. The build uses it for the
codegen headers so that a compile is only rebuilt for the headers it
really includes.
- **Depfile** (`deps = gcc`): the list of files clang read while
compiling a TU; ninja stores it and treats each entry as an implicit
input of that compile on later runs. Whether an entry can re-dirty the
compile within the run that rewrites it depends on whether some edge
declares the entry as an output: declared outputs are re-checked after
their edge runs, anything else is stat'd once at startup.
- **restat**: after an edge runs, ninja re-stats its outputs and drops
downstream work for outputs whose mtime did not move;
`writeIfNotChanged` in the codegen scripts is what keeps the mtimes
still. This is why declaring more outputs does not cause extra
recompiles.
- **bindgen** (`src/codegen/bindgen.ts`, `*.bind.ts`): generates the C++
side of native functions; one `GeneratedBindings.cpp` plus a
`Generated<Name>.h` per file holding the function pointers and struct
definitions the hand-written C++ uses. **bindgenv2**
(`src/codegen/bindgenv2/`, `*.bindv2.ts`): generates a conversion header
per named type, plus a `.cpp` for dictionaries and enumerations; unions
are header-only. Its output set depends on what the files export, so
configure asks the script with `--command=list-outputs`.

<details>
<summary>Probe transcript (linux-x64, warm build/debug; the edit appends
an exported <code>fn</code> to BunObject.bind.ts, which adds a
declaration to GeneratedBunObject.h)</summary>

Unfixed:

```
$ bun scripts/build.ts --profile=debug --target=obj/src/jsc/bindings/BunObject.cpp.o   # baseline
ninja: no work to do.
$ printf '\nexport const undeclaredHeaderProbe = fn({ args: {}, ret: t.u64 });\n' >> src/runtime/api/BunObject.bind.ts
$ bun scripts/build.ts --profile=debug --target=obj/src/jsc/bindings/BunObject.cpp.o
[1/1] gen .bind.ts -> GeneratedBindings.cpp
$ grep -c jsUndeclaredHeaderProbe build/debug/codegen/GeneratedBunObject.h
2                                                   <- header rewritten...
$ ninja -C build/debug -n obj/src/jsc/bindings/BunObject.cpp.o
[1/1] cxx obj/src/jsc/bindings/BunObject.cpp.o      <- ...but its includer only recompiles next time
$ ninja -C build/debug -t query codegen/GeneratedBunObject.h
codegen/GeneratedBunObject.h:
  outputs:                                          <- no "input:" edge produces it
```

Fixed (tree converged first, `ninja: no work to do`):

```
$ printf '...' >> src/runtime/api/BunObject.bind.ts                                     # same edit
$ bun scripts/build.ts --profile=debug --target=obj/src/jsc/bindings/BunObject.cpp.o
[1/2] gen .bind.ts -> GeneratedBindings.cpp
[2/2] cxx obj/src/jsc/bindings/BunObject.cpp.o
$ ninja -C build/debug -n obj/src/jsc/bindings/BunObject.cpp.o
ninja: no work to do.
$ git checkout src/runtime/api/BunObject.bind.ts
$ bun scripts/build.ts --profile=debug --target=obj/src/jsc/bindings/BunObject.cpp.o
[1/2] gen .bind.ts -> GeneratedBindings.cpp
[2/2] cxx obj/src/jsc/bindings/BunObject.cpp.o
$ touch src/runtime/api/BunObject.bind.ts
$ bun scripts/build.ts --profile=debug --target=obj/src/jsc/bindings/BunObject.cpp.o
[1/2] gen .bind.ts -> GeneratedBindings.cpp         <- headers unchanged, cxx pruned by restat
```

The two edges as now written to build.ninja:

```
build codegen/GeneratedBindings.cpp codegen/GeneratedBindgenTest.h codegen/GeneratedFmtJsc.h
    codegen/GeneratedNodeModuleModule.h codegen/GeneratedBunObject.h codegen/GeneratedBake.h
    codegen/GeneratedDevServer.h codegen/GeneratedNodeOs.h: codegen ../../src/codegen/bindgen.ts ...

build codegen/GeneratedSocketConfigBinaryType.h codegen/GeneratedSocketConfigBinaryType.cpp
    codegen/GeneratedSocketConfigHandlers.h codegen/GeneratedSocketConfigHandlers.cpp codegen/GeneratedSocketConfig.h
    codegen/GeneratedSocketConfig.cpp codegen/GeneratedSocketConfigTLS.h codegen/GeneratedALPNProtocols.h
    codegen/GeneratedSSLConfig.h codegen/GeneratedSSLConfig.cpp codegen/GeneratedSSLConfigFile.h
    codegen/GeneratedSSLConfigSingleFile.h codegen/GeneratedFakeTimersConfig.h codegen/GeneratedFakeTimersConfig.cpp: codegen ...
```

</details>
robobun and others added 3 commits August 13, 2026 07:58
…ts in them

Codegen headers are order-only inputs of the PCH, cxx and cc edges; the
compiler's depfile is what re-dirties a compile when one of them changes.
Ninja matches depfile entries to declared outputs by string, and the
compiler records a header as "<-I dir>/<name>", so with
-I/abs/build/codegen the PCH depended on /abs/build/codegen/X.h, a node
no edge produces, which ninja stats once at startup. A codegen rerun in
the same run therefore never reached the PCH: the first bun bd after
adding a src/js module compiled every TU against a PCH built from the old
InternalModuleRegistry+enum.h and failed, and the second bun bd rebuilt
the PCH. The pch rule is the one compile rule that does not go through
ccache, whose CCACHE_BASEDIR rewriting had been relativizing the flags of
every other rule; a plain ninja invocation or a host without ccache had
the same one-build lag on every TU.

includeFlags() now spells include dirs inside buildDir buildDir-relative
(-Icodegen, -Ideps/zlib, -I.), matching ninja.ts, and bunCompileFlags()
assembles bun's compile flags through it. Directories outside buildDir
stay absolute so ccache entries remain shareable between worktrees.

BunBuiltinNames+extras.h, which root-pch.h reaches through
BunBuiltinNames.h, is written by bundle-functions.ts but was not a
declared output of the JS modules step, so it is declared there; the
depfile can only follow a declared output.
…utputs implicit

The direct deps that include another dep's generated headers (libarchive,
libspng and lsquic all include zlib's zlib.h/zconf.h out of deps/zlib/) had
the same shape as the PCH: an absolute -I into the build dir and the
headers reachable only as order-only inputs, so a zlib rebuild reached
their objects one build late whenever ccache was not rewriting the flags.

emitDirect now spells DirectBuild.includes through includeFlags() (libspng
moves its hand-written -I from cflags into includes) and takes the
fetchDeps producers' outputs as implicit inputs of every object, the way
emitNestedCmake and bun.ts's depHeaderSignal already do; only the dep's
own source stamp stays order-only. cc()/cxx()/pch() reject an absolute
-I into the build dir at configure time, so the spelling is enforced where
the depfile is consumed instead of by convention; the full graph passes.

The codegen.ts header comment is merged with the one from the bindgen
headers change, which landed in the meantime.
@robobun
robobun force-pushed the farm/f8e9f635/pch-codegen-depfile-spelling branch from d81933d to 6745ed8 Compare August 13, 2026 08:40

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nit, I checked the other DirectBuild deps for raw -I<buildDir>/... in cflags that would trip the new assertIncludesSpelledAsDeclared — libspng was the only one (fixed here); boringssl's hand-written -I points at depSourceDir (vendor/), not the build dir. lsquic already routes depBuildDir(cfg, "zlib") through includes:, so it picks up the relative spelling via includeFlags() without changes.

Extended reasoning...

Verified the new configure-time assertion in compile()/pch() won't break any existing dep beyond the libspng case this PR already migrates: grepped scripts/build/deps/ for -I${...} in cflags and for depBuildDir — boringssl's raw -I targets depSourceDir (outside buildDir, passes the prefix filter), and lsquic/libarchive already list their zlib build dir under includes: where emitDirect now runs it through includeFlags(). Also confirmed the nasm-branch nit is latent: the two .asm-using deps (boringssl, libjpeg-turbo) declare no fetchDeps.

Comment thread scripts/build/source.ts
…ir .c/.cpp objects

Splitting fetchDepStamps out of emitDirect's order-only list had left the
nasm arm with no dependency on the cross deps at all. nasm() now takes
implicitInputs like cc()/cxx(), emitDirect passes the same list to all
three, and the emitDirect test compiles one source of each kind.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the .asm branch concern from my earlier pass is addressed in 900fb0a (nasm() now takes implicitInputs and emitDirect passes implicit to it, with the emitDirect test covering all three arms).

What was reviewed:

  • includeFlags() / buildDirSpelling() correctly relativize only paths inside buildDir (. for buildDir itself, ..-prefix and cross-drive absolutes stay untouched); n.rel() semantics in ninja.ts confirmed.
  • assertIncludesSpelledAsDeclared() — the cheap startsWith(n.buildDir) prefix filter can over-match (/foo/bar vs /foo/barbaz), but the follow-up buildDirSpelling() call returns undefined for those, so no false positive; quoted-flag unwrapping matches what emitDirect emits.
  • emitDirect: fetchDepStamps moved from order-only to implicit now applies to cc/cxx/nasm alike; genInc no longer needs q() since the relativized deps/<name> path is space-free.
  • emitJsModules: BunBuiltinNames+extras.h lands in cppHeaders via the .h filter, so it flows into cppAll and the PCH's order-only set.
Extended reasoning...

Overview

This PR fixes a one-build-lag in ninja's depfile tracking of generated headers: the PCH (and, without ccache, every C/C++ compile) recorded codegen headers under absolute paths in its depfile, while the codegen edges declare them buildDir-relative — ninja matches by string, so a codegen rerun mid-run went unnoticed until the next build. The fix introduces includeFlags() in compile.ts to spell build-dir -I directories the same way ninja.ts spells outputs, threads it through bunCompileFlags() (bun.ts) and emitDirect (source.ts), enforces it with a configure-time assertion in cc()/cxx()/pch(), declares the previously-undeclared BunBuiltinNames+extras.h output in emitJsModules, and promotes direct deps' fetchDepStamps from order-only to implicit inputs (matching emitNestedCmake). libspng's hand-written -I moves from cflags to includes so it goes through the same path. Eight files touched, all under scripts/build/ plus docs and a new test file.

Security risks

None. This is build-graph emission logic only — no runtime code, no user-facing surface, no parsing of untrusted input. The one assert() added is a configure-time developer guard.

Level of scrutiny

Medium. It's build-system-only, so the blast radius is bounded to build correctness (worst case: a build fails or over-rebuilds, not a shipped runtime bug). But it changes the -I spelling on every compile edge (~1300 edges), which forces a one-time full recompile on existing trees, and it changes dependency semantics for direct deps' cross-dep inputs. The PR description is unusually thorough — root-causes the ninja string-matching behavior, shows ninja -t deps/-t query transcripts before/after, verifies restat pruning still works, and notes CI built on every platform including clang-cl.

Other factors

  • My earlier inline comment on the .asm arm losing its fetchDepStamps dependency was addressed by commit 900fb0a: nasm() now accepts implicitInputs (typed via Pick<CompileOpts, ...>), emitDirect passes implicit to it, and the new emitDirect test asserts implicit inputs on cc/cxx/nasm edges alike.
  • I checked buildDirSpelling() against Ninja.rel() in ninja.ts: rel(buildDir) → "" → ., paths outside buildDir → ..-prefixed or absolute (Windows cross-drive) → undefined, and the ..odd edge case in the test is handled correctly by the === ".." / startsWith(".." + sep) guards.
  • The configure-time assertion's cheap prefix filter (dir.startsWith(n.buildDir)) can over-match a sibling path with a shared prefix, but the subsequent buildDirSpelling() call is the exact check and returns undefined for those — so the assert only fires on genuine build-dir absolutes.
  • The test file exercises each part in isolation (includeFlags, bunCompileFlags, the compile-constructor assertion including the quoted form, emitDirect with a synthetic producer/consumer through resolveDep, emitJsModules) with no compiler/ninja/subprocess, and each is designed to fail when only its part is reverted.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant