Visitar URL original
Turbopack: terminate failed plugin worker threads by marcoshernanz · Pull Request #96592 · vercel/next.js · GitHub
Skip to content

Turbopack: terminate failed plugin worker threads - #96592

Merged
marcoshernanz merged 1 commit into
canaryfrom
codex/turbopack-terminate-failed-worker-threads
Aug 4, 2026
Merged

marcoshernanz merged 1 commit into
canaryfrom
codex/turbopack-terminate-failed-worker-threads

Conversation

@marcoshernanz

@marcoshernanz marcoshernanz commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

What

Terminate a worker-thread plugin runtime when an evaluation or IPC error marks
its WorkerOperation non-reusable.

disallow_reuse() already removed the worker from pool statistics and disabled
the callback that returns it to the pool, but it did not stop the underlying
Node.js Worker. The worker then stayed strongly owned by loaderWorkers, kept
its V8 isolate/module graph alive, and waited forever for another task that
could never arrive.

This calls the existing worker terminator used by wait_or_kill, scale-down,
and scale-to-zero. It removes the routed native channel and dispatches the
existing JavaScript Worker.terminate() callback, which also deletes the map
entry.

Why this matters for v0

v0's planned worker-thread runtime turns transient loader/PostCSS errors during
agent edits into a deterministic lifecycle leak. On the real v0 /button
route, each unique Tailwind/PostCSS failure retained one complete worker runtime
on the baseline.

The balanced eight-process A/B panel (80 unique failures per lane) measured:

Lane WorkerThread slope Private-memory slope Median failure Median recovery >5 s failures
baseline +1.000/error +49.81 MiB/error 108.04 ms 329.26 ms 3 / 80
candidate 0.000/error +5.12 MiB/error 108.15 ms 317.65 ms 2 / 80

That is a 100% elimination of leaked-worker growth and an 89.7% reduction
in retained private-memory slope
(exact process-bootstrap 95% interval:
85.7%–99.5%). Every candidate process was below every baseline process; exact
4-vs-4 process-label permutation p=0.0143 one-sided / 0.0286 two-sided.
Median failure latency changed by only +0.11 ms.

The independent 100-cycle-per-lane endurance confirmation showed the same
causal result:

Lane WorkerThread cycle 1→100 Private memory cycle 1→100 Private slope Median failure Median recovery
baseline 38→137 4.28→7.34 GiB +27.02 MiB/error 104.45 ms 331.32 ms
candidate 32→32 3.93→4.10 GiB +0.98 MiB/error 102.94 ms 297.24 ms

That long run eliminated all observed worker growth and reduced the fitted
private-memory slope 96.4%. Every one of the 200 unique failures recovered to
the complete real route, and the source hash was restored after every cycle.

The benchmark used fresh .next state per process, five error/recovery warmups,
a five-minute quiescence window, unique uncached errors, exact source restoration
after every cycle, and within-process slopes. Baseline and candidate native
binaries were built from the same Next.js base, with only this lifecycle change
affecting candidate native code, and selected by SHA-verified
NEXT_TEST_NATIVE_DIR paths.

This is independent of #96433. That PR coordinates explicit multi-file edit
transactions; this PR fixes the lifecycle of a worker that has already failed.
It also does not change idle-worker scale-down policy: failed workers are absent
from the idle pool, so scale-down cannot see them.

Regression test

The new development test schedules a one-second filesystem marker inside a
custom loader, then throws an evaluation error under
turbopackPluginRuntimeStrategy: 'workerThreads':

  • baseline native binary: fails because the orphan worker remains alive and
    writes the marker;
  • candidate native binary: passes because the worker is actually terminated;
  • candidate then renders a recovered value, proving replacement/recovery works.

Validation

  • pnpm build-all
  • cargo fmt --all -- --check
  • cargo check -p turbopack-node --all-targets
  • cargo test -p turbopack-node --lib
  • cargo clippy -p turbopack-node --all-targets -- -D warnings
  • focused Prettier and ESLint checks for the new test
  • candidate integration test pass; exact baseline integration test fails at the
    intended liveness assertion
  • GPT-5.6 Sol xhigh + Claude Opus 5 xhigh autoreview panel: zero actionable
    findings, patch correct at 0.96 confidence

Exact optimized native SHA-256 values used by the benchmark:

  • baseline: 609eeeed41e1425f06136b54b7997e5b1b1add992169b17a797889dfb830dd7f
  • candidate: 141211112ae47b8bd814aaab437060114e92f60a1bf6f23e1b0e153b1e4837cc

Both stripped binaries were 205,863,784 bytes. Raw per-process JSON, /proc
samples, server logs, harness source, analysis output, and A/B plus endurance
graphs are retained in the investigation archive.

Co-Authored-By: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com>
@github-actions

github-actions Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Stats from current PR

✅ No significant changes detected

📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change Trend
Cold (Listen) 813ms 812ms ✓ ▂▂▂▂▂
Cold (Ready in log) 785ms 783ms ✓ ▁▁▁▁▁
Cold (First Request) 1.320s 1.285s ✓ ▃▁▁▁▁
Warm (Listen) 813ms 812ms ✓ ▂▂▂▂▁
Warm (Ready in log) 782ms 781ms ✓ ▂▁▁▁▁
Warm (First Request) 1.293s 1.274s ✓ ▁████
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 1.066s 1.064s ✓ ████▁
Cold (Ready in log) 1.028s 1.024s ✓ ▄▄▄▇▂
Cold (First Request) 4.657s 4.653s ✓ ▄▅▆█▅
Warm (Listen) 1.063s 1.013s ✓ █████
Warm (Ready in log) 1.027s 1.021s ✓ ▄▅▅█▄
Warm (First Request) 4.645s 4.658s ✓ ▃▄▅█▅

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 5.768s 5.723s ✓ ▂▃▄▃▃
Cached Build 3.047s 2.907s ✓ ▅▁▁▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 35.198s 35.468s ✓ ▅▂▄█▄
Cached Build 35.746s 35.439s ✓ ▃▅▄█▂
node_modules Size 552 MB 552 MB ✓ ▁████
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles
Canary PR Change
05-qx3ncawea3.js gzip 155 B N/A -
09z5quq-m03tm.js gzip 157 B N/A -
0a-p0meqxynjz.js gzip 10.3 kB N/A -
0avtke4yefj4-.js gzip 13.1 kB N/A -
0cz1d0mv5g_q7.js gzip 39.4 kB 39.4 kB ✓
0nqpqym0y3lk1.js gzip 7.44 kB N/A -
0pt5of3ch6d2u.js gzip 13.6 kB N/A -
0xnsdo-6y9r3k.js gzip 5.72 kB N/A -
0yi7is8u4covm.js gzip 8.77 kB N/A -
13h1g5-eggare.js gzip 155 B N/A -
17a_13xnvg_g4.js gzip 157 B N/A -
1elt1qium-r2m.css gzip 115 B 115 B ✓
1prv-wg6jgtt2.js gzip 8.77 kB N/A -
1rahjhhwcy3ib.js gzip 154 B N/A -
1t47vod02e_4a.js gzip 8.75 kB N/A -
1tf1phijqlx9j.js gzip 220 B 220 B ✓
1tw002sq5bo4l.js gzip 10 kB N/A -
1ut16jsaacr9-.js gzip 155 B N/A -
1vivj8wonb1vb.js gzip 161 B N/A -
1wlerik1n7zbr.js gzip 8.7 kB N/A -
1yc1-hn1hjlh7.js gzip 156 B N/A -
26xeehk9dzita.js gzip 155 B N/A -
28ldracwhxjtk.js gzip 46.3 kB N/A -
2jpx0_6n0ykra.js gzip 3.66 kB N/A -
2o3jok2ajlv1u.js gzip 155 B N/A -
2pevdqjpbf3xk.js gzip 9.45 kB N/A -
2qd2anu9eizlz.js gzip 8.75 kB N/A -
2sv39ae5_8vfj.js gzip 8.81 kB N/A -
2utx6n7w65xqa.js gzip 8.7 kB N/A -
2wdwz921uie6s.js gzip 71.4 kB N/A -
3-83nse6yhwik.js gzip 153 B N/A -
3-ukivglqkfeq.js gzip 8.78 kB N/A -
325xisqrylugu.js gzip 450 B N/A -
3ane9tg0qzdom.js gzip 168 B N/A -
3jc9ghvds1spf.js gzip 2.29 kB N/A -
3jmunbr0y3i5y.js gzip 65.6 kB N/A -
3juwkvkgvywfe.js gzip 10.6 kB N/A -
3kbove7q58uqh.js gzip 13.2 kB N/A -
3mfjeceftos56.js gzip 159 B N/A -
3qn7vntp30gaj.js gzip 1.47 kB N/A -
turbopack-0_..4ck9.js gzip 3.84 kB N/A -
turbopack-0b..hv7r.js gzip 3.84 kB N/A -
turbopack-0g..rsq5.js gzip 3.84 kB N/A -
turbopack-0y.._k5w.js gzip 3.84 kB N/A -
turbopack-15..7wbk.js gzip 3.84 kB N/A -
turbopack-17..2g0n.js gzip 3.84 kB N/A -
turbopack-1a..3t_f.js gzip 3.84 kB N/A -
turbopack-1m..3npm.js gzip 3.84 kB N/A -
turbopack-1t..l_fg.js gzip 3.84 kB N/A -
turbopack-1v.._rqs.js gzip 3.82 kB N/A -
turbopack-26..n4hm.js gzip 3.84 kB N/A -
turbopack-26..12mt.js gzip 3.84 kB N/A -
turbopack-2m..wmkm.js gzip 3.86 kB N/A -
turbopack-35..jle5.js gzip 3.85 kB N/A -
0_sfs0nf236wo.js gzip N/A 3.65 kB -
030a1njeh5b77.js gzip N/A 10 kB -
07apy8ueq20wa.js gzip N/A 157 B -
08caocbfek4_i.js gzip N/A 156 B -
0g_asir4qqohq.js gzip N/A 65.6 kB -
0ot0qt5np20h7.js gzip N/A 8.78 kB -
0qyau1fucae-s.js gzip N/A 169 B -
0rfmyt_1koql4.js gzip N/A 156 B -
11psvbmaigaiu.js gzip N/A 156 B -
12m9rr7_7cexd.js gzip N/A 8.7 kB -
186r3y_rfvr0b.js gzip N/A 8.78 kB -
19v97848yrjp7.js gzip N/A 8.81 kB -
1i5vnnubggw5l.js gzip N/A 156 B -
1nd_0rwgz2ozk.js gzip N/A 9.45 kB -
1oi9pltr_e10m.js gzip N/A 10.6 kB -
1osqg_m2p3pwd.js gzip N/A 155 B -
1rd9tzqgzsz2w.js gzip N/A 8.77 kB -
1rq1g_cwv642d.js gzip N/A 10.3 kB -
1uojw_uabeg93.js gzip N/A 450 B -
1w-7lusgyl81y.js gzip N/A 8.7 kB -
1w55luor4fqhe.js gzip N/A 161 B -
2-kydmf6v8paa.js gzip N/A 158 B -
21h56wdn9zddo.js gzip N/A 8.75 kB -
240cm9uape91v.js gzip N/A 162 B -
25kc25t9ga8lo.js gzip N/A 46.3 kB -
2flz-xxf0_v5q.js gzip N/A 158 B -
2jp1fuvxwoiaz.js gzip N/A 7.45 kB -
2ovff1533zvno.js gzip N/A 13.2 kB -
2s4z8jy0z7v93.js gzip N/A 1.46 kB -
2uoqef9eainki.js gzip N/A 71.4 kB -
2y6gb5bs4mnam.js gzip N/A 8.75 kB -
2zzexcbnj74z4.js gzip N/A 154 B -
341h71ln1jpl3.js gzip N/A 2.29 kB -
34ttpt_n7605h.js gzip N/A 5.72 kB -
36ckfq9o-ys-a.js gzip N/A 13.1 kB -
3vdh6_u50pu5t.js gzip N/A 13.6 kB -
40-xebwwe3oqt.js gzip N/A 157 B -
turbopack-04..shn8.js gzip N/A 3.86 kB -
turbopack-0p..-3-7.js gzip N/A 3.84 kB -
turbopack-1_..i5ob.js gzip N/A 3.82 kB -
turbopack-18..i-28.js gzip N/A 3.85 kB -
turbopack-1h..p9c0.js gzip N/A 3.85 kB -
turbopack-20..9yry.js gzip N/A 3.84 kB -
turbopack-27..g1nh.js gzip N/A 3.84 kB -
turbopack-2m..vdl7.js gzip N/A 3.85 kB -
turbopack-2n..vwzk.js gzip N/A 3.84 kB -
turbopack-3-..ah57.js gzip N/A 3.84 kB -
turbopack-3k..3-qn.js gzip N/A 3.84 kB -
turbopack-3k..v9l_.js gzip N/A 3.84 kB -
turbopack-3p..abgz.js gzip N/A 3.85 kB -
turbopack-3r..vviz.js gzip N/A 3.84 kB -
Total 450 kB 450 kB ⚠️ +75 B

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 772 B 774 B ✓
Total 772 B 774 B ⚠️ +2 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 433 B 433 B ✓
Total 433 B 433 B ✓
Build Cache
Canary PR Change
00000001.sst gzip 13.3 MB 13 MB 🟢 348 kB (-3%)
00000002.sst gzip 13.9 MB 13 MB 🟢 931 kB (-7%)
00000003.sst gzip 14.3 MB 15.4 MB 🔴 +1.14 MB (+8%)
00000004.sst gzip 13.7 MB 13.9 MB 🔴 +165 kB (+1%)
00000005.sst gzip 10.3 MB 2.83 MB 🟢 7.5 MB (-73%)
00000006.sst gzip 2.83 MB 10.3 MB 🔴 +7.48 MB (+264%)
00000007.sst gzip 59 B 59 B ✓
00000008.meta gzip 89 B 89 B ✓
00000009.meta gzip 298 kB 298 kB ✓
00000010.meta gzip 298 kB 299 kB ✓
00000011.meta gzip 298 kB 298 kB ✓
00000012.sst gzip 51.7 kB 51.8 kB ✓
00000013.sst gzip 1.93 MB 1.9 MB 🟢 27.2 kB (-1%)
00000014.sst gzip 59 B 59 B ✓
00000015.meta gzip 116 B 116 B ✓
00000016.meta gzip 324 kB 324 kB ✓
00000017.meta gzip 405 kB 405 kB ✓
00000018.sst gzip 52.5 kB 52.6 kB ✓
00000019.sst gzip 1.42 MB 1.38 MB 🟢 32.9 kB (-2%)
00000020.sst gzip 59 B 59 B ✓
00000021.meta gzip 116 B 116 B ✓
00000022.meta gzip 324 kB 324 kB ✓
00000023.meta gzip 370 kB 370 kB ✓
00000024.sst gzip 52.5 kB 52.7 kB ✓
00000025.sst gzip 1.42 MB 1.38 MB 🟢 32.2 kB (-2%)
00000026.sst gzip 59 B 59 B ✓
00000027.meta gzip 116 B 116 B ✓
00000028.meta gzip 324 kB 324 kB ✓
00000029.meta gzip 370 kB 370 kB ✓
00000030.sst gzip 51.8 kB 51.9 kB ✓
00000031.sst gzip 1.42 MB 1.38 MB 🟢 32.4 kB (-2%)
00000032.sst gzip 59 B 59 B ✓
00000033.meta gzip 116 B 116 B ✓
00000034.meta gzip 324 kB 324 kB ✓
00000035.meta gzip 370 kB 370 kB ✓
00000036.sst gzip 52.4 kB 52.5 kB ✓
00000037.sst gzip 1.42 MB 1.38 MB 🟢 32.4 kB (-2%)
00000038.sst gzip 59 B 59 B ✓
00000039.meta gzip 116 B 116 B ✓
00000040.meta gzip 324 kB 324 kB ✓
00000041.meta gzip 370 kB 370 kB ✓
CURRENT gzip 24 B 24 B ✓
LOG gzip 660 B 676 B 🔴 +16 B (+2%)
Total 80.7 MB 80.5 MB ✅ -161 kB

