diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f616fbcf3..85442294dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,53 @@ ## Unreleased +* Make it possible to cancel a build ([#2725](https://github.com/evanw/esbuild/issues/2725)) + + The context object introduced in version 0.17.0 has a new `cancel()` method. You can use it to cancel a long-running build so that you can start a new one without needing to wait for the previous one to finish. When this happens, the previous build should always have at least one error and have no output files (i.e. it will be a failed build). + + Using it might look something like this: + + * JS: + + ```js + let ctx = await esbuild.context({ + // ... + }) + + let rebuildWithTimeLimit = timeLimit => { + let timeout = setTimeout(() => ctx.cancel(), timeLimit) + return ctx.rebuild().finally(() => clearTimeout(timeout)) + } + + let build = await rebuildWithTimeLimit(500) + ``` + + * Go: + + ```go + ctx, err := api.Context(api.BuildOptions{ + // ... + }) + if err != nil { + return + } + + rebuildWithTimeLimit := func(timeLimit time.Duration) api.BuildResult { + t := time.NewTimer(timeLimit) + go func() { + <-t.C + ctx.Cancel() + }() + result := ctx.Rebuild() + t.Stop() + return result + } + + build := rebuildWithTimeLimit(500 * time.Millisecond) + ``` + + This API is a quick implementation and isn't maximally efficient, so the build may continue to do some work for a little bit before stopping. For example, I have added stop points between each top-level phase of the bundler and in the main module graph traversal loop, but I haven't added fine-grained stop points within the internals of the linker. How quickly esbuild stops can be improved in future releases. This means you'll want to wait for `cancel()` and/or the previous `rebuild()` to finish (i.e. await the returned promise in JavaScript) before starting a new build, otherwise `rebuild()` will give you the just-canceled build that still hasn't ended yet. Note that `onEnd` callbacks will still be run regardless of whether or not the build was canceled. + * Fix server-sent events without `servedir` ([#2827](https://github.com/evanw/esbuild/issues/2827)) The server-sent events for live reload were incorrectly using `servedir` to calculate the path to modified output files. This means events couldn't be sent when `servedir` wasn't specified. This release uses the internal output directory (which is always present) instead of `servedir` (which might be omitted), so live reload should now work when `servedir` is not specified. diff --git a/cmd/esbuild/service.go b/cmd/esbuild/service.go index 78b51cfaba0..be782ac7452 100644 --- a/cmd/esbuild/service.go +++ b/cmd/esbuild/service.go @@ -415,6 +415,32 @@ func (service *serviceType) handleIncomingPacket(bytes []byte) { }, })) + case "cancel": + key := request["key"].(int) + if build := service.getActiveBuild(key); build != nil { + build.mutex.Lock() + ctx := build.ctx + build.mutex.Unlock() + if ctx != nil { + service.keepAliveWaitGroup.Add(1) + go func() { + defer service.keepAliveWaitGroup.Done() + ctx.Cancel() + + // Only return control to JavaScript once the cancel operation has succeeded + service.sendPacket(encodePacket(packet{ + id: p.id, + value: make(map[string]interface{}), + })) + }() + return + } + } + service.sendPacket(encodePacket(packet{ + id: p.id, + value: make(map[string]interface{}), + })) + case "dispose": key := request["key"].(int) if build := service.getActiveBuild(key); build != nil { diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index 0ef3577f662..0c9b224153a 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -1110,6 +1110,10 @@ func ScanBundle( applyOptionDefaults(&options) + if options.CancelFlag.DidCancel() { + return Bundle{options: options} + } + // Run "onStart" plugins in parallel timer.Begin("On-start callbacks") onStartWaitGroup := sync.WaitGroup{} @@ -1197,11 +1201,34 @@ func ScanBundle( onStartWaitGroup.Wait() timer.End("On-start callbacks") + if options.CancelFlag.DidCancel() { + return Bundle{options: options} + } + s.preprocessInjectedFiles() + + if options.CancelFlag.DidCancel() { + return Bundle{options: options} + } + entryPointMeta := s.addEntryPoints(entryPoints) + + if options.CancelFlag.DidCancel() { + return Bundle{options: options} + } + s.scanAllDependencies() + + if options.CancelFlag.DidCancel() { + return Bundle{options: options} + } + files := s.processScannedFiles(entryPointMeta) + if options.CancelFlag.DidCancel() { + return Bundle{options: options} + } + return Bundle{ fs: fs, res: s.res, @@ -1480,6 +1507,10 @@ func (s *scanner) preprocessInjectedFiles() { } injectResolveWaitGroup.Wait() + if s.options.CancelFlag.DidCancel() { + return + } + // Parse all entry points that were resolved successfully results := make([]config.InjectedFile, len(s.options.InjectPaths)) j := 0 @@ -1537,6 +1568,10 @@ func (s *scanner) addEntryPoints(entryPoints []EntryPoint) []graph.EntryPoint { }) } + if s.options.CancelFlag.DidCancel() { + return nil + } + // Check each entry point ahead of time to see if it's a real file entryPointAbsResolveDir := s.fs.Cwd() for i := range entryPoints { @@ -1572,6 +1607,10 @@ func (s *scanner) addEntryPoints(entryPoints []EntryPoint) []graph.EntryPoint { } } + if s.options.CancelFlag.DidCancel() { + return nil + } + // Add any remaining entry points. Run resolver plugins on these entry points // so plugins can alter where they resolve to. These are run in parallel in // case any of these plugins block. @@ -1630,6 +1669,10 @@ func (s *scanner) addEntryPoints(entryPoints []EntryPoint) []graph.EntryPoint { } entryPointWaitGroup.Wait() + if s.options.CancelFlag.DidCancel() { + return nil + } + // Parse all entry points that were resolved successfully for i, resolveResult := range entryPointResolveResults { if resolveResult != nil { @@ -1782,6 +1825,10 @@ func (s *scanner) scanAllDependencies() { // Continue scanning until all dependencies have been discovered for s.remaining > 0 { + if s.options.CancelFlag.DidCancel() { + return + } + result := <-s.resultChannel s.remaining-- if !result.ok { @@ -2366,6 +2413,10 @@ func (b *Bundle) Compile(log logger.Log, timer *helpers.Timer, mangleCache map[s timer.Begin("Compile phase") defer timer.End("Compile phase") + if b.options.CancelFlag.DidCancel() { + return nil, "" + } + options := b.options // In most cases we don't need synchronized access to the mangle cache diff --git a/internal/config/config.go b/internal/config/config.go index 2b5c91d2a22..0317642c280 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "regexp" "strings" "sync" + "sync/atomic" "github.com/evanw/esbuild/internal/ast" "github.com/evanw/esbuild/internal/compat" @@ -241,6 +242,15 @@ const ( False ) +type CancelFlag struct { + atomic.Bool +} + +// This checks for nil in one place so we don't have to do that everywhere +func (flag *CancelFlag) DidCancel() bool { + return flag != nil && flag.Load() +} + type Options struct { ModuleTypeData js_ast.ModuleTypeData Defines *ProcessedDefines @@ -248,6 +258,7 @@ type Options struct { TSAlwaysStrict *TSAlwaysStrict MangleProps *regexp.Regexp ReserveProps *regexp.Regexp + CancelFlag *CancelFlag // When mangling property names, call this function with a callback and do // the property name mangling inside the callback. The callback takes an diff --git a/lib/shared/common.ts b/lib/shared/common.ts index 16a5ddbb1fa..1ca12dee0f3 100644 --- a/lib/shared/common.ts +++ b/lib/shared/common.ts @@ -1103,6 +1103,17 @@ function buildOrContextImpl( }) }), + cancel: () => new Promise(resolve => { + if (didDispose) return resolve() + const request: protocol.CancelRequest = { + command: 'cancel', + key: buildKey, + } + sendRequest(refs, request, () => { + resolve(); // We don't care about errors here + }) + }), + dispose: () => new Promise(resolve => { if (didDispose) return resolve() const request: protocol.DisposeRequest = { diff --git a/lib/shared/stdio_protocol.ts b/lib/shared/stdio_protocol.ts index 01d30e3943a..ba9bee11ac2 100644 --- a/lib/shared/stdio_protocol.ts +++ b/lib/shared/stdio_protocol.ts @@ -87,6 +87,11 @@ export interface DisposeRequest { key: number } +export interface CancelRequest { + command: 'cancel' + key: number +} + export interface WatchRequest { command: 'watch' key: number diff --git a/lib/shared/types.ts b/lib/shared/types.ts index 47729283359..77d7a971274 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -494,6 +494,7 @@ export interface BuildContext + cancel(): Promise dispose(): Promise } diff --git a/pkg/api/api.go b/pkg/api/api.go index ec7604d8017..2b432d30249 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -496,6 +496,7 @@ type BuildContext interface { // Documentation: https://esbuild.github.io/api/#serve Serve(options ServeOptions) (ServeResult, error) + Cancel() Dispose() } diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index 0613d5eedcb..a14f2939e64 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -913,6 +913,7 @@ func contextImpl(buildOpts BuildOptions) (*internalContext, []Message) { type buildInProgress struct { state rebuildState waitGroup sync.WaitGroup + cancel config.CancelFlag } type internalContext struct { @@ -952,6 +953,7 @@ func (ctx *internalContext) rebuild() rebuildState { watcher := ctx.watcher handler := ctx.handler oldSummary := ctx.latestSummary + args.options.CancelFlag = &build.cancel ctx.mutex.Unlock() // Do the build without holding the mutex @@ -1066,6 +1068,27 @@ func (ctx *internalContext) Watch(options WatchOptions) error { return nil } +func (ctx *internalContext) Cancel() { + ctx.mutex.Lock() + + // Ignore disposed contexts + if ctx.didDispose { + ctx.mutex.Unlock() + return + } + + build := ctx.activeBuild + ctx.mutex.Unlock() + + if build != nil { + // Tell observers to cut this build short + build.cancel.Store(true) + + // Wait for the build to finish before returning + build.waitGroup.Wait() + } +} + func (ctx *internalContext) Dispose() { // Only dispose once ctx.mutex.Lock() @@ -1398,6 +1421,11 @@ func rebuildImpl(args rebuildArgs, oldSummary buildSummary) rebuildState { result.MangleCache = cloneMangleCache(log, args.mangleCache) results, metafile := bundle.Compile(log, timer, result.MangleCache, linker.Link) + // Canceling a build generates a single error at the end of the build + if args.options.CancelFlag.DidCancel() { + log.AddError(nil, logger.Range{}, "The build was canceled") + } + // Stop now if there were errors if !log.HasErrors() { result.Metafile = metafile @@ -1492,7 +1520,10 @@ func rebuildImpl(args rebuildArgs, oldSummary buildSummary) rebuildState { result.Errors = convertMessagesToPublic(logger.Error, msgs) result.Warnings = convertMessagesToPublic(logger.Warning, msgs) - // Run any registered "OnEnd" callbacks now + // Run any registered "OnEnd" callbacks now. These always run regardless of + // whether the current build has bee canceled or not. They can check for + // errors by checking the error array in the build result, and canceled + // builds should always have at least one error. timer.Begin("On-end callbacks") for _, onEnd := range args.onEndCallbacks { fromPlugin, thrown := onEnd.fn(&result) diff --git a/scripts/js-api-tests.js b/scripts/js-api-tests.js index 7febe5f569f..d163373b091 100644 --- a/scripts/js-api-tests.js +++ b/scripts/js-api-tests.js @@ -2642,6 +2642,110 @@ import "after/alias"; } }, + async rebuildCancel({ esbuild }) { + let loopForever = true + let onEndResult + + const context = await esbuild.context({ + entryPoints: ['entry'], + bundle: true, + write: false, + logLevel: 'silent', + format: 'esm', + plugins: [{ + name: '∞', + setup(build) { + build.onResolve({ filter: /.*/ }, args => { + return { path: args.path, namespace: '∞' } + }) + build.onLoad({ filter: /.*/ }, async (args) => { + if (!loopForever) return { contents: 'foo()' } + await new Promise(r => setTimeout(r, 10)) + return { contents: 'import ' + JSON.stringify(args.path + '.') } + }) + build.onEnd(result => { + onEndResult = result + }) + }, + }], + }) + + try { + // Build 1 + { + // Start a build + const buildPromise = context.rebuild() + + // Add a dummy catch handler to avoid terminating due to an unhandled exception + buildPromise.catch(() => { }) + + // Wait a bit + await new Promise(r => setTimeout(r, 200)) + + // Cancel the build + await context.cancel() + + // Check the result + try { + await buildPromise + throw new Error('Expected an error to be thrown') + } catch (error) { + assert.strictEqual(error.message, `Build failed with 1 error:\nerror: The build was canceled`) + assert.strictEqual(error.errors.length, 1) + assert.strictEqual(error.warnings.length, 0) + assert.notStrictEqual(onEndResult, undefined) + assert.strictEqual(onEndResult.errors.length, 1) + assert.strictEqual(onEndResult.errors[0].text, 'The build was canceled') + assert.strictEqual(onEndResult.warnings.length, 0) + assert.strictEqual(onEndResult.outputFiles.length, 0) + } + } + + // Build 2 + { + // Start a build + const buildPromise = context.rebuild() + + // Add a dummy catch handler to avoid terminating due to an unhandled exception + buildPromise.catch(() => { }) + + // Wait a bit + await new Promise(r => setTimeout(r, 200)) + + // Cancel the build + await context.cancel() + + // Check the result + try { + await buildPromise + throw new Error('Expected an error to be thrown') + } catch (error) { + assert.strictEqual(error.message, `Build failed with 1 error:\nerror: The build was canceled`) + assert.strictEqual(error.errors.length, 1) + assert.strictEqual(error.warnings.length, 0) + assert.notStrictEqual(onEndResult, undefined) + assert.strictEqual(onEndResult.errors.length, 1) + assert.strictEqual(onEndResult.errors[0].text, 'The build was canceled') + assert.strictEqual(onEndResult.warnings.length, 0) + assert.strictEqual(onEndResult.outputFiles.length, 0) + } + } + + // Build 3 + loopForever = false + { + const result = await context.rebuild() + assert.strictEqual(result.errors.length, 0) + assert.strictEqual(result.warnings.length, 0) + assert.strictEqual(result.outputFiles.length, 1) + assert.strictEqual(result.outputFiles[0].text, `// ∞:entry\nfoo();\n`) + assert.strictEqual(onEndResult, result) + } + } finally { + context.dispose() + } + }, + async bundleAvoidTDZ({ esbuild }) { var { outputFiles } = await esbuild.build({ stdin: {