Skip to content

[wrangler] Serialize custom build watcher events to avoid concurrent runs - #14936

Merged
petebacondarwin merged 6 commits into
mainfrom
pbd/serialize-custom-builds
Jul 31, 2026
Merged

[wrangler] Serialize custom build watcher events to avoid concurrent runs#14936
petebacondarwin merged 6 commits into
mainfrom
pbd/serialize-custom-builds

Conversation

@petebacondarwin

@petebacondarwin petebacondarwin commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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 via Co-authored-by.

The bug

When several watched files changed at once — a git pull, a "save all", a framework writing out a directory — wrangler dev started 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, but tree-kill walks 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

  • Custom builds triggered by the watcher are debounced (100ms), so a burst of events results in one build rather than a build per event that is immediately superseded by the next.
  • Builds are queued and run one at a time, keeping only the most recent request. A build only starts once the previous one has exited.
  • This makes the #activeCustomBuilds set 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.
  • A queued build is dropped if its abort signal has already fired, so a build scheduled against a replaced config never runs its command.

Related fixes while in here

These share a root cause with the above: watchers deliver events while they are closing, and DevEnv tears its controllers down concurrently, so work could be started after dev had already moved on. Controller already tracks teardown state for suppressing error events, so it's now exposed as a protected getter and checked at each entry point.

  • Custom builds could run after dev shut down. A build scheduled in that window gets a fresh 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.)
  • The assets watcher's debounced bundle refresh was never cancelled. A pending refresh could dispatch bundleComplete into 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. ConfigController can 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 a cancel() 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_factory to esbuild as the JSX fragment factory, so projects setting both jsx_factory and jsx_fragment got 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.

  • Concurrency: drives the real chokidar watcher and a real build command. The command records its pid in a lock file and reports an overlap only if the recorded pid is still alive — a liveness check rather than a bare existsSync, because a superseded build is killed and leaves its lock behind. Fails pre-fix with overlap.txt written (genuinely concurrent builds) and expected 5 to be less than 5 (no coalescing).
  • Teardown: the assets tests drive the watcher through the existing chokidar mock, which makes arming the debounce deterministic rather than dependent on filesystem event timing. The EMFILE test file already had that mock, so it's been renamed to cover assets watcher behaviour generally.
  • Post-teardown config updates: covered for all three paths (watched custom build, unwatched custom build, plain esbuild). The leaked .wrangler/tmp/dev-* directory is the signal that makes each case fail without the guard.
  • debounce()'s coalescing and cancel() are unit tested with fake timers.

  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: this fixes incorrect wrangler dev behaviour with no change to the configuration or API surface. Custom builds and jsx_fragment are already documented.

A picture of a cute animal (not mandatory, but encouraged)

…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-bot

changeset-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8d72f99

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
wrangler Patch
@cloudflare/vite-plugin Patch
@cloudflare/vitest-pool-workers Patch

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

@github-project-automation github-project-automation Bot moved this to Untriaged in workers-sdk Jul 30, 2026
@workers-devprod
workers-devprod requested review from a team and dario-piotrowicz and removed request for a team July 30, 2026 15:33
@workers-devprod

workers-devprod commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • ✅ @cloudflare/wrangler
Show detailed file reviewers

@ask-bonk

ask-bonk Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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 lastChangedPath closure shared across debounced calls could produce a misleading log path. It uses the most recent changed path for the log message, which is acceptable and intentional per the comment.

Let me also verify the ready event and the debounced watcher don't race in a way that drops the initial build. On ready, #scheduleCustomBuild is called directly (not debounced). That's fine.

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 cancel() addition is correct and tested. No logic bugs, security issues, or backward compatibility violations found.

LGTM

github run

devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Jul 30, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@14936

@cloudflare/build-output-utils

npm i https://pkg.pr.new/@cloudflare/build-output-utils@14936

@cloudflare/config

npm i https://pkg.pr.new/@cloudflare/config@14936

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@14936

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@14936

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@14936

miniflare

npm i https://pkg.pr.new/miniflare@14936

@cloudflare/pages-functions

npm i https://pkg.pr.new/@cloudflare/pages-functions@14936

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@14936

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@14936

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@14936

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@14936

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@14936

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@14936

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@14936

wrangler

npm i https://pkg.pr.new/wrangler@14936

commit: 8d72f99

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

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

✅ All changesets look good

devin-ai-integration[bot]

This comment was marked as resolved.

…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.
devin-ai-integration[bot]

This comment was marked as resolved.

…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.
devin-ai-integration[bot]

This comment was marked as resolved.

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.
devin-ai-integration[bot]

This comment was marked as resolved.

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

@dario-piotrowicz dario-piotrowicz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM 🙂

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

Codeowners reviews satisfied

@github-project-automation github-project-automation Bot moved this from Untriaged to Approved in workers-sdk Jul 31, 2026
@petebacondarwin
petebacondarwin merged commit f92d1fc into main Jul 31, 2026
70 of 71 checks passed
@petebacondarwin
petebacondarwin deleted the pbd/serialize-custom-builds branch July 31, 2026 10:45
@github-project-automation github-project-automation Bot moved this from Approved to Done in workers-sdk Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Prevent custom builds from executing concurrenctly

3 participants