📦 Webpack

Client

Main Bundles
Canary PR Change
3322-HASH.js gzip 65 kB N/A -
4191.HASH.js gzip 169 B N/A -
7920-HASH.js gzip 4.68 kB N/A -
9784-HASH.js gzip 5.63 kB N/A -
b1ad9f4c-HASH.js gzip 63.2 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB ✓
main-app-HASH.js gzip 254 B 253 B ✓
main-HASH.js gzip 40 kB 40 kB ✓
webpack-HASH.js gzip 1.68 kB 1.68 kB ✓
3577.HASH.js gzip N/A 168 B -
578-HASH.js gzip N/A 65.8 kB -
8590-HASH.js gzip N/A 5.61 kB -
9750-HASH.js gzip N/A 4.69 kB -
a8984546-HASH.js gzip N/A 63.2 kB -
Total 240 kB 241 kB ⚠️ +697 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB ✓
Total 39.4 kB 39.4 kB ✓
Pages
Canary PR Change
_app-HASH.js gzip 194 B 193 B ✓
_error-HASH.js gzip 181 B 182 B ✓
css-HASH.js gzip 334 B 331 B ✓
dynamic-HASH.js gzip 1.81 kB 1.81 kB ✓
edge-ssr-HASH.js gzip 255 B 253 B ✓
head-HASH.js gzip 349 B 351 B ✓
hooks-HASH.js gzip 382 B 384 B ✓
image-HASH.js gzip 581 B 582 B ✓
index-HASH.js gzip 260 B 259 B ✓
link-HASH.js gzip 2.48 kB 2.48 kB ✓
routerDirect..HASH.js gzip 317 B 318 B ✓
script-HASH.js gzip 384 B 386 B ✓
withRouter-HASH.js gzip 316 B 315 B ✓
1afbb74e6ecf..834.css gzip 106 B 106 B ✓
Total 7.95 kB 7.95 kB ⚠️ +1 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 129 kB 128 kB ✓
page.js gzip 293 kB 294 kB ✓
Total 421 kB 422 kB ⚠️ +307 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 619 B 616 B ✓
middleware-r..fest.js gzip 156 B 156 B ✓
middleware.js gzip 45.5 kB 45.2 kB ✓
edge-runtime..pack.js gzip 842 B 842 B ✓
Total 47.2 kB 46.9 kB ✅ -310 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 718 B 718 B ✓
Total 718 B 718 B ✓
Build Cache
Canary PR Change
0.pack gzip 4.87 MB 4.86 MB 🟢 6.1 kB (0%)
index.pack gzip 122 kB 123 kB ✓
index.pack.old gzip 123 kB 123 kB ✓
Total 5.11 MB 5.1 MB ✅ -6.59 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 373 kB 373 kB ✓
app-page-exp..prod.js gzip 206 kB 206 kB ✓
app-page-tur...dev.js gzip 373 kB 373 kB ✓
app-page-tur..prod.js gzip 206 kB 206 kB ✓
app-page-tur...dev.js gzip 369 kB 369 kB ✓
app-page-tur..prod.js gzip 203 kB 203 kB ✓
app-page.run...dev.js gzip 369 kB 369 kB ✓
app-page.run..prod.js gzip 204 kB 204 kB ✓
app-route-ex...dev.js gzip 83 kB 83 kB ✓
app-route-ex..prod.js gzip 56.1 kB 56.1 kB ✓
app-route-tu...dev.js gzip 83.1 kB 83.1 kB ✓
app-route-tu..prod.js gzip 56.2 kB 56.2 kB ✓
app-route-tu...dev.js gzip 82.6 kB 82.6 kB ✓
app-route-tu..prod.js gzip 55.9 kB 55.9 kB ✓
app-route.ru...dev.js gzip 82.6 kB 82.6 kB ✓
app-route.ru..prod.js gzip 55.9 kB 55.9 kB ✓
dev-validati...dev.js gzip 133 kB 133 kB ✓
dev-validati...dev.js gzip 133 kB 133 kB ✓
dev-validati...dev.js gzip 131 kB 131 kB ✓
dev-validati...dev.js gzip 131 kB 131 kB ✓
dist_client_...dev.js gzip 324 B 324 B ✓
dist_client_...dev.js gzip 326 B 326 B ✓
dist_client_...dev.js gzip 318 B 318 B ✓
dist_client_...dev.js gzip 317 B 317 B ✓
pages-api-tu...dev.js gzip 46 kB 46 kB ✓
pages-api-tu..prod.js gzip 34.3 kB 34.3 kB ✓
pages-api.ru...dev.js gzip 45.9 kB 45.9 kB ✓
pages-api.ru..prod.js gzip 34.3 kB 34.3 kB ✓
pages-turbo....dev.js gzip 54.7 kB 54.7 kB ✓
pages-turbo...prod.js gzip 39.8 kB 39.8 kB ✓
pages.runtim...dev.js gzip 54.7 kB 54.7 kB ✓
pages.runtim..prod.js gzip 39.8 kB 39.8 kB ✓
server.runti..prod.js gzip 66.9 kB 66.9 kB ✓
use-cache-pr...dev.js gzip 72.1 kB 72.1 kB ✓
use-cache-pr...dev.js gzip 72.1 kB 72.1 kB ✓
use-cache-pr...dev.js gzip 70.4 kB 70.4 kB ✓
use-cache-pr...dev.js gzip 70.4 kB 70.4 kB ✓
Total 4.09 MB 4.09 MB ✓
📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/3bed72989e973f4bc73196996f72c24e93542ead/next

