[wrangler] Serialize custom build watcher events to avoid concurrent runs - #14936
Conversation
…runs When several watched files changed at once, `wrangler dev` started a custom build for every watcher event, so multiple copies of the user's build command ran concurrently and fought over the same output files. Custom builds triggered by the watcher are now debounced and queued, keeping only the most recent request, so a burst of changes results in a single build and a build only starts once the previous one has exited. This makes the `#activeCustomBuilds` set redundant: there is now at most one build in flight, tracked by the promise for the queue-draining loop. Also fixes the custom build path passing `jsx_factory` to esbuild as the JSX fragment factory instead of `jsx_fragment`. Co-authored-by: dhruv7539 <dhruvbhanderi7@gmail.com>
🦋 Changeset detectedLatest commit: 8d72f99 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Codeowners approval required for this PR:
Show detailed file reviewers |
|
No pnpm/build available. I'll rely on static analysis, which is sufficient here. Let me do a final review of a subtle edge case: whether the Let me also verify the One last thing — let me confirm the changesets follow repo rules (two separate changesets for two distinct changes — correct per AGENTS.md). The implementation is careful, well-commented, correctly serializes builds, fixes the JSX fragment bug, and includes good tests. Both the queue-drain logic and abort handling are sound. The debounce LGTM |
@cloudflare/autoconfig
@cloudflare/build-output-utils
@cloudflare/config
create-cloudflare
@cloudflare/deploy-helpers
@cloudflare/kv-asset-handler
miniflare
@cloudflare/pages-functions
@cloudflare/pages-shared
@cloudflare/unenv-preset
@cloudflare/vite-plugin
@cloudflare/vitest-pool-workers
@cloudflare/workers-auth
@cloudflare/workers-editor-shared
@cloudflare/workers-utils
wrangler
commit: |
Watcher events can be delivered between `teardown()` aborting the in-flight build and the watcher finishing closing. Because a newly scheduled build gets a fresh `AbortController`, the "drop the build if its signal already fired" guard did not catch these, so a build could spawn the user's command and dispatch bundle events after dev had stopped. `Controller` already tracks this state for suppressing error events, so expose it to subclasses and bail out of scheduling when it is set. Cancelling the debounce also moves after the watcher has closed, so a late event can't re-arm the timer and keep the process alive. Reported by Devin on #14936.
|
✅ All changesets look good |
…load The debounced bundle refresh armed by the assets watcher was never cancelled, so a pending refresh could dispatch `bundleComplete` into a torn-down bus, or report a bundle against a config that a reload had just replaced. Its timer could also delay the process exiting by up to the debounce delay. Store it on a field like `#debouncedCustomBuild` and cancel it once the old watcher has closed — both when re-watching for a new config and in `teardown()` — plus a `tearingDown` guard for events delivered while the watcher is closing. Renames the EMFILE test file, which already mocks chokidar, to cover assets watcher behaviour generally. Driving the watcher through the mock makes the new tests deterministic: both fail without the cancellation.
…rn down `DevEnv` tears its controllers down concurrently, and `ConfigController` can dispatch a config-file change that was delivered while its own watcher was closing — with a fresh `AbortController`, so its `signal.aborted` guard doesn't stop it. A `configUpdate` could therefore reach `BundlerController` after its teardown had finished, at which point `#startCustomBuild` and `#ensureWatchingAssets` create persistent chokidar watchers that are never closed, `#startBundle` starts an esbuild watch build whose cleanup callback is never invoked, and `getWranglerTmpDir()` creates a temp directory (and registers a process exit listener) that is never removed. Any of these can keep the node process alive after dev has shut down. `onConfigUpdate()` now short-circuits when tearing down. The post-teardown test is extended to cover the watch-enabled and plain esbuild paths as well; the leaked temp directory is what makes all three cases fail without the guard.
REVIEW.md asks for changesets that target users of the tools rather than maintainers, without implementation details. All three described internal machinery — controllers, watchers, esbuild, temp directories — so they now describe only what a Wrangler user would notice. Flagged by Devin on #14936.
… will miss `#startCustomBuild`, `#startBundle` and `#ensureWatchingAssets` all suspend on a first `await` that tears down the previous config's resources, then install replacements. `teardown()` reads those handles once, so a setup resuming after it had finished would install a `persistent: true` chokidar watcher or an esbuild watch build that nothing ever closes, keeping the process alive. Re-check `tearingDown` after that first `await` in each method.
workers-devprod
left a comment
There was a problem hiding this comment.
Codeowners reviews satisfied
Fixes #10944.
Supersedes #12704 — that PR had gone stale and the code it touched was rewritten by #14462 and #14561 in the meantime, so this is a re-implementation on top of current
main. Credit to @dhruv7539 for the original fix and the approach, retained viaCo-authored-by.The bug
When several watched files changed at once — a
git pull, a "save all", a framework writing out a directory —wrangler devstarted a custom build for every watcher event. Multiple copies of the user's build command ran at the same time and fought over the same output files.Since #14462 a superseded build is sent
SIGTERM, buttree-killwalks the process tree asynchronously while the replacement build is spawned immediately, so builds still overlap. A long burst also produces a stream of start/kill churn.The fix
#activeCustomBuildsset from [wrangler] Abort custom builds on dev teardown #14462 redundant — there is now at most one build in flight by construction, tracked by the promise for the queue-draining loop.Related fixes while in here
These share a root cause with the above: watchers deliver events while they are closing, and
DevEnvtears its controllers down concurrently, so work could be started after dev had already moved on.Controlleralready tracks teardown state for suppressing error events, so it's now exposed as aprotectedgetter and checked at each entry point.AbortController, so the aborted-signal guard above can't catch it, and it would spawn the user's build command and dispatch bundle events into a torn-down environment. (Reported by Devin on this PR.)bundleCompleteinto a torn-down bus, or report a bundle against a config a reload had just replaced, and its timer could delay the process exiting.onConfigUpdate()had no teardown check at all.ConfigControllercan dispatch a config-file change delivered while its own watcher was closing, so an update could reach the bundler after its teardown had finished. That created chokidar watchers that are never closed, an esbuild watch build whose cleanup callback is never invoked, and a temp directory — plus its process exit listener — that is never removed. Any of these can keep the node process alive after dev has stopped.debounce()gains acancel()method for the two debounced paths, called once the relevant watcher has closed so a late event can't re-arm the timer.Also fixes an unrelated one-line bug in the same function that the original PR spotted: the custom build path passed the configured
jsx_factoryto esbuild as the JSX fragment factory, so projects setting bothjsx_factoryandjsx_fragmentgot the wrong fragment pragma. Separate changeset.Testing notes
Every new test was checked to fail without its corresponding fix — a couple of earlier drafts passed either way and were rewritten.
existsSync, because a superseded build is killed and leaves its lock behind. Fails pre-fix withoverlap.txtwritten (genuinely concurrent builds) andexpected 5 to be less than 5(no coalescing)..wrangler/tmp/dev-*directory is the signal that makes each case fail without the guard.debounce()'s coalescing andcancel()are unit tested with fake timers.wrangler devbehaviour with no change to the configuration or API surface. Custom builds andjsx_fragmentare already documented.A picture of a cute animal (not mandatory, but encouraged)