Skip to content

Worker: split create into create (validate, allocate) and start (spawn) - #44941

Open
robobun wants to merge 1 commit into
mainfrom
robobun/20c50f58/worker-create-start-seam
Open

robobun wants to merge 1 commit into
mainfrom
robobun/20c50f58/worker-create-start-seam

Conversation

@robobun

@robobun robobun commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Behaviour change: none

#44942 stacks on this PR.

Problem

  • Worker::create, WorkerMessagingProxy::startWorkerGlobalScope and WebWorker__create (src/jsc/web_worker.rs) each reject options and spawn the thread in one step.
  • The constructor consumes its transfer list before that step, so a rejected option costs the caller its buffers and ports. Worker: consume the transfer list after every option has validated #44942 fixes the order. It needs a step that only validates.

Fix

  • Each becomes two. Create validates the options and allocates the thread object. Start copies the parent's env, takes the keep-alive and spawns.
  • The serialized workerData, its ports and the shared env store become arguments of start.
  • JSWorkerDOMConstructor::construct calls create then start where it called create. No statement changes its order.
  • Verified: 31 worker, MessagePort and ModuleGraph test files pass. The 13 tests that Worker: consume the transfer list after every option has validated #44942 fixes still fail here.

Background

  • WorkerMessagingProxy is shared by the parent thread and the worker thread. WebWorker is the Rust object that owns the OS thread.
  • Create registers the thread object in the parent's child_workers, so the existing exit path releases one that never started.
  • Considered one PR for the split and the reorder. A revert of the reorder would remove the split too.

Downsides

  • One more C++ to Rust call per new Worker(). Instructions in these functions for a plain construct: 7,844 to 7,728. Allocator calls: 110 to 110. Release text: +768 bytes.
  • None other found. Checked allocator calls on five construct shapes, size_of::<WebWorker>() (200 bytes before and after) and the test files above.
Notes

What moved

  • WebWorker::create keeps the preload resolution and the execArgv flag check, allocates the WebWorker and registers it as a child. It stores the two flags that execArgv sets (--no-addons, --no-ffi-cc) for start.
  • WebWorker::start holds the rest of the old body unchanged: the copy of the transform options and of the env, the keep-alive, the parent ticket and the spawn. It returns false if the OS refuses the thread.
  • Deleted: the Out of memory arm around Loader::clone_for_worker. That function returns no Err (ArrayHashMap::clone cannot fail), so the arm was unreachable. Start has one failure left, the OS refusing the thread.
  • WorkerMessagingProxy::prepareWorkerGlobalScope is the old startWorkerGlobalScope up to WebWorker__create. The new startWorkerGlobalScope stores the three arguments in m_options, where the worker thread reads them, and calls WebWorker__start.
  • The temp-log block and the preload loop in WebWorker::create are untouched, so Report the resolve error when a Worker preload does not resolve #41496 merges with no conflict.

Checks that it behaves as main (release builds of main e655c58032 and of this commit, before the rebase onto 71d0d439973, which touches none of these files)

main this PR
the 19 tests of #44942 6 pass, 13 fail 6 pass, 13 fail
buffer and port kept after a rejected option (7 shapes) 0 of 7 0 of 7
threadId sequence: construct, rejected option, construct, DataCloneError, construct 1, throw, 3, throw, 4 1, throw, 3, throw, 4
new.target with a throwing prototype getter buffer 0 bytes, the entry runs buffer 0 bytes, the entry runs

Cost (gdb counters on the constructing thread. pthread_create is stepped over.)

main this PR
Rust calls per construct 1 2
allocator calls, new Worker(f) 110 110
allocator calls, { data, transferList } 203 203
allocator calls, { preload: [p] } 202 202
allocator calls, { execArgv: [] } 197 197
allocator calls, node:worker_threads new Worker(f) 210 210
instructions in WebWorker__create (+ WebWorker__start), new Worker(f) 7,844 7,728
instructions in the whole constructor, new Worker(f), constructs 4 and 5 48,288 and 46,888 48,218 and 46,784
size_of::<WebWorker>(), release / debug 200 B / 216 B 200 B / 224 B
release text 89,001,781 89,002,549

Tests

  • Release build of this commit: 31 test files under test/js/node/worker_threads, test/js/web/workers and test/js/bun/module-graph, 2,811 pass, 0 fail. module-graph-workers.test.ts ran with --max-concurrency=2: at the default some of its tests time out on my machine, for main too. Node's test-worker-*.js: 105 of 106, as on main.
  • cargo clippy -p bun_jsc, cargo fmt --check and clang-format are clean.
  • The OS refuses the thread (run by hand with prlimit --nproc as nobody, there is no portable test): the constructor throws TypeError: Failed to spawn worker thread, the transferred port closes at once, no 'worker' event fires, and the process exits 0. Main does the same. Worker: throw ERR_WORKER_INIT_FAILED when the OS refuses the thread #41675 adds a Linux test for this path.

Open PRs that touch the same lines (git merge-tree against this branch)


no test proof · iteration 0 · the description declares no behaviour change, so there is no failing test to prove; the existing suite in CI is the check

Worker::create, WorkerMessagingProxy and WebWorker__create validated the
options and spawned the thread in one step. Split each into a create step
that validates and allocates, and a start step that spawns. The serialized
workerData, its ports and the shared env store become arguments of start.

The constructor calls create and start back to back, where it called create.
No behaviour change.
@robobun

robobun commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:33 PM PT - Oct 10th, 2026

@robobun, your commit 4be8403 is building: #124356

@coderabbitai

coderabbitai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: b94f4787-ddb2-48c9-8b4d-b60f917dbbda




📥 Commits

Reviewing files that changed from the base of the PR and between 71d0d43 and 4be8403.





📒 Files selected for processing (6)
  • src/jsc/bindings/webcore/JSWorker.cpp
  • src/jsc/bindings/webcore/Worker.cpp
  • src/jsc/bindings/webcore/Worker.h
  • src/jsc/bindings/webcore/WorkerMessagingProxy.cpp
  • src/jsc/bindings/webcore/WorkerMessagingProxy.h
  • src/jsc/web_worker.rs




Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.






Walkthrough

Worker startup is split into worker allocation and thread launch. Creation prepares the worker and validates its options. Startup passes serialized data, transferred ports, and shared environment state, then launches the thread. Startup errors are propagated through the constructor.

Changes

Worker startup lifecycle

Layer / File(s) Summary
Rust worker allocation and launch
src/jsc/web_worker.rs
create() now allocates and registers the worker without spawning a thread. start() snapshots parent transform options and environment, applies stored execArgv permissions, and returns whether thread spawning succeeded.
Native worker preparation and launch
src/jsc/bindings/webcore/WorkerMessagingProxy.cpp, src/jsc/bindings/webcore/WorkerMessagingProxy.h
Preparation validates options and allocates the thread object. The separate launch phase supplies worker data, transferred ports, and shared environment state, then starts the thread with the unref setting.
Worker constructor startup wiring
src/jsc/bindings/webcore/JSWorker.cpp, src/jsc/bindings/webcore/Worker.cpp, src/jsc/bindings/webcore/Worker.h
The constructor calls Worker::create and then Worker::start. Both operations can propagate exceptions; startup receives the serialized message, transferred ports, and shared environment store.

Suggested reviewers: dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 4be84

No actionable merge-blocking risk remains from the reviewed worker startup split.

Pre-merge checks | Passed 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 change: splitting worker creation into validation/allocation and startup/spawning.
Description check Passed The description explains the problem, implementation, rationale, trade-offs, and verification results. It does not use the template headings exactly, but it provides the required information in equiva…

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@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.

I reviewed this PR and didn't find any bugs. Because it reshapes worker construction across the C++/Rust FFI boundary (refcounts, keep-alive, child_workers registration, thread spawn) and ships no test for the new spawn-failure path, a human look would still be worthwhile.

What was reviewed:

  • Spawn-failure path: thread ref and parent ticket drop with the closure, start unrefs the poll ref, then releaseWorkerThread() unrefs again (KeepAlive::unref is a no-op when already inactive), removes the child_workers entry, joins nothing, and derefs the proxy's ref — balanced with the old null-return path.
  • child_workers.push moved before spawn: create and start run back-to-back on the parent thread inside the constructor, so no exit path can observe the entry between them; release_parent_poll_ref still prunes it.
  • Options stored on m_options before WebWorker__start, so the worker thread's read in Worker.cpp still sees them before the thread exists; stopped-at-birth early return preserved.
  • JSWorker.cpp: toJSNewlyCreated 3-arg overload exists; manual ExceptionOr propagation matches the old throwScope overload; single caller of Worker::create/startWorkerGlobalScope, no stale call sites.
Extended reasoning...

The change splits Worker construction into create (validate + allocate) and start (copy parent state, keep-alive, spawn) across six files in src/jsc/bindings/webcore and src/jsc/web_worker.rs, moving workerData/ports/sharedEnvStore from WorkerOptions into a new start() argument list and the child_workers registration from after spawn to before it. It touches no injection, auth, or data-exposure surface; the sensitive surface is cross-FFI refcount and thread-lifecycle correctness. Refcounts and the keep-alive were traced as balanced on success, spawn failure, and stopped-at-birth, and the OOM arm now routes through bun_core::handle_oom per repo convention. Deferring rather than approving because the diff rewires worker lifecycle plumbing that is hard to exercise in tests (the PR adds none), and the author describes a second PR stacking on it that depends on this ordering. No CODEOWNERS entry covers the changed files and there are no third-party review events.

@robobun

robobun commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

Status

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.

2 participants