Commit: 3bed729

@github-actions

github-actions Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 3bed729

@marcoshernanz
marcoshernanz marked this pull request as ready for review August 4, 2026 18:39
@marcoshernanz
marcoshernanz merged commit 39b7da2 into canary Aug 4, 2026
131 checks passed
@marcoshernanz
marcoshernanz deleted the codex/turbopack-terminate-failed-worker-threads branch August 4, 2026 20:29
marcoshernanz added a commit that referenced this pull request Aug 6, 2026
When a worker-thread plugin runtime dies unexpectedly (uncaught exception,
process.exit, OOM), nothing told the Rust worker pool about it: the dead
Worker stayed in loaderWorkers, the operation waiting for its response
hung forever, and every later evaluation could be handed the dead worker,
wedging the dev server's compilation permanently. This is the crash-time
sibling of #96592, which covered evaluation and IPC errors reported over
the channel.

JavaScript now attaches 'error'/'exit' listeners to pooled workers and
reports unexpected deaths through a new workerDied binding (intentional
terminations remove the worker from the map first, so they are never
reported; the thread id is captured at creation since it resets to -1 by
the time 'exit' fires). On the Rust side, handle_worker_death closes the
in-flight task's channel so its pending recv fails and surfaces through
the existing subprocess-crash path in pull_operation (synthesized issue +
disallow_reuse + fresh worker on the next evaluation), removes idle dead
workers from the pool, and fails the oldest pending creation when a worker
dies while booting before worker_created.

Verified end to end with turbopackPluginRuntimeStrategy: 'workerThreads':

- before: a loader calling process.exit(1) hangs the request forever and
  wedges all later compilations;
- after: the request fails in ~1s with "Node.js subprocess crashed while
  evaluating loaders", and the next request is served by a fresh worker in
  ~50ms. A worker that dies between tasks (idle) is reaped the same way.

The new development test covers the crash path: baseline (stock binary)
fails by hanging; candidate fails the evaluation and recovers. The #96592
error-cleanup test still passes.

Validation: pnpm build-all (TS + native), cargo fmt --check, cargo clippy
-p turbopack-node --all-targets -D warnings, cargo test -p turbopack-node
--lib, candidate integration test pass, exact baseline binary fails by
hanging.

Co-Authored-By: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com>
marcoshernanz added a commit that referenced this pull request Aug 6, 2026
When a worker-thread plugin runtime dies unexpectedly (uncaught exception,
process.exit, OOM), nothing told the Rust worker pool about it: the dead
Worker stayed in loaderWorkers, the operation waiting for its response
hung forever, and every later evaluation could be handed the dead worker,
wedging the dev server's compilation permanently. This is the crash-time
sibling of #96592, which covered evaluation and IPC errors reported over
the channel.

JavaScript now attaches 'error'/'exit' listeners to pooled workers and
reports any death while the worker is still registered (intentional
terminations remove the worker from the map first, so they are never
reported; the thread id is captured at creation since it resets to -1 by
the time 'exit' fires). On the Rust side, a dead-worker set records
reaped ids and handle_worker_death classifies the death:

- busy: closes the in-flight task's channel (via a watch channel, which
  cannot lose the wakeup), so its pending recv fails and surfaces through
  the existing subprocess-crash path in pull_operation - synthesized
  issue, disallow_reuse, fresh worker on the next evaluation;
- idle: removed from the idle list and pool statistics;
- created but unassigned (in transit to an acquirer or routed to a
  waiter): total worker count decremented;
- boot failure: the pending creation for the same pool is failed (so
  create_worker errors instead of waiting forever) and the booting
  statistics are balanced - no phantom workers after a boot crash.

Every acquisition and return path checks the dead set so a reaped id is
never handed out again: acquire_worker retries after dead candidates
(idle pop, waiter handoff, fresh boot), and WorkerOperation's drop skips
returning a worker that died while checked out.

Verified end to end with turbopackPluginRuntimeStrategy: 'workerThreads':

- before: a loader calling process.exit(1) hangs the request forever and
  wedges all later compilations;
- after: the request fails in ~1s with "Node.js subprocess crashed while
  evaluating loaders", and the next request is served by a fresh worker
  in ~50ms. A worker that dies between tasks (idle) is reaped the same
  way.

The new development test covers the crash path: baseline (stock binary)
fails by hanging; candidate fails the evaluation and recovers. The #96592
error-cleanup test still passes. Autoreview panel findings addressed:
watch-channel close, dead-set validation on all handoff paths,
options-tagged pending creations, clean-exit reaping.

Validation: pnpm build-all (TS + native), cargo fmt --check, cargo clippy
-p turbopack-node --all-targets -D warnings, cargo test -p turbopack-node
--lib, candidate integration test pass, exact baseline binary fails by
hanging.

Co-Authored-By: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com>
marcoshernanz added a commit that referenced this pull request Aug 6, 2026
When a worker-thread plugin runtime dies unexpectedly (uncaught exception,
process.exit, OOM), nothing told the Rust worker pool about it: the dead
Worker stayed in loaderWorkers, the operation waiting for its response
hung forever, and every later evaluation could be handed the dead worker,
wedging the dev server's compilation permanently. This is the crash-time
sibling of #96592, which covered evaluation and IPC errors reported over
the channel.

JavaScript now attaches 'error'/'exit' listeners to pooled workers and
reports any death while the worker is still registered (intentional
terminations remove the worker from the map first, so they are never
reported; the thread id is captured at creation since it resets to -1 by
the time 'exit' fires). On the Rust side, a dead-worker set records
reaped ids and handle_worker_death classifies the death:

- busy: closes the in-flight task's channel (via a watch channel, which
  cannot lose the wakeup), so its pending recv fails and surfaces through
  the existing subprocess-crash path in pull_operation - synthesized
  issue, disallow_reuse, fresh worker on the next evaluation;
- idle: removed from the idle list and pool statistics;
- created but unassigned (in transit to an acquirer or routed to a
  waiter): total worker count decremented;
- boot failure: the pending creation for the same pool is failed (so
  create_worker errors instead of waiting forever) and the booting
  statistics are balanced - no phantom workers after a boot crash.

Every acquisition and return path checks the dead set so a reaped id is
never handed out again: acquire_worker retries after dead candidates
(idle pop, waiter handoff, fresh boot), and WorkerOperation's drop skips
returning a worker that died while checked out.

Verified end to end with turbopackPluginRuntimeStrategy: 'workerThreads':

- before: a loader calling process.exit(1) hangs the request forever and
  wedges all later compilations;
- after: the request fails in ~1s with "Node.js subprocess crashed while
  evaluating loaders", and the next request is served by a fresh worker
  in ~50ms. A worker that dies between tasks (idle) is reaped the same
  way.

The new development test covers the crash path: baseline (stock binary)
fails by hanging; candidate fails the evaluation and recovers. The #96592
error-cleanup test still passes. Autoreview panel findings addressed:
watch-channel close, dead-set validation on all handoff paths,
options-tagged pending creations, clean-exit reaping.

Validation: pnpm build-all (TS + native), cargo fmt --check, cargo clippy
-p turbopack-node --all-targets -D warnings, cargo test -p turbopack-node
--lib, candidate integration test pass, exact baseline binary fails by
hanging.

Co-Authored-By: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com>
marcoshernanz added a commit that referenced this pull request Aug 6, 2026
When a worker-thread plugin runtime dies unexpectedly (uncaught exception,
process.exit, OOM), nothing told the Rust worker pool about it: the dead
Worker stayed in loaderWorkers, the operation waiting for its response
hung forever, and every later evaluation could be handed the dead worker,
wedging the dev server's compilation permanently. This is the crash-time
sibling of #96592, which covered evaluation and IPC errors reported over
the channel.

JavaScript now attaches 'error'/'exit' listeners to pooled workers and
reports any death while the worker is still registered (intentional
terminations remove the worker from the map first, so they are never
reported; the thread id is captured at creation since it resets to -1 by
the time 'exit' fires). On the Rust side, a dead-worker set records
reaped ids and handle_worker_death classifies the death:

- busy: closes the in-flight task's channel (via a watch channel, which
  cannot lose the wakeup), so its pending recv fails and surfaces through
  the existing subprocess-crash path in pull_operation - synthesized
  issue, disallow_reuse, fresh worker on the next evaluation;
- idle: removed from the idle list and pool statistics;
- created but unassigned (in transit to an acquirer or routed to a
  waiter): total worker count decremented;
- boot failure: the pending creation for the same pool is failed (so
  create_worker errors instead of waiting forever) and the booting
  statistics are balanced - no phantom workers after a boot crash.

Every acquisition and return path checks the dead set so a reaped id is
never handed out again: acquire_worker retries after dead candidates
(idle pop, waiter handoff, fresh boot), and WorkerOperation's drop skips
returning a worker that died while checked out.

Verified end to end with turbopackPluginRuntimeStrategy: 'workerThreads':

- before: a loader calling process.exit(1) hangs the request forever and
  wedges all later compilations;
- after: the request fails in ~1s with "Node.js subprocess crashed while
  evaluating loaders", and the next request is served by a fresh worker
  in ~50ms. A worker that dies between tasks (idle) is reaped the same
  way.

The new development test covers the crash path: baseline (stock binary)
fails by hanging; candidate fails the evaluation and recovers. The #96592
error-cleanup test still passes. Autoreview panel findings addressed:
watch-channel close, dead-set validation on all handoff paths,
options-tagged pending creations, clean-exit reaping.

Validation: pnpm build-all (TS + native), cargo fmt --check, cargo clippy
-p turbopack-node --all-targets -D warnings, cargo test -p turbopack-node
--lib, candidate integration test pass, exact baseline binary fails by
hanging.

Co-Authored-By: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com>
marcoshernanz added a commit that referenced this pull request Aug 6, 2026
When a worker-thread plugin runtime dies unexpectedly (uncaught exception,
process.exit, OOM), nothing told the Rust worker pool about it: the dead
Worker stayed in loaderWorkers, the operation waiting for its response
hung forever, and every later evaluation could be handed the dead worker,
wedging the dev server's compilation permanently. This is the crash-time
sibling of #96592, which covered evaluation and IPC errors reported over
the channel.

JavaScript now attaches 'error'/'exit' listeners to pooled workers and
reports any death while the worker is still registered (intentional
terminations remove the worker from the map first, so they are never
reported; the thread id is captured at creation since it resets to -1 by
the time 'exit' fires). On the Rust side, a dead-worker set records
reaped ids and handle_worker_death classifies the death:

- busy: closes the in-flight task's channel (via a watch channel, which
  cannot lose the wakeup), so its pending recv fails and surfaces through
  the existing subprocess-crash path in pull_operation - synthesized
  issue, disallow_reuse, fresh worker on the next evaluation;
- idle: removed from the idle list and pool statistics;
- created but unassigned (in transit to an acquirer or routed to a
  waiter): total worker count decremented;
- boot failure: the pending creation for the same pool is failed (so
  create_worker errors instead of waiting forever) and the booting
  statistics are balanced - no phantom workers after a boot crash.

Every acquisition and return path checks the dead set so a reaped id is
never handed out again: acquire_worker retries after dead candidates
(idle pop, waiter handoff, fresh boot), and WorkerOperation's drop skips
returning a worker that died while checked out.

Verified end to end with turbopackPluginRuntimeStrategy: 'workerThreads':

- before: a loader calling process.exit(1) hangs the request forever and
  wedges all later compilations;
- after: the request fails in ~1s with "Node.js subprocess crashed while
  evaluating loaders", and the next request is served by a fresh worker
  in ~50ms. A worker that dies between tasks (idle) is reaped the same
  way.

The new development test covers the crash path: baseline (stock binary)
fails by hanging; candidate fails the evaluation and recovers. The #96592
error-cleanup test still passes. Autoreview panel findings addressed:
watch-channel close, dead-set validation on all handoff paths,
options-tagged pending creations, clean-exit reaping.

Validation: pnpm build-all (TS + native), cargo fmt --check, cargo clippy
-p turbopack-node --all-targets -D warnings, cargo test -p turbopack-node
--lib, candidate integration test pass, exact baseline binary fails by
hanging.

Co-Authored-By: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com>
marcoshernanz added a commit that referenced this pull request Aug 6, 2026
When a worker-thread plugin runtime dies unexpectedly (uncaught exception,
process.exit, OOM), nothing told the Rust worker pool about it: the dead
Worker stayed in loaderWorkers, the operation waiting for its response
hung forever, and every later evaluation could be handed the dead worker,
wedging the dev server's compilation permanently. This is the crash-time
sibling of #96592, which covered evaluation and IPC errors reported over
the channel.

JavaScript now attaches 'error'/'exit' listeners to pooled workers and
reports any death while the worker is still registered (intentional
terminations remove the worker from the map first, so they are never
reported; the thread id is captured at creation since it resets to -1 by
the time 'exit' fires). On the Rust side, a dead-worker set records
reaped ids and handle_worker_death classifies the death:

- busy: closes the in-flight task's channel (via a watch channel, which
  cannot lose the wakeup), so its pending recv fails and surfaces through
  the existing subprocess-crash path in pull_operation - synthesized
  issue, disallow_reuse, fresh worker on the next evaluation;
- idle: removed from the idle list and pool statistics;
- created but unassigned (in transit to an acquirer or routed to a
  waiter): total worker count decremented;
- boot failure: the pending creation for the same pool is failed (so
  create_worker errors instead of waiting forever) and the booting
  statistics are balanced - no phantom workers after a boot crash.

Every acquisition and return path checks the dead set so a reaped id is
never handed out again: acquire_worker retries after dead candidates
(idle pop, waiter handoff, fresh boot), and WorkerOperation's drop skips
returning a worker that died while checked out.

Verified end to end with turbopackPluginRuntimeStrategy: 'workerThreads':

- before: a loader calling process.exit(1) hangs the request forever and
  wedges all later compilations;
- after: the request fails in ~1s with "Node.js subprocess crashed while
  evaluating loaders", and the next request is served by a fresh worker
  in ~50ms. A worker that dies between tasks (idle) is reaped the same
  way.

The new development test covers the crash path: baseline (stock binary)
fails by hanging; candidate fails the evaluation and recovers. The #96592
error-cleanup test still passes. Autoreview panel findings addressed:
watch-channel close, dead-set validation on all handoff paths,
options-tagged pending creations, clean-exit reaping.

Validation: pnpm build-all (TS + native), cargo fmt --check, cargo clippy
-p turbopack-node --all-targets -D warnings, cargo test -p turbopack-node
--lib, candidate integration test pass, exact baseline binary fails by
hanging.

Co-Authored-By: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com>
marcoshernanz added a commit that referenced this pull request Aug 6, 2026
When a worker-thread plugin runtime dies unexpectedly (uncaught exception,
process.exit, OOM), nothing told the Rust worker pool about it: the dead
Worker stayed in loaderWorkers, the operation waiting for its response
hung forever, and every later evaluation could be handed the dead worker,
wedging the dev server's compilation permanently. This is the crash-time
sibling of #96592, which covered evaluation and IPC errors reported over
the channel.

JavaScript now attaches 'error'/'exit' listeners to pooled workers and
reports any death while the worker is still registered (intentional
terminations remove the worker from the map first, so they are never
reported; the thread id is captured at creation since it resets to -1 by
the time 'exit' fires). On the Rust side, a dead-worker set records
reaped ids and handle_worker_death classifies the death:

- busy: closes the in-flight task's channel (via a watch channel, which
  cannot lose the wakeup), so its pending recv fails and surfaces through
  the existing subprocess-crash path in pull_operation - synthesized
  issue, disallow_reuse, fresh worker on the next evaluation;
- idle: removed from the idle list and pool statistics;
- created but unassigned (in transit to an acquirer or routed to a
  waiter): total worker count decremented;
- boot failure: the pending creation for the same pool is failed (so
  create_worker errors instead of waiting forever) and the booting
  statistics are balanced - no phantom workers after a boot crash.

Every acquisition and return path checks the dead set so a reaped id is
never handed out again: acquire_worker retries after dead candidates
(idle pop, waiter handoff, fresh boot), and WorkerOperation's drop skips
returning a worker that died while checked out.

Verified end to end with turbopackPluginRuntimeStrategy: 'workerThreads':

- before: a loader calling process.exit(1) hangs the request forever and
  wedges all later compilations;
- after: the request fails in ~1s with "Node.js subprocess crashed while
  evaluating loaders", and the next request is served by a fresh worker
  in ~50ms. A worker that dies between tasks (idle) is reaped the same
  way.

The new development test covers the crash path: baseline (stock binary)
fails by hanging; candidate fails the evaluation and recovers. The #96592
error-cleanup test still passes. Autoreview panel findings addressed:
watch-channel close, dead-set validation on all handoff paths,
options-tagged pending creations, clean-exit reaping.

Validation: pnpm build-all (TS + native), cargo fmt --check, cargo clippy
-p turbopack-node --all-targets -D warnings, cargo test -p turbopack-node
--lib, candidate integration test pass, exact baseline binary fails by
hanging.

Co-Authored-By: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com>
m-kawafuji pushed a commit to m-kawafuji/next.js that referenced this pull request Aug 8, 2026
## What

Terminate a worker-thread plugin runtime when an evaluation or IPC error
marks
its `WorkerOperation` non-reusable.

`disallow_reuse()` already removed the worker from pool statistics and
disabled
the callback that returns it to the pool, but it did not stop the
underlying
Node.js `Worker`. The worker then stayed strongly owned by
`loaderWorkers`, kept
its V8 isolate/module graph alive, and waited forever for another task
that
could never arrive.

This calls the existing worker terminator used by `wait_or_kill`,
scale-down,
and scale-to-zero. It removes the routed native channel and dispatches
the
existing JavaScript `Worker.terminate()` callback, which also deletes
the map
entry.

## Why this matters for v0

v0's planned worker-thread runtime turns transient loader/PostCSS errors
during
agent edits into a deterministic lifecycle leak. On the real v0
`/button`
route, each unique Tailwind/PostCSS failure retained one complete worker
runtime
on the baseline.

The balanced eight-process A/B panel (80 unique failures per lane)
measured:

| Lane | `WorkerThread` slope | Private-memory slope | Median failure |
Median recovery | >5 s failures |
|---|---:|---:|---:|---:|---:|
| baseline | **+1.000/error** | **+49.81 MiB/error** | 108.04 ms |
329.26 ms | 3 / 80 |
| candidate | **0.000/error** | **+5.12 MiB/error** | 108.15 ms | 317.65
ms | 2 / 80 |

That is a **100% elimination of leaked-worker growth** and an **89.7%
reduction
in retained private-memory slope** (exact process-bootstrap 95%
interval:
85.7%–99.5%). Every candidate process was below every baseline process;
exact
4-vs-4 process-label permutation `p=0.0143` one-sided / `0.0286`
two-sided.
Median failure latency changed by only +0.11 ms.

The independent 100-cycle-per-lane endurance confirmation showed the
same
causal result:

| Lane | `WorkerThread` cycle 1→100 | Private memory cycle 1→100 |
Private slope | Median failure | Median recovery |
|---|---:|---:|---:|---:|---:|
| baseline | **38→137** | **4.28→7.34 GiB** | **+27.02 MiB/error** |
104.45 ms | 331.32 ms |
| candidate | **32→32** | **3.93→4.10 GiB** | **+0.98 MiB/error** |
102.94 ms | 297.24 ms |

That long run eliminated all observed worker growth and reduced the
fitted
private-memory slope 96.4%. Every one of the 200 unique failures
recovered to
the complete real route, and the source hash was restored after every
cycle.

The benchmark used fresh `.next` state per process, five error/recovery
warmups,
a five-minute quiescence window, unique uncached errors, exact source
restoration
after every cycle, and within-process slopes. Baseline and candidate
native
binaries were built from the same Next.js base, with only this lifecycle
change
affecting candidate native code, and selected by SHA-verified
`NEXT_TEST_NATIVE_DIR` paths.

This is independent of vercel#96433. That PR coordinates explicit multi-file
edit
transactions; this PR fixes the lifecycle of a worker that has already
failed.
It also does not change idle-worker scale-down policy: failed workers
are absent
from the idle pool, so scale-down cannot see them.

## Regression test

The new development test schedules a one-second filesystem marker inside
a
custom loader, then throws an evaluation error under
`turbopackPluginRuntimeStrategy: 'workerThreads'`:

- baseline native binary: fails because the orphan worker remains alive
and
  writes the marker;
- candidate native binary: passes because the worker is actually
terminated;
- candidate then renders a recovered value, proving replacement/recovery
works.

## Validation

- `pnpm build-all`
- `cargo fmt --all -- --check`
- `cargo check -p turbopack-node --all-targets`
- `cargo test -p turbopack-node --lib`
- `cargo clippy -p turbopack-node --all-targets -- -D warnings`
- focused Prettier and ESLint checks for the new test
- candidate integration test pass; exact baseline integration test fails
at the
  intended liveness assertion
- GPT-5.6 Sol xhigh + Claude Opus 5 xhigh autoreview panel: zero
actionable
  findings, patch correct at 0.96 confidence

Exact optimized native SHA-256 values used by the benchmark:

- baseline:
`609eeeed41e1425f06136b54b7997e5b1b1add992169b17a797889dfb830dd7f`
- candidate:
`141211112ae47b8bd814aaab437060114e92f60a1bf6f23e1b0e153b1e4837cc`

Both stripped binaries were 205,863,784 bytes. Raw per-process JSON,
`/proc`
samples, server logs, harness source, analysis output, and A/B plus
endurance
graphs are retained in the investigation archive.

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
@github-actions github-actions Bot added the locked label Sep 6, 2026
@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Sep 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants