1- import { existsSync } from "node:fs" ;
1+ import { existsSync , readdirSync } from "node:fs" ;
22import path from "node:path" ;
3+ import { setTimeout } from "node:timers/promises" ;
34import {
45 normalizeString ,
56 runInTempDir ,
@@ -20,10 +21,20 @@ function findSourceFile(source: string, name: string): string {
2021 return source . slice ( startIndex , endIndex ) ;
2122}
2223
24+ /**
25+ * The temporary build directories created under the project root, each of which
26+ * also registers a process exit listener to clean itself up.
27+ */
28+ function wranglerTmpDirs ( ) : string [ ] {
29+ const tmpRoot = path . resolve ( ".wrangler/tmp" ) ;
30+ return existsSync ( tmpRoot ) ? readdirSync ( tmpRoot ) : [ ] ;
31+ }
32+
2333function configDefaults (
2434 config : Partial <
25- Omit < StartDevWorkerOptions , "build" > & {
35+ Omit < StartDevWorkerOptions , "build" | "dev" > & {
2636 build : Partial < StartDevWorkerOptions [ "build" ] > ;
37+ dev : Partial < StartDevWorkerOptions [ "dev" ] > ;
2738 }
2839 >
2940) : StartDevWorkerOptions {
@@ -34,8 +45,11 @@ function configDefaults(
3445 entrypoint : path . resolve ( "src/index.ts" ) ,
3546 projectRoot : path . resolve ( "src" ) ,
3647 legacy : { } ,
37- dev : { persist } ,
3848 ...config ,
49+ dev : {
50+ persist,
51+ ...config . dev ,
52+ } ,
3953 build : {
4054 additionalModules : [ ] ,
4155 processEntrypoint : false ,
@@ -319,6 +333,112 @@ describe("BundleController", { retry: 5, timeout: 10_000 }, () => {
319333 ) ;
320334 } ) ;
321335
336+ test ( "a burst of watched file changes does not run custom builds concurrently" , async ( {
337+ expect,
338+ } ) => {
339+ await seed ( {
340+ // Records an overlap in `overlap.txt` if another custom build process is
341+ // still running when this one starts.
342+ //
343+ // A build that is superseded part way through is killed, leaving
344+ // `build.lock` behind, so the recorded pid is checked for liveness
345+ // rather than treating the presence of the lock as an overlap.
346+ "build.js" : dedent /* javascript */ `
347+ const fs = require("node:fs");
348+
349+ if (fs.existsSync("build.lock")) {
350+ const pid = Number(fs.readFileSync("build.lock", "utf8"));
351+ let running = true;
352+ try {
353+ process.kill(pid, 0);
354+ } catch {
355+ running = false;
356+ }
357+ if (running) {
358+ fs.writeFileSync("overlap.txt", String(pid));
359+ process.exit(1);
360+ }
361+ }
362+
363+ fs.writeFileSync("build.lock", String(process.pid));
364+ setTimeout(() => {
365+ fs.cpSync("custom_build_dir/index.ts", "out.ts");
366+ fs.rmSync("build.lock");
367+ }, 300);
368+ ` ,
369+ "custom_build_dir/index.ts" : dedent /* javascript */ `
370+ export default {
371+ fetch() {
372+ return new Response("hello custom build")
373+ }
374+ } satisfies ExportedHandler
375+ ` ,
376+ } ) ;
377+ const config = configDefaults ( {
378+ entrypoint : path . resolve ( "out.ts" ) ,
379+ projectRoot : path . resolve ( "." ) ,
380+ build : {
381+ custom : {
382+ command : "node build.js" ,
383+ watch : "custom_build_dir" ,
384+ } ,
385+ moduleRoot : path . resolve ( "." ) ,
386+ } ,
387+ } ) ;
388+
389+ const firstBuild = bus . waitFor ( "bundleComplete" ) ;
390+ controller . onConfigUpdate ( { type : "configUpdate" , config } ) ;
391+ await firstBuild ;
392+
393+ const bundleStartCount = ( ) =>
394+ bus . events . filter ( ( event ) => event . type === "bundleStart" ) . length ;
395+ const buildsBeforeChanges = bundleStartCount ( ) ;
396+
397+ // Simulate the burst of watcher events produced by something like a
398+ // `git pull` touching several files at once.
399+ await seed (
400+ Object . fromEntries (
401+ Array . from ( { length : 5 } , ( _ , i ) => [
402+ `custom_build_dir/change-${ i } .txt` ,
403+ String ( i ) ,
404+ ] )
405+ )
406+ ) ;
407+
408+ // Wait for the builds triggered by the burst to settle, which we treat as
409+ // two consecutive polls seeing the same number of builds. We deliberately
410+ // don't wait for `bundleComplete`: overlapping builds make the last build
411+ // fail, and the assertions below report that far more usefully than the
412+ // test timing out.
413+ let previousCount = - 1 ;
414+ await vi . waitFor (
415+ ( ) => {
416+ const count = bundleStartCount ( ) ;
417+ const settled =
418+ count > buildsBeforeChanges && count === previousCount ;
419+ previousCount = count ;
420+ if ( ! settled ) {
421+ throw new Error (
422+ `Custom builds have not settled yet (${ count - buildsBeforeChanges } started since the file changes)`
423+ ) ;
424+ }
425+ } ,
426+ { timeout : 8_000 , interval : 500 }
427+ ) ;
428+
429+ expect ( existsSync ( "overlap.txt" ) ) . toBe ( false ) ;
430+ expect (
431+ bus . events . filter (
432+ ( event ) =>
433+ event . type === "error" &&
434+ event . source === "BundlerController" &&
435+ event . reason === "Custom build failed"
436+ )
437+ ) . toEqual ( [ ] ) ;
438+ // The burst is debounced, so it must not produce a build per changed file
439+ expect ( bundleStartCount ( ) - buildsBeforeChanges ) . toBeLessThan ( 5 ) ;
440+ } ) ;
441+
322442 test ( "teardown aborts an in-flight watched custom build" , async ( {
323443 expect,
324444 } ) => {
@@ -382,6 +502,73 @@ describe("BundleController", { retry: 5, timeout: 10_000 }, () => {
382502 )
383503 ) . toBe ( false ) ;
384504 } ) ;
505+
506+ // `DevEnv` tears its controllers down concurrently, and `ConfigController` can
507+ // dispatch a config-file change that was delivered while its own watcher was
508+ // closing, so a config update can reach the bundler after it has torn down.
509+ // Acting on one leaks watchers, an esbuild watch build and a temp directory
510+ // that nothing will ever clean up, and can keep the process alive.
511+ const postTeardownCases = [
512+ {
513+ name : "watched custom build" ,
514+ build : {
515+ custom : { command : "node build.js" , watch : "custom_build_dir" } ,
516+ } ,
517+ } ,
518+ {
519+ name : "unwatched custom build" ,
520+ dev : { watch : false } ,
521+ build : {
522+ custom : { command : "node build.js" , watch : "custom_build_dir" } ,
523+ } ,
524+ } ,
525+ {
526+ name : "esbuild bundler" ,
527+ entrypoint : path . resolve ( "custom_build_dir/index.ts" ) ,
528+ } ,
529+ ] ;
530+
531+ for ( const testCase of postTeardownCases ) {
532+ test ( `a config update after teardown is ignored (${ testCase . name } )` , async ( {
533+ expect,
534+ } ) => {
535+ await seed ( {
536+ "build.js" : dedent /* javascript */ `
537+ const fs = require("node:fs");
538+ fs.writeFileSync("built.txt", "yes");
539+ fs.cpSync("custom_build_dir/index.ts", "out.ts");
540+ ` ,
541+ "custom_build_dir/index.ts" : dedent /* javascript */ `
542+ export default {
543+ fetch() {
544+ return new Response("initial")
545+ }
546+ }
547+ ` ,
548+ } ) ;
549+ const config = configDefaults ( {
550+ entrypoint : path . resolve ( "out.ts" ) ,
551+ projectRoot : path . resolve ( "." ) ,
552+ ...testCase ,
553+ build : { moduleRoot : path . resolve ( "." ) , ...testCase . build } ,
554+ } ) ;
555+
556+ await controller . teardown ( ) ;
557+ controller . onConfigUpdate ( { type : "configUpdate" , config } ) ;
558+ await setTimeout ( 500 ) ;
559+
560+ // Nothing may run the user's build command or report to the torn-down bus
561+ expect ( existsSync ( "built.txt" ) ) . toBe ( false ) ;
562+ expect (
563+ bus . events . filter (
564+ ( event ) =>
565+ event . type === "bundleStart" || event . type === "bundleComplete"
566+ )
567+ ) . toEqual ( [ ] ) ;
568+ // ...nor create resources that nothing is left to clean up
569+ expect ( wranglerTmpDirs ( ) ) . toEqual ( [ ] ) ;
570+ } ) ;
571+ }
385572 } ) ;
386573
387574 test ( "module aliasing" , async ( { expect } ) => {
0 commit comments