Skip to content

Parcel serve crashes sometimes when dealing with rapidly churning compiler generated js output #10322

Description

@drewwestphal

🐛 bug report

I am working with parcel on a rescript project in a big ol' monorepo. We've had intermittent parcel build failures since time immemorial. The fix we've used for years is just to restart parcel and nuke the cache. This works, it slows things down and sometimes it takes you a minute to notice. My assumption has been that that this has to do with race conditions watching the massive churn of generated js files from the rescript compiler.

The below is from a session with claude code where it isolated the issue, first in my repo and then again a test in repo with the parcel testing toolchain installed. Anyway, first time pull-requester long-time-user and thanks to the maintainers for all the work they do. Below from the maw of the machine:

Watcher race: an external process bulk-writing files mid-build crashes parcel serve with Got unexpected null at nullthrows(bundle.name) after BundleGraphRequest aborts.

When parcel serve (or any watch-mode build) is bundling and the watcher delivers an FS event before the bundler finishes naming bundles, the in-flight BundleGraphRequest aborts inside its try block, the catch path (added in #9366 for failure-replay diagnostics) caches the partially-constructed BundleGraph, and every subsequent build that hits getPreviousResult() reuses that partial graph — skipping nameBundle, applyRuntimes, and validateBundles. Downstream packaging then explodes the first time anything reads bundle.name.

Triggering scenario: a separate process (for us a ReScript compiler, but any external compiler that emits a burst of files into a watched directory has the same effect) writes many .js files in quick succession while Parcel is building.

🎛 Configuration (.babelrc, package.json, cli command)

No .babelrc, no .parcelrc. Minimal package.json for the synthetic repro:

{
  "name": "parcel-race-repro",
  "private": true,
  "dependencies": { "parcel": "^2.16.4" }
}

CLI:

parcel serve --no-hmr --no-source-maps --port 9991 \
  --log-level verbose --cache-dir .parcel-cache ./index.html

🤔 Expected Behavior

While parcel serve is mid-build, an external process writes a burst of .js files into a watched directory. Either:

  1. The current build completes and the next one picks up the changes, OR
  2. The current build is aborted (legitimate BuildAbortError) and the next build runs from scratch and succeeds.

Subsequent builds should not crash with nullthrows-class errors that have nothing to do with the user's source.

😯 Current Behavior

Subsequent builds crash repeatedly with Got unexpected null at bundle.name reads:

🚨 Build failed.

Error: Got unexpected null
    at nullthrows /node_modules/@parcel/core/lib/PackagerRunner.js:152
       (same surface also fires at PackagerRunner.js:634, :738; BundleGraphRequest.js:480)

Older 2.16.1 also surfaced Worker send back a reference to a missing dev dep request and Expected content key <16-hex> to exist; on 2.16.4 the bundle.name=null surface is the dominant one. Mitigation matrix from a 45-second storm (100 files / 50 ms, truncate mode, Parcel 2.16.4):

Configuration Build failures Got unexpected null
Baseline (FSEvents, default workers) 94 176
--watch-backend watchman similar (236 on 2.16.1)
PARCEL_WORKERS=0 (single-process) 112 (2.16.1) / similar
--watch-ignore <dir> 83 (2.16.1)

Critical: PARCEL_WORKERS=0 only halves the failure count and does not eliminate it. That rules out the worker-IPC race chased by #9532 / #9636. The remaining failures are a main-process async-reentrancy bug: a watcher callback runs on a microtask between the build loop's await points and mutates state the loop is iterating.

💁 Possible Solution

Root cause (verified locally with code instrumentation)

In packages/core/core/src/requests/BundleGraphRequest.js:

  1. BundleGraphRequest.run constructs internalBundleGraph via InternalBundleGraph.fromAssetGraph(...), then await bundler.bundle(...). Bundles exist but are unnamed.
  2. If an abort signal fires during the surrounding await runDevDepRequest(...) (or any nested await that bottoms out in assertSignalNotAborted(this.signal)), BuildAbortError propagates up.
  3. The catch (e) block (added by #9366 for failure-replay diagnostics) caches the partial graph via this.api.storeResult({bundleGraph: internalBundleGraph, ...}, this.cacheKey) and re-throws.
  4. The next build re-enters BundleGraphRequest.run. previousBundleGraphResult = await this.api.getPreviousResult() returns the partial graph stored in step 3. Since previousBundleGraphResult != null, the code reuses it and skips the entire if (!previousBundleGraphResult) { nameBundle … applyRuntimes … validateBundles … } block.
  5. WriteBundlesRequest fans out PackageRequests. PackagerRunner.loadConfig runs let name = nullthrows(bundle.name) → 💥.

Confirmation logs from a 45-second run with verifying instrumentation:

[BGR:catch:storePartial] {bundles:2, namedBundles:0, unnamedBundles:2, cacheKey:"70dba4b52c152bd4-BundleGraph"}
[BGR:reuse:UNNAMED]    {bundles:2, unnamed:2, cacheKey:"70dba4b52c152bd4-BundleGraph"}  ← 114 occurrences
[bundleNameNull]       {bundleId:"89d10fc6ef6ce18a", bundleType:"html", nameValue:"null"}  ← every subsequent build

One catch-path storage spawns dozens of follow-on builds that all reuse the unnamed graph until either the cache key changes or the storm subsides.

Proposed fix

Smallest viable change (~19 lines including comments): tag the cached-on-error result with incomplete: true and reject it in the reuse check. Preserves #9366's diagnostic intent (the partial graph is still cached for failure analysis) while preventing accidental reuse as an incremental baseline.

--- a/packages/core/core/src/requests/BundleGraphRequest.js
+++ b/packages/core/core/src/requests/BundleGraphRequest.js
 type BundleGraphRequestResult = {|
   bundleGraph: InternalBundleGraph,
+  incomplete?: boolean,
 |};

 export type BundleGraphResult = {|
   bundleGraph: InternalBundleGraph,
   changedAssets: Map<string, Asset>,
   assetRequests: Array<AssetGroup>,
+  incomplete?: boolean,
 |};
 …
     if (graph.safeToIncrementallyBundle) {
       try {
         previousBundleGraphResult = await this.api.getPreviousResult();
       } catch { /* … */ }
     }
+    if (previousBundleGraphResult?.incomplete) {
+      previousBundleGraphResult = null;
+    }
     if (previousBundleGraphResult == null) {
       graph.safeToIncrementallyBundle = false;
     }
 …
     } catch (e) {
       if (internalBundleGraph != null) {
         this.api.storeResult(
           {
             bundleGraph: internalBundleGraph,
             changedAssets: new Map(),
             assetRequests: [],
+            incomplete: true,
           },
           this.cacheKey,
         );
       }
       throw new ThrowableDiagnostic({ /* … */ });

On the synthetic repro this drops Got unexpected null from 176 to 0 and total build failures from 94 to 7 — the residual 7 are the pre-existing BuildAbortError from the abort signal itself, not corrupted-graph crashes.

I have a branch staged with this change plus a deterministic regression test under packages/core/integration-tests/test/incremental-bundling.js (uses a sinon.stub on the default bundler to drive the same catch-path, so it doesn't depend on watcher timing in CI). The test fails without the fix (expected the recovered build to succeed) and passes with it. Happy to open the PR once this issue # is assigned.

🔦 Context

This bites real-world ReScript stacks because the ReScript compiler emits a burst of .bs.js files into the watched output directory whenever anything in the project recompiles. Every recompile triggers the race; the race fails 50–80% of the time (per the matrix above); each failure corrupts the in-memory BundleGraph cache for the next ~100+ builds until the cache key churns past it. Result: parcel serve becomes effectively unusable when ReScript is rebuilding.

The same pattern fires for any external code generator that writes to a watched directory in bursts: protoc, sqlc, GraphQL codegen, Tailwind CLI, etc. The synthetic repro below uses plain shell to remove ReScript from the picture.

Related issues / PRs:

💻 Code Sample

Self-contained, no plugins, no ReScript. Paste this single block into a terminal — it scaffolds a sandbox in $TMPDIR, runs parcel serve via npx (no install in your working tree), fires the storm for 15 s, prints the failure counts, and cleans up after itself. ~30 s end-to-end on a recent Mac.

bash <<'REPRO'
set -uo pipefail
DUR="${DUR:-30}"
DIR="$(mktemp -d -t parcel-race-XXXXXX)"
PARCEL_PID=""
cleanup() {
  [ -n "$PARCEL_PID" ] && kill -INT "$PARCEL_PID" 2>/dev/null
  [ -n "$PARCEL_PID" ] && wait "$PARCEL_PID" 2>/dev/null
  cd /
  rm -rf "$DIR"
}
trap cleanup EXIT
cd "$DIR"

mkdir -p gen
echo '{ "name": "parcel-race", "private": true }' > package.json
echo '<!doctype html><script type="module" src="./entry.js"></script>' > index.html
: > entry.js
for i in $(seq 0 99); do
  printf 'export const v%d = %d;\n' "$i" "$i" > "gen/mod${i}.js"
  printf 'import { v%d } from "./gen/mod%d.js";\n' "$i" "$i" >> entry.js
done
echo 'console.log(v0);' >> entry.js

echo "[parcel-race-repro] $DIR — starting parcel serve via npx..."
PORT="${PORT:-9991}"
npx -y parcel@2.16.4 serve --no-hmr --no-source-maps --port "$PORT" \
  --log-level verbose --cache-dir .parcel-cache index.html > parcel.log 2>&1 &
PARCEL_PID=$!

# Wait for the first successful build, then start the storm. Bail if parcel exits early.
for _ in $(seq 1 120); do
  grep -q "Built in" parcel.log 2>/dev/null && break
  kill -0 "$PARCEL_PID" 2>/dev/null || { echo "✗ parcel exited before first build:"; tail -10 parcel.log; exit 1; }
  sleep 0.5
done
grep -q "Built in" parcel.log 2>/dev/null || { echo "✗ first build did not land within 60s"; tail -10 parcel.log; exit 1; }
echo "[parcel-race-repro] first build landed; storming for ${DUR}s..."

END=$(( $(date +%s) + DUR ))
while [ "$(date +%s)" -lt "$END" ]; do
  for i in $(seq 0 99); do
    printf 'export const v%d = %d; // %d\n' "$i" "$i" "$RANDOM" > "gen/mod${i}.js"
  done
  sleep 0.05
done
sleep 1

NULL_HITS=$(grep -cE 'Got unexpected null' parcel.log)
BUILD_FAIL=$(grep -cE '🚨 Build failed' parcel.log)
LOG_COPY="${TMPDIR:-/tmp}/parcel-race-repro-$$.log"
cp parcel.log "$LOG_COPY"

echo ""
echo "=== parcel-race-repro results (${DUR}s storm) ==="
echo "  Got unexpected null  : $NULL_HITS"
echo "  Build failed         : $BUILD_FAIL"
echo "  Full log preserved   : $LOG_COPY"
if [ "$NULL_HITS" -gt 0 ]; then
  echo "✗ Reproduced — saw $NULL_HITS null-deref crashes from a cached partial bundle graph."
else
  echo "✓ No null-deref crashes observed."
fi
REPRO

Expected on a recent Mac with the default 30 s storm: tens of Build failed and dozens-to-hundreds of Got unexpected null lines (45 s scales linearly to the matrix figures above; we observed 40 / 23 in a 15 s run and 94 / 176 in a 45 s run). The race is timing-sensitive — if the first attempt shows zero crashes, re-run with DUR=60 bash <<'REPRO' … REPRO. The bug fires within ~1-2 s of the storm starting once parcel is actively bundling.

🌍 Your Environment

Software Version(s)
Parcel 2.16.4 (also reproduced on 2.16.1)
Node v22.22.3 (also v20)
npm/Yarn npm 10.x; underlying project uses Yarn 3.6.3
Operating System macOS 15 (Darwin 25.4, arm64)

Reproduced with parcel serve (watch mode). Watch backend: FSEvents (default); reproduces under --watch-backend watchman as well.


LLM Disclaimer Issue body drafted with AI assistance (Claude). I know there are many slop PRs out there. This is a real bug I've encountered and this fix seems to address it. While I have my reservations about LLM technology and its uses, this was something that would have taken me long enough to get moving on my local machine that I would have simply continued to suffer the problem (or submitted a much vaguer bug report).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions