Skip to content

Commit

Permalink
fix #2725: make it possible to cancel a build
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Jan 16, 2023
1 parent 01efc05 commit e4377c1
Show file tree
Hide file tree
Showing 10 changed files with 289 additions and 1 deletion.
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions cmd/esbuild/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions internal/bundler/bundler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"regexp"
"strings"
"sync"
"sync/atomic"

"github.com/evanw/esbuild/internal/ast"
"github.com/evanw/esbuild/internal/compat"
Expand Down Expand Up @@ -241,13 +242,23 @@ 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
TSTarget *TSTarget
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
Expand Down
11 changes: 11 additions & 0 deletions lib/shared/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,17 @@ function buildOrContextImpl(
})
}),

cancel: () => new Promise(resolve => {
if (didDispose) return resolve()
const request: protocol.CancelRequest = {
command: 'cancel',
key: buildKey,
}
sendRequest<protocol.CancelRequest, null>(refs, request, () => {
resolve(); // We don't care about errors here
})
}),

dispose: () => new Promise(resolve => {
if (didDispose) return resolve()
const request: protocol.DisposeRequest = {
Expand Down
5 changes: 5 additions & 0 deletions lib/shared/stdio_protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ export interface DisposeRequest {
key: number
}

export interface CancelRequest {
command: 'cancel'
key: number
}

export interface WatchRequest {
command: 'watch'
key: number
Expand Down
1 change: 1 addition & 0 deletions lib/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ export interface BuildContext<SpecificOptions extends BuildOptions = BuildOption
/** Documentation: https://esbuild.github.io/api/#serve */
serve(options?: ServeOptions): Promise<ServeResult>

cancel(): Promise<void>
dispose(): Promise<void>
}

Expand Down
1 change: 1 addition & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ type BuildContext interface {
// Documentation: https://esbuild.github.io/api/#serve
Serve(options ServeOptions) (ServeResult, error)

Cancel()
Dispose()
}

Expand Down
33 changes: 32 additions & 1 deletion pkg/api/api_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,7 @@ func contextImpl(buildOpts BuildOptions) (*internalContext, []Message) {
type buildInProgress struct {
state rebuildState
waitGroup sync.WaitGroup
cancel config.CancelFlag
}

type internalContext struct {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit e4377c1

Please sign in to comment.