diff --git a/cmd/tsgo/main.go b/cmd/tsgo/main.go index 8d6816fa3d4..1abf26d9bbb 100644 --- a/cmd/tsgo/main.go +++ b/cmd/tsgo/main.go @@ -8,6 +8,7 @@ import ( "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/execute" + "github.com/microsoft/typescript-go/internal/execute/tsc" ) func main() { @@ -25,8 +26,48 @@ func runMain() int { return runAPI(args[1:]) } } - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() + + // Not signal.NotifyContext: we need to know which signal fired so we can exit by + // re-raising it, the way the JS tsc terminates under node's default handler. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Written before cancel(), so a canceled CommandLine always finds the signal here. + canceledBy := make(chan os.Signal, 1) + go func() { + select { + case sig := <-sigCh: + canceledBy <- sig + cancel() + case <-ctx.Done(): + } + }() + result := execute.CommandLine(ctx, newSystem(), args, nil) + + if result.Status == tsc.ExitStatusCanceled { + // Terminate via the signal itself, for the conventional exit code (130 for + // SIGINT, 143 for SIGTERM) and the terminal reset an unhandled signal produces. + select { + case sig := <-canceledBy: + // Does not return if the signal is re-delivered; otherwise (e.g. Windows) + // fall through to the same exit code numerically. + reRaiseSignal(sig) + if signo := signalNumber(sig); signo != 0 { + return 128 + signo + } + default: + } + } return int(result.Status) } + +// signalNumber returns the platform signal number for sig, or 0 if it has none. +func signalNumber(sig os.Signal) int { + if s, ok := sig.(syscall.Signal); ok { + return int(s) + } + return 0 +} diff --git a/cmd/tsgo/reraisesignal_other.go b/cmd/tsgo/reraisesignal_other.go new file mode 100644 index 00000000000..f7bff55bb47 --- /dev/null +++ b/cmd/tsgo/reraisesignal_other.go @@ -0,0 +1,10 @@ +//go:build !unix + +package main + +import "os" + +// reRaiseSignal is a no-op here: these platforms cannot re-deliver a termination +// signal to the current process (on Windows, os.Process.Signal rejects Interrupt). +func reRaiseSignal(sig os.Signal) { +} diff --git a/cmd/tsgo/reraisesignal_unix.go b/cmd/tsgo/reraisesignal_unix.go new file mode 100644 index 00000000000..366b4ce5201 --- /dev/null +++ b/cmd/tsgo/reraisesignal_unix.go @@ -0,0 +1,22 @@ +//go:build unix + +package main + +import ( + "os" + "os/signal" + "syscall" +) + +// reRaiseSignal restores the default disposition for sig and re-delivers it to this +// process, terminating us via the signal itself. It returns only if that fails. +func reRaiseSignal(sig os.Signal) { + s, ok := sig.(syscall.Signal) + if !ok { + return + } + signal.Reset(s) + if proc, err := os.FindProcess(os.Getpid()); err == nil { + _ = proc.Signal(s) + } +} diff --git a/internal/compiler/checkerpool.go b/internal/compiler/checkerpool.go index 55158370765..f3f204aa624 100644 --- a/internal/compiler/checkerpool.go +++ b/internal/compiler/checkerpool.go @@ -137,6 +137,10 @@ func (p *checkerPool) GetGlobalDiagnostics() []*ast.Diagnostic { p.createCheckers() globalDiagnostics := make([][]*ast.Diagnostic, len(p.checkers)) p.forEachCheckerParallel(func(idx int, checker *checker.Checker) { + // A canceled checker panics in checkNotCanceled if asked for diagnostics. + if checker.WasCanceled() { + return + } globalDiagnostics[idx] = checker.GetGlobalDiagnostics() }) return SortAndDeduplicateDiagnostics(slices.Concat(globalDiagnostics...)) @@ -155,6 +159,11 @@ func (p *checkerPool) forEachCheckerGroupDo(ctx context.Context, files []*ast.So p.locks[checkerIdx].Lock() defer p.locks[checkerIdx].Unlock() for i, file := range files { + // Feeding another file to a checker that canceled mid-check panics in + // checkNotCanceled. + if ctx.Err() != nil { + break + } if checker := p.checkers[checkerIdx]; checker == p.fileAssociations[file] { cb(checker, i, file) } diff --git a/internal/compiler/checkerpool_test.go b/internal/compiler/checkerpool_test.go new file mode 100644 index 00000000000..9156124e885 --- /dev/null +++ b/internal/compiler/checkerpool_test.go @@ -0,0 +1,93 @@ +package compiler_test + +import ( + "context" + "strings" + "sync/atomic" + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/compiler" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/tsoptions" + "github.com/microsoft/typescript-go/internal/vfs/vfstest" +) + +// cancelAfterNPolls cancels itself after Err has been polled pollThreshold times, +// then stays canceled, so cancellation lands after checking has begun. +type cancelAfterNPolls struct { + context.Context + pollThreshold int32 + polls atomic.Int32 + tripped atomic.Bool + done chan struct{} +} + +func newCancelAfterNPolls(pollThreshold int32) *cancelAfterNPolls { + return &cancelAfterNPolls{Context: context.Background(), pollThreshold: pollThreshold, done: make(chan struct{})} +} + +func (c *cancelAfterNPolls) Err() error { + if c.tripped.Load() { + return context.Canceled + } + if c.polls.Add(1) > c.pollThreshold { + if c.tripped.CompareAndSwap(false, true) { + close(c.done) + } + return context.Canceled + } + return nil +} + +func (c *cancelAfterNPolls) Done() <-chan struct{} { return c.done } + +// TestGetGlobalDiagnosticsAfterCancellation pins the checker-pool behavior that +// GetGlobalDiagnostics skips a checker canceled mid-check rather than reusing it +// (which panics in checkNotCanceled). This guards every caller, including +// emitBuildInfo's error-state probe, which has no cancellation check of its own. +func TestGetGlobalDiagnosticsAfterCancellation(t *testing.T) { + t.Parallel() + + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + fs := bundled.WrapFS(vfstest.FromMap[any](nil, false /*useCaseSensitiveFileNames*/)) + + // Many statements with type errors across a few files: global diagnostics exist, + // and the checker polls often so cancellation lands mid-check. + var src strings.Builder + for i := range 50 { + src.WriteString("export const v") + src.WriteString(strings.Repeat("x", i+1)) + src.WriteString(`: number = "not a number";` + "\n") + } + for _, name := range []string{"/src/a.ts", "/src/b.ts", "/src/c.ts"} { + _ = fs.WriteFile(name, src.String()) + } + + // One checker, so the mid-check cancellation marks the same checker that the + // subsequent GetGlobalDiagnostics will visit. + oneChecker := 1 + program := compiler.NewProgram(compiler.ProgramOptions{ + Config: &tsoptions.ParsedCommandLine{ + ParsedConfig: &core.ParsedOptions{ + FileNames: []string{"/src/a.ts", "/src/b.ts", "/src/c.ts"}, + CompilerOptions: &core.CompilerOptions{Strict: core.TSTrue, Checkers: &oneChecker}, + }, + }, + Host: compiler.NewCompilerHost("/src", fs, bundled.LibPath(), nil, nil), + }) + + ctx := newCancelAfterNPolls(5) + + // Drive checking only to leave the checker canceled; the result is discarded. + _ = program.GetSemanticDiagnostics(ctx, nil) + if !ctx.tripped.Load() { + t.Fatal("expected cancellation to trip during checking, but it never did") + } + + // The real assertion: this must not panic on the canceled checker. + _ = program.GetGlobalDiagnostics(ctx) +} diff --git a/internal/compiler/program.go b/internal/compiler/program.go index 3299b6a3acb..de74ef46da2 100644 --- a/internal/compiler/program.go +++ b/internal/compiler/program.go @@ -1761,6 +1761,11 @@ func HandleNoEmitOnError(ctx context.Context, program ProgramLike, files []*ast. if !program.Options().NoEmitOnError.IsTrue() { return nil // No emit on error is not set, so we can proceed with emitting } + if ctx.Err() != nil { + // The emit is abandoned anyway, and re-running diagnostics on an + // already-canceled checker panics in checkNotCanceled. + return nil + } diagnostics := GetDiagnosticsOfAnyProgram( ctx, @@ -1815,6 +1820,11 @@ func GetDiagnosticsOfAnyProgram( if len(allDiagnostics) == configFileParsingDiagnosticsLength { allDiagnostics = appendDiagnosticsForAllFiles(allDiagnostics, getSemanticDiagnostics) + // The calls below reuse the now-canceled checkers, which panics in + // checkNotCanceled; the diagnostics are discarded anyway. + if ctx.Err() != nil { + return allDiagnostics + } // Ask for the global diagnostics again (they were empty above); we may have found new during checking, e.g. missing globals. allDiagnostics = append(allDiagnostics, program.GetGlobalDiagnostics(ctx)...) } diff --git a/internal/execute/build/buildtask.go b/internal/execute/build/buildtask.go index 3508d2de074..22ad52e3cbc 100644 --- a/internal/execute/build/buildtask.go +++ b/internal/execute/build/buildtask.go @@ -1,6 +1,7 @@ package build import ( + "context" "fmt" "slices" "strings" @@ -73,10 +74,16 @@ type BuildTask struct { dirty bool } -func (t *BuildTask) waitOnUpstream() { +// Returns true when upstream is done, false when canceled. +func (t *BuildTask) waitOnUpstream(ctx context.Context) bool { for _, upstream := range t.upStream { - <-upstream.task.done + select { + case <-upstream.task.done: + case <-ctx.Done(): + return false + } } + return true } func (t *BuildTask) unblockDownstream() { @@ -120,15 +127,18 @@ func (t *BuildTask) report(orchestrator *Orchestrator, configPath tspath.Path, b close(t.reportDone) } -func (t *BuildTask) buildProject(orchestrator *Orchestrator, path tspath.Path) { - // Wait on upstream tasks to complete - t.waitOnUpstream() - if t.pending.Load() { +func (t *BuildTask) buildProject(ctx context.Context, orchestrator *Orchestrator, path tspath.Path) { + // The ctx.Err() check is not redundant: waitOnUpstream only observes the context + // while it has upstream to wait on, so a task with none (e.g. an up-to-date root) + // would take the no-build success path and swallow the interrupt. + if ctx.Err() != nil || !t.waitOnUpstream(ctx) { + t.result.exitStatus = tsc.ExitStatusCanceled + } else if t.pending.Load() { t.status = t.getUpToDateStatus(orchestrator, path) t.reportUpToDateStatus(orchestrator) if !t.handleStatusThatDoesntRequireBuild(orchestrator) { - t.compileAndEmit(orchestrator, path) - t.updateDownstream(orchestrator, path) + t.compileAndEmit(ctx, orchestrator, path) + t.updateDownstream(ctx, orchestrator, path) } else { if t.resolved != nil { for _, diagnostic := range t.resolved.GetConfigFileParsingDiagnostics() { @@ -151,7 +161,11 @@ func (t *BuildTask) buildProject(orchestrator *Orchestrator, path tspath.Path) { t.unblockDownstream() } -func (t *BuildTask) updateDownstream(orchestrator *Orchestrator, path tspath.Path) { +func (t *BuildTask) updateDownstream(ctx context.Context, orchestrator *Orchestrator, path tspath.Path) { + // A canceled build has partial results; downstream tasks must not consume them. + if ctx.Err() != nil { + return + } if t.isInitialCycle { return } @@ -187,7 +201,7 @@ func (t *BuildTask) updateDownstream(orchestrator *Orchestrator, path tspath.Pat } } -func (t *BuildTask) compileAndEmit(orchestrator *Orchestrator, path tspath.Path) { +func (t *BuildTask) compileAndEmit(ctx context.Context, orchestrator *Orchestrator, path tspath.Path) { t.errors = nil if orchestrator.opts.Command.BuildOptions.Verbose.IsTrue() { t.result.reportStatus(ast.NewCompilerDiagnostic(diagnostics.Building_project_0, orchestrator.relativeFileName(t.config))) @@ -216,7 +230,7 @@ func (t *BuildTask) compileAndEmit(orchestrator *Orchestrator, path tspath.Path) t.result.program = incremental.NewProgram(program, oldProgram, orchestrator.host, orchestrator.opts.Sys.Now, orchestrator.opts.Testing != nil) compileTimes.ChangesComputeTime = orchestrator.opts.Sys.Now().Sub(changesComputeStart) - result, statistics := tsc.EmitAndReportStatistics(tsc.EmitInput{ + result, statistics := tsc.EmitAndReportStatistics(ctx, tsc.EmitInput{ Sys: orchestrator.opts.Sys, ProgramLike: t.result.program, Program: program, @@ -233,6 +247,10 @@ func (t *BuildTask) compileAndEmit(orchestrator *Orchestrator, path tspath.Path) }) t.result.exitStatus = result.Status t.result.statistics = statistics + if result.Status == tsc.ExitStatusCanceled { + // EmitResult is nil when canceled; the code below would dereference it. + return + } t.packageJsons = t.result.program.PackageJsonLookupPaths() if (!program.Options().NoEmitOnError.IsTrue() || len(result.Diagnostics) == 0) && (len(result.EmitResult.EmittedFiles) > 0 || t.status.kind != upToDateStatusTypeOutOfDateBuildInfoWithErrors) { @@ -714,7 +732,7 @@ func (t *BuildTask) updateTimeStamps(orchestrator *Orchestrator, emittedFiles [] updateTimeStamp(t.resolved.GetBuildInfoFileName()) } -func (t *BuildTask) cleanProject(orchestrator *Orchestrator, path tspath.Path) { +func (t *BuildTask) cleanProject(ctx context.Context, orchestrator *Orchestrator, path tspath.Path) { if t.resolved == nil { t.reportDiagnostic(ast.NewCompilerDiagnostic(diagnostics.File_0_not_found, t.config)) t.result.exitStatus = tsc.ExitStatusDiagnosticsPresent_OutputsSkipped @@ -723,8 +741,17 @@ func (t *BuildTask) cleanProject(orchestrator *Orchestrator, path tspath.Path) { inputs := collections.NewSetFromItems(core.Map(t.resolved.FileNames(), orchestrator.toPath)...) for outputFile := range t.resolved.GetOutputFileNames() { + // Stop mid-clean rather than exiting with success, so the CLI re-raises. + if ctx.Err() != nil { + t.result.exitStatus = tsc.ExitStatusCanceled + return + } t.cleanProjectOutput(orchestrator, outputFile, inputs) } + if ctx.Err() != nil { + t.result.exitStatus = tsc.ExitStatusCanceled + return + } t.cleanProjectOutput(orchestrator, t.resolved.GetBuildInfoFileName(), inputs) } diff --git a/internal/execute/build/graph_test.go b/internal/execute/build/graph_test.go index c0288f94868..448b39a887b 100644 --- a/internal/execute/build/graph_test.go +++ b/internal/execute/build/graph_test.go @@ -115,7 +115,7 @@ func (b *buildOrderTestCase) run(t *testing.T) { Sys: sys, Command: buildCommand, }) - orchestrator.GenerateGraph(nil) + orchestrator.GenerateGraph(t.Context(), nil) buildOrder := core.Map(orchestrator.Order(), b.projectName) assert.DeepEqual(t, buildOrder, b.expected) verifyDeps(orchestrator, buildOrder, false) @@ -136,7 +136,7 @@ func (b *buildOrderTestCase) run(t *testing.T) { } } - orchestrator.GenerateGraphReusingOldTasks() + orchestrator.GenerateGraphReusingOldTasks(t.Context()) buildOrder2 := core.Map(orchestrator.Order(), b.projectName) assert.DeepEqual(t, buildOrder2, b.expected) @@ -146,7 +146,7 @@ func (b *buildOrderTestCase) run(t *testing.T) { Sys: sys, Command: buildCommandWatch, }) - orchestrator.GenerateGraph(nil) + orchestrator.GenerateGraph(t.Context(), nil) buildOrder3 := core.Map(orchestrator.Order(), b.projectName) verifyDeps(orchestrator, buildOrder3, true) }) diff --git a/internal/execute/build/orchestrator.go b/internal/execute/build/orchestrator.go index da13e64bbff..0aefdf75a52 100644 --- a/internal/execute/build/orchestrator.go +++ b/internal/execute/build/orchestrator.go @@ -121,9 +121,13 @@ func (o *Orchestrator) getTask(path tspath.Path) *BuildTask { return task } -func (o *Orchestrator) createBuildTasks(oldTasks *collections.SyncMap[tspath.Path, *BuildTask], configs []string, wg core.WorkGroup) { +func (o *Orchestrator) createBuildTasks(ctx context.Context, oldTasks *collections.SyncMap[tspath.Path, *BuildTask], configs []string, wg core.WorkGroup) { for _, config := range configs { wg.Queue(func() { + if ctx.Err() != nil { + // Stop early when canceled, our caller will report status. + return + } path := o.toPath(config) var task *BuildTask var buildInfo *buildInfoEntry @@ -148,7 +152,7 @@ func (o *Orchestrator) createBuildTasks(oldTasks *collections.SyncMap[tspath.Pat task.resolved = o.host.GetResolvedProjectReference(config, path) task.upStream = nil if task.resolved != nil { - o.createBuildTasks(oldTasks, task.resolved.ResolvedProjectReferencePaths(), wg) + o.createBuildTasks(ctx, oldTasks, task.resolved.ResolvedProjectReferencePaths(), wg) } }) } @@ -200,21 +204,28 @@ func (o *Orchestrator) setupBuildTask( return task } -func (o *Orchestrator) GenerateGraphReusingOldTasks() { +func (o *Orchestrator) GenerateGraphReusingOldTasks(ctx context.Context) { tasks := o.tasks o.tasks = &collections.SyncMap[tspath.Path, *BuildTask]{} o.order = nil o.errors = nil - o.GenerateGraph(tasks) + o.GenerateGraph(ctx, tasks) } -func (o *Orchestrator) GenerateGraph(oldTasks *collections.SyncMap[tspath.Path, *BuildTask]) { +func (o *Orchestrator) GenerateGraph(ctx context.Context, oldTasks *collections.SyncMap[tspath.Path, *BuildTask]) { projects := o.opts.Command.ResolvedProjectPaths() // Parse all config files in parallel wg := core.NewWorkGroup(o.opts.Command.CompilerOptions.SingleThreaded.IsTrue()) - o.createBuildTasks(oldTasks, projects, wg) + o.createBuildTasks(ctx, oldTasks, projects, wg) wg.RunAndWait() + // Cancellation leaves tasks missing for projects we never resolved, and + // setupBuildTask panics on a referenced project that has none. Leave the order + // empty; buildOrClean turns that into ExitStatusCanceled. + if ctx.Err() != nil { + return + } + // Generate the graph completed := collections.Set[tspath.Path]{} analyzing := collections.Set[tspath.Path]{} @@ -228,11 +239,17 @@ func (o *Orchestrator) Start(ctx context.Context) tsc.CommandLineResult { if o.opts.Command.CompilerOptions.Watch.IsTrue() { o.watchStatusReporter(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode)) } - o.GenerateGraph(nil) - result := o.buildOrClean() + o.GenerateGraph(ctx, nil) + result := o.buildOrClean(ctx) if o.opts.Command.CompilerOptions.Watch.IsTrue() { + // Cancellation is the only way to exit a watch, so it is not a failure. + if result.Status == tsc.ExitStatusCanceled { + result.Status = tsc.ExitStatusSuccess + return result + } o.Watch(ctx) result.Watcher = o + return result } return result } @@ -247,7 +264,7 @@ func (o *Orchestrator) Watch(ctx context.Context) { o.wm.EnsureDefaultBackend() } - o.updateWatch() + o.updateWatch(ctx) desiredDirs := o.computeDesiredWatches() if err := o.wm.ReconcileWatches(desiredDirs); err != nil { fmt.Fprintf(o.opts.Sys.Writer(), "%v\n", err) @@ -262,10 +279,10 @@ func (o *Orchestrator) Watch(ctx context.Context) { } } -func (o *Orchestrator) updateWatch() { +func (o *Orchestrator) updateWatch(ctx context.Context) { oldCache := o.host.mTimes o.host.mTimes = &collections.SyncMap[tspath.Path, time.Time]{} - o.rangeTask(func(path tspath.Path, task *BuildTask) { + o.rangeTask(ctx, func(_ context.Context, path tspath.Path, task *BuildTask) { task.updateWatch(o, oldCache) }) } @@ -390,7 +407,8 @@ func (o *Orchestrator) checkTasksForEventChanges(changedPaths map[string]fswatch for eventPath := range changedPaths { if o.host.FS().DirectoryExists(eventPath) { if o.wm.IsPathUnderWatch(eventPath, opts) { - o.rangeTask(func(path tspath.Path, task *BuildTask) { + // There is nothing interesting to cancel, just pass Background. + o.rangeTask(context.Background(), func(_ context.Context, path tspath.Path, task *BuildTask) { task.resetStatus() task.reportDone = make(chan struct{}) task.done = make(chan struct{}) @@ -542,7 +560,8 @@ func (o *Orchestrator) DoCycle() { if overflow { // Overflow: reset all tasks to force a full rebuild. - o.rangeTask(func(path tspath.Path, task *BuildTask) { + // There is nothing interesting to cancel, just pass Background. + o.rangeTask(context.Background(), func(_ context.Context, path tspath.Path, task *BuildTask) { task.resetConfig(o, path) task.reportDone = make(chan struct{}) task.done = make(chan struct{}) @@ -560,13 +579,16 @@ func (o *Orchestrator) DoCycle() { } o.watchStatusReporter(ast.NewCompilerDiagnostic(diagnostics.File_change_detected_Starting_incremental_compilation)) + + // TODO: propagate a proper context here and support cancellation within a cycle + ctx := context.Background() if needsConfigUpdate.Load() { // Generate new tasks - o.GenerateGraphReusingOldTasks() + o.GenerateGraphReusingOldTasks(ctx) } - o.buildOrClean() - o.updateWatch() + o.buildOrClean(ctx) + o.updateWatch(ctx) desiredDirs := o.computeDesiredWatches() if err := o.wm.ReconcileWatches(desiredDirs); err != nil { fmt.Fprintf(o.opts.Sys.Writer(), "%v\n", err) @@ -576,7 +598,12 @@ func (o *Orchestrator) DoCycle() { o.resetCaches() } -func (o *Orchestrator) buildOrClean() tsc.CommandLineResult { +func (o *Orchestrator) buildOrClean(ctx context.Context) tsc.CommandLineResult { + // Cancellation during GenerateGraph leaves the order empty, so no task exists to + // report the canceled status. + if ctx.Err() != nil && len(o.order) == 0 { + return tsc.CommandLineResult{Status: tsc.ExitStatusCanceled} + } if !o.opts.Command.BuildOptions.Clean.IsTrue() && o.opts.Command.BuildOptions.Verbose.IsTrue() { o.createBuilderStatusReporter(nil)(ast.NewCompilerDiagnostic( diagnostics.Projects_in_this_build_Colon_0, @@ -588,9 +615,11 @@ func (o *Orchestrator) buildOrClean() tsc.CommandLineResult { var buildResult orchestratorResult if len(o.errors) == 0 { buildResult.statistics.Projects = len(o.Order()) - o.rangeTask(func(path tspath.Path, task *BuildTask) { - o.buildOrCleanProject(task, path, &buildResult) + o.rangeTask(ctx, func(ctx context.Context, path tspath.Path, task *BuildTask) { + o.buildOrCleanProject(ctx, task, path, &buildResult) }) + // No cancellation override needed here: a canceled task reports + // ExitStatusCanceled through its own report(). } else { // Circularity errors prevent any project from being built buildResult.result.Status = tsc.ExitStatusProjectReferenceCycle_OutputsSkipped @@ -604,7 +633,7 @@ func (o *Orchestrator) buildOrClean() tsc.CommandLineResult { return buildResult.result } -func (o *Orchestrator) rangeTask(f func(path tspath.Path, task *BuildTask)) { +func (o *Orchestrator) rangeTask(ctx context.Context, f func(ctx context.Context, path tspath.Path, task *BuildTask)) { numRoutines := 4 if o.opts.Command.CompilerOptions.SingleThreaded.IsTrue() { numRoutines = 1 @@ -625,7 +654,10 @@ func (o *Orchestrator) rangeTask(f func(path tspath.Path, task *BuildTask)) { } runTask := func() { for path, task, ok := getNextTask(); ok; path, task, ok = getNextTask() { - f(path, task) + // Every task runs even after cancellation: each must close its + // done/reportDone channels or other tasks' waiters deadlock. buildProject + // skips the compile but still completes the task. + f(ctx, path, task) } } @@ -640,14 +672,14 @@ func (o *Orchestrator) rangeTask(f func(path tspath.Path, task *BuildTask)) { } } -func (o *Orchestrator) buildOrCleanProject(task *BuildTask, path tspath.Path, buildResult *orchestratorResult) { +func (o *Orchestrator) buildOrCleanProject(ctx context.Context, task *BuildTask, path tspath.Path, buildResult *orchestratorResult) { task.result = &taskResult{} task.result.reportStatus = o.createBuilderStatusReporter(task) task.result.diagnosticReporter = o.createDiagnosticReporter(task) if !o.opts.Command.BuildOptions.Clean.IsTrue() { - task.buildProject(o, path) + task.buildProject(ctx, o, path) } else { - task.cleanProject(o, path) + task.cleanProject(ctx, o, path) } task.report(o, path, buildResult) } diff --git a/internal/execute/tsc.go b/internal/execute/tsc.go index 7db8be97a02..8e21be29f18 100644 --- a/internal/execute/tsc.go +++ b/internal/execute/tsc.go @@ -55,15 +55,15 @@ func CommandLine(ctx context.Context, sys tsc.System, commandLineArgs []string, case "-b", "--b", "-build", "--build": return tscBuildCompilation(ctx, sys, tsoptions.ParseBuildCommandLine(commandLineArgs, sys), testing) // case "-f": - // return fmtMain(sys, commandLineArgs[1], commandLineArgs[1]) + // return fmtMain(ctx, sys, commandLineArgs[1], commandLineArgs[1]) } } return tscCompilation(ctx, sys, tsoptions.ParseCommandLine(commandLineArgs, sys), testing) } -func fmtMain(sys tsc.System, input, output string) tsc.ExitStatus { - ctx := format.WithFormatCodeSettings(context.Background(), lsutil.GetDefaultFormatCodeSettings(), "\n") +func fmtMain(ctx context.Context, sys tsc.System, input, output string) tsc.ExitStatus { + ctx = format.WithFormatCodeSettings(ctx, lsutil.GetDefaultFormatCodeSettings(), "\n") input = string(tspath.ToPath(input, sys.GetCurrentDirectory(), sys.FS().UseCaseSensitiveFileNames())) output = string(tspath.ToPath(output, sys.GetCurrentDirectory(), sys.FS().UseCaseSensitiveFileNames())) fileContent, ok := sys.FS().ReadFile(input) @@ -243,6 +243,7 @@ func tscCompilation(ctx context.Context, sys tsc.System, commandLine *tsoptions. return tsc.CommandLineResult{Status: tsc.ExitStatusSuccess, Watcher: watcher} } else if configForCompilation.CompilerOptions().IsIncremental() { return performIncrementalCompilation( + ctx, sys, configForCompilation, reportDiagnostic, @@ -253,6 +254,7 @@ func tscCompilation(ctx context.Context, sys tsc.System, commandLine *tsoptions. ) } return performCompilation( + ctx, sys, configForCompilation, reportDiagnostic, @@ -282,6 +284,7 @@ func getTraceFromSys(sys tsc.System, locale locale.Locale, testing tsc.CommandLi } func performIncrementalCompilation( + ctx context.Context, sys tsc.System, config *tsoptions.ParsedCommandLine, reportDiagnostic tsc.DiagnosticReporter, @@ -307,7 +310,7 @@ func performIncrementalCompilation( changesComputeStart := sys.Now() incrementalProgram := incremental.NewProgram(program, oldProgram, incremental.CreateHost(host), sys.Now, testing != nil) compileTimes.ChangesComputeTime = sys.Now().Sub(changesComputeStart) - result, _ := tsc.EmitAndReportStatistics(tsc.EmitInput{ + result, _ := tsc.EmitAndReportStatistics(ctx, tsc.EmitInput{ Sys: sys, ProgramLike: incrementalProgram, Program: incrementalProgram.GetProgram(), @@ -331,6 +334,7 @@ func performIncrementalCompilation( } func performCompilation( + ctx context.Context, sys tsc.System, config *tsoptions.ParsedCommandLine, reportDiagnostic tsc.DiagnosticReporter, @@ -350,7 +354,7 @@ func performCompilation( Tracing: tr, }) compileTimes.ParseTime = sys.Now().Sub(parseStart) - result, _ := tsc.EmitAndReportStatistics(tsc.EmitInput{ + result, _ := tsc.EmitAndReportStatistics(ctx, tsc.EmitInput{ Sys: sys, ProgramLike: program, Program: program, diff --git a/internal/execute/tsc/compile.go b/internal/execute/tsc/compile.go index 4f69345efaa..aa771bd0185 100644 --- a/internal/execute/tsc/compile.go +++ b/internal/execute/tsc/compile.go @@ -36,6 +36,7 @@ const ( ExitStatusInvalidProject_OutputsSkipped ExitStatus = 3 ExitStatusProjectReferenceCycle_OutputsSkipped ExitStatus = 4 ExitStatusNotImplemented ExitStatus = 5 + ExitStatusCanceled ExitStatus = 6 ) type Watcher interface { diff --git a/internal/execute/tsc/emit.go b/internal/execute/tsc/emit.go index 88e03f7a19e..89e26a6f64e 100644 --- a/internal/execute/tsc/emit.go +++ b/internal/execute/tsc/emit.go @@ -43,11 +43,11 @@ type EmitInput struct { Tracing *tracing.Tracing } -func EmitAndReportStatistics(input EmitInput) (CompileAndEmitResult, *Statistics) { +func EmitAndReportStatistics(ctx context.Context, input EmitInput) (CompileAndEmitResult, *Statistics) { var statistics *Statistics - result := EmitFilesAndReportErrors(input) - if result.Status != ExitStatusSuccess { - // compile exited early + result := EmitFilesAndReportErrors(ctx, input) + if result.Status != ExitStatusSuccess || result.EmitResult == nil { + // compile exited early (e.g. errors or cancellation); EmitResult may be nil return result, nil } result.times.totalTime = input.Sys.SinceStart() @@ -71,9 +71,8 @@ func EmitAndReportStatistics(input EmitInput) (CompileAndEmitResult, *Statistics return result, statistics } -func EmitFilesAndReportErrors(input EmitInput) (result CompileAndEmitResult) { +func EmitFilesAndReportErrors(ctx context.Context, input EmitInput) (result CompileAndEmitResult) { result.times = input.CompileTimes - ctx := context.Background() allDiagnostics := compiler.GetDiagnosticsOfAnyProgram( ctx, @@ -112,6 +111,12 @@ func EmitFilesAndReportErrors(input EmitInput) (result CompileAndEmitResult) { }, ) + // The diagnostics above are incomplete when canceled; do not emit or report them. + if ctx.Err() != nil { + result.Status = ExitStatusCanceled + return result + } + emitResult := &compiler.EmitResult{EmitSkipped: true, Diagnostics: []*ast.Diagnostic{}} if !input.ProgramLike.Options().ListFilesOnly.IsTrue() { emitStart := input.Sys.Now() @@ -119,6 +124,12 @@ func EmitFilesAndReportErrors(input EmitInput) (result CompileAndEmitResult) { WriteFile: input.WriteFile, }) result.times.emitTime += input.Sys.Now().Sub(emitStart) + // Emit returns nil if canceled partway through (e.g. during its internal + // no-emit-on-error recheck), which must not be reported as success. + if ctx.Err() != nil { + result.Status = ExitStatusCanceled + return result + } } if emitResult != nil { allDiagnostics = append(allDiagnostics, emitResult.Diagnostics...) diff --git a/internal/execute/tsc/emit_test.go b/internal/execute/tsc/emit_test.go index f2ac8b50491..34c4a5dfef5 100644 --- a/internal/execute/tsc/emit_test.go +++ b/internal/execute/tsc/emit_test.go @@ -132,7 +132,7 @@ export const make = (): Box => ({ value: "ok" }); } incrementalProgram := incremental.NewProgram(program, oldProgram, incremental.CreateHost(host), clock.NestedEmitNow, false) times := &CompileTimes{} - EmitFilesAndReportErrors(EmitInput{ + EmitFilesAndReportErrors(t.Context(), EmitInput{ Sys: sys, ProgramLike: incrementalProgram, Program: program, diff --git a/internal/execute/tsctests/runner.go b/internal/execute/tsctests/runner.go index 38c7e9c2089..7653905cbf2 100644 --- a/internal/execute/tsctests/runner.go +++ b/internal/execute/tsctests/runner.go @@ -57,6 +57,8 @@ func (test *tscInput) executeCommand(sys *TestSys, baselineBuilder *strings.Buil baselineBuilder.WriteString("ExitStatus:: ProjectReferenceCycle_OutputsSkipped") case tsc.ExitStatusNotImplemented: baselineBuilder.WriteString("ExitStatus:: NotImplemented") + case tsc.ExitStatusCanceled: + baselineBuilder.WriteString("ExitStatus:: Canceled") default: panic(fmt.Sprintf("UnknownExitStatus %d", result.Status)) } diff --git a/internal/execute/tsctests/tsccancel_test.go b/internal/execute/tsctests/tsccancel_test.go new file mode 100644 index 00000000000..9dd676f50af --- /dev/null +++ b/internal/execute/tsctests/tsccancel_test.go @@ -0,0 +1,373 @@ +package tsctests + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/microsoft/typescript-go/internal/execute" + "github.com/microsoft/typescript-go/internal/execute/tsc" +) + +// cancelAfterNPolls is a context that cancels itself after Err has been polled +// pollThreshold times, then stays canceled. The checker polls between top-level +// statements (checkSourceElements), so the threshold selects how far into checking +// the cancellation lands -- something a pre-canceled context cannot exercise. +type cancelAfterNPolls struct { + context.Context + pollThreshold int32 + polls atomic.Int32 + tripped atomic.Bool + done chan struct{} +} + +func newCancelAfterNPolls(pollThreshold int32) *cancelAfterNPolls { + return &cancelAfterNPolls{ + Context: context.Background(), + pollThreshold: pollThreshold, + done: make(chan struct{}), + } +} + +func (c *cancelAfterNPolls) Err() error { + if c.tripped.Load() { + return context.Canceled + } + if c.polls.Add(1) > c.pollThreshold { + if c.tripped.CompareAndSwap(false, true) { + close(c.done) + } + return context.Canceled + } + return nil +} + +func (c *cancelAfterNPolls) Done() <-chan struct{} { return c.done } + +// TestTscCancellationAborts verifies that a canceled compile aborts with +// ExitStatusCanceled and never reports its incomplete diagnostics. Without the +// checker-reuse guards, the mid-check cases panic in checkNotCanceled -- a canceled +// checker fed more files, or asked for global/declaration diagnostics again. +func TestTscCancellationAborts(t *testing.T) { + t.Parallel() + + // Many distinctly-named statements per file, so a mid-check cancellation reliably + // lands while checking. The type errors are the signal: reported means the checker + // ran to completion, absent means it aborted. + var bad, inferred strings.Builder + for i := range 50 { + x := strings.Repeat("x", i+1) + bad.WriteString("export const v") + bad.WriteString(x) + bad.WriteString(`: number = "not a number";` + "\n") + // Inferred return types force type serialization during declaration emit + // (SerializeReturnTypeForSignature -> node reuse -> checkNotCanceled), a + // distinct checker-reuse path from plain semantic checking. + inferred.WriteString("export function make") + inferred.WriteString(x) + inferred.WriteString("() { return { a: 1, b: 'x', deep: [1, 2, 3] as const }; }\n") + } + badSrc, inferredSrc := bad.String(), inferred.String() + + testCases := []struct { + name string + args []string + tsconfig string + src string + midCheck bool // cancel during checking rather than before the compile starts + }{ + { + name: "pre-canceled single file", + args: []string{"--noEmit"}, + tsconfig: `{ "compilerOptions": { "noEmit": true, "strict": true } }`, + src: badSrc, + }, + { + name: "pre-canceled build mode", + args: []string{"-b"}, + tsconfig: `{ "compilerOptions": { "composite": true, "strict": true } }`, + src: badSrc, + }, + { + // One checker for all files, so forEachCheckerGroupDo must stop feeding it + // files after it cancels on an early one. + name: "mid-check single checker", + args: []string{"--noEmit", "--singleThreaded"}, + tsconfig: `{ "compilerOptions": { "noEmit": true, "strict": true } }`, + src: badSrc, + midCheck: true, + }, + { + // tsc -b through the incremental program + orchestrator, which also reaches + // GetGlobalDiagnostics from emitBuildInfo -> ensureHasErrorsForState. + name: "mid-check build mode", + args: []string{"-b"}, + tsconfig: `{ "compilerOptions": { "composite": true, "strict": true } }`, + src: badSrc, + midCheck: true, + }, + { + // A canceled checker must not be handed to GetDeclarationDiagnostics. + name: "mid-check declaration emit", + args: []string{"--noEmit", "--declaration", "--singleThreaded"}, + tsconfig: `{ "compilerOptions": { "noEmit": true, "declaration": true, "strict": true } }`, + src: inferredSrc, + midCheck: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + sys := newTestSys(&tscInput{ + commandLineArgs: tc.args, + files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": tc.tsconfig, + "/home/src/workspaces/project/a.ts": tc.src, + "/home/src/workspaces/project/b.ts": tc.src, + "/home/src/workspaces/project/c.ts": tc.src, + }, + }, false) + + result, midChecked := runWithCancellation(t, sys, tc.args, tc.midCheck) + + if result.Status != tsc.ExitStatusCanceled { + t.Errorf("status = %v, want ExitStatusCanceled", result.Status) + } + if out := sys.getOutput(true); strings.Contains(out, "error TS") { + t.Errorf("expected no diagnostics after cancellation; got output:\n%s", out) + } + // A mid-check case that never tripped isn't testing what it claims. + if tc.midCheck && !midChecked { + t.Error("expected cancellation to trip during checking, but it never did") + } + }) + } +} + +// runWithCancellation runs the command line under a context canceled either before +// the run starts (midCheck false) or after checking has begun (midCheck true). The +// returned bool reports whether a mid-check cancellation actually tripped. +func runWithCancellation(t *testing.T, sys *TestSys, args []string, midCheck bool) (tsc.CommandLineResult, bool) { + t.Helper() + var ( + ctx context.Context + midChecked func() bool + ) + if midCheck { + // Small relative to the statement count, so cancellation lands well before + // checking finishes. + c := newCancelAfterNPolls(5) + ctx, midChecked = c, c.tripped.Load + } else { + canceled, cancel := context.WithCancel(context.Background()) + cancel() + ctx, midChecked = canceled, func() bool { return false } + } + + resultCh := make(chan tsc.CommandLineResult, 1) + go func() { + resultCh <- execute.CommandLine(ctx, sys, args, sys) + }() + select { + case result := <-resultCh: + return result, midChecked() + case <-time.After(30 * time.Second): + t.Fatal("compile did not abort after cancellation") + return tsc.CommandLineResult{}, false + } +} + +// runPreCanceled runs the command line on an existing sys under an already-canceled +// context. +func runPreCanceled(t *testing.T, sys *TestSys, args []string) tsc.CommandLineResult { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + resultCh := make(chan tsc.CommandLineResult, 1) + go func() { + resultCh <- execute.CommandLine(ctx, sys, args, sys) + }() + select { + case result := <-resultCh: + return result + case <-time.After(30 * time.Second): + t.Fatal("run did not abort after cancellation") + return tsc.CommandLineResult{} + } +} + +// TestTscBuildCancellationUpToDate verifies that `tsc -b` honors an interrupt even +// with nothing to build. An up-to-date root has no upstream to wait on, so the +// no-build path is where the interrupt would otherwise be swallowed as success. +func TestTscBuildCancellationUpToDate(t *testing.T) { + t.Parallel() + files := FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{ "compilerOptions": { "composite": true } }`, + "/home/src/workspaces/project/a.ts": "export const a = 1;\n", + } + sys := newTestSys(&tscInput{ + commandLineArgs: []string{"-b"}, + files: files, + }, false) + + // First build to success so the project is up to date on the next run. + if result := execute.CommandLine(context.Background(), sys, []string{"-b"}, sys); result.Status != tsc.ExitStatusSuccess { + t.Fatalf("initial build status = %v, want ExitStatusSuccess", result.Status) + } + + result := runPreCanceled(t, sys, []string{"-b"}) + if result.Status != tsc.ExitStatusCanceled { + t.Errorf("status = %v, want ExitStatusCanceled", result.Status) + } +} + +// TestTscBuildCancellationDuringGraph verifies that an interrupt during graph +// construction stops resolving configs and reports ExitStatusCanceled, rather than +// building from the partial graph or panicking on a task that was never created. +func TestTscBuildCancellationDuringGraph(t *testing.T) { + t.Parallel() + + // Each project references the next, so graph construction has several levels to + // resolve before it completes. + files := FileMap{} + const projects = 8 + for i := range projects { + dir := fmt.Sprintf("/home/src/workspaces/project/p%d", i) + references := "" + if i+1 < projects { + references = fmt.Sprintf(`, "references": [{ "path": "../p%d" }]`, i+1) + } + files[dir+"/tsconfig.json"] = fmt.Sprintf( + `{ "compilerOptions": { "composite": true }%s }`, references) + files[dir+fmt.Sprintf("/p%d.ts", i)] = "export {}\n" + } + + args := []string{"-b", "/home/src/workspaces/project/p0"} + + // Sweep rather than fix one threshold, so the graph phase keeps being exercised + // as the number of polls before it shifts. + for threshold := int32(0); threshold <= 20; threshold++ { + sys := newTestSys(&tscInput{commandLineArgs: args, files: files}, false) + ctx := newCancelAfterNPolls(threshold) + + var result tsc.CommandLineResult + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("threshold=%d: panicked (want clean abort): %v", threshold, r) + } + }() + result = execute.CommandLine(ctx, sys, args, sys) + }() + + if ctx.tripped.Load() { + if result.Status != tsc.ExitStatusCanceled { + t.Fatalf("threshold=%d: status = %v, want ExitStatusCanceled", threshold, result.Status) + } + } else if result.Status != tsc.ExitStatusSuccess { + t.Fatalf("threshold=%d: status = %v, want ExitStatusSuccess (cancellation never tripped)", threshold, result.Status) + } + } +} + +// TestTscCleanCancellation verifies that `tsc -b --clean` interrupted before it runs +// does not delete outputs and reports ExitStatusCanceled instead of success. +func TestTscCleanCancellation(t *testing.T) { + t.Parallel() + const outFile = "/home/src/workspaces/project/a.js" + files := FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{ "compilerOptions": { "composite": true } }`, + "/home/src/workspaces/project/a.ts": "export const a = 1;\n", + } + sys := newTestSys(&tscInput{ + commandLineArgs: []string{"-b"}, + files: files, + }, false) + + // Build first so there is an output for clean to delete. + if result := execute.CommandLine(context.Background(), sys, []string{"-b"}, sys); result.Status != tsc.ExitStatusSuccess { + t.Fatalf("initial build status = %v, want ExitStatusSuccess", result.Status) + } + if !sys.FS().FileExists(outFile) { + t.Fatalf("expected %s to exist after build", outFile) + } + + result := runPreCanceled(t, sys, []string{"-b", "--clean"}) + if result.Status != tsc.ExitStatusCanceled { + t.Errorf("status = %v, want ExitStatusCanceled", result.Status) + } + if !sys.FS().FileExists(outFile) { + t.Errorf("expected %s to survive a canceled clean", outFile) + } +} + +// TestTscCancellationSweep steps the cancellation point across the whole compile, +// past checking and into emit, asserting the run never panics or reports a partial +// result as success. A single fixed threshold would miss the narrow windows: the +// no-emit-on-error recheck, the emit-returns-nil path, and declaration-emit type +// serialization. +func TestTscCancellationSweep(t *testing.T) { + t.Parallel() + + configs := []struct { + name string + tsconfig string + }{ + { + // noEmitOnError + incremental: emit performs the internal no-emit-on-error + // recheck, the path where a mid-emit cancellation makes Emit return nil. + name: "incremental noEmitOnError", + tsconfig: `{ "compilerOptions": { "outDir": "out", "incremental": true, "noEmitOnError": true, "strict": true } }`, + }, + { + // declaration emit serializes inferred types via the checker during emit. + name: "declaration", + tsconfig: `{ "compilerOptions": { "outDir": "out", "declaration": true, "noEmitOnError": true, "strict": true } }`, + }, + } + + for _, cfg := range configs { + t.Run(cfg.name, func(t *testing.T) { + t.Parallel() + files := FileMap{ + "/home/src/workspaces/project/tsconfig.json": cfg.tsconfig, + "/home/src/workspaces/project/a.ts": "export const a = 1;\nexport function f() { return { x: 1, y: 'z' }; }\n", + "/home/src/workspaces/project/b.ts": "export const b = 2;\nexport function g() { return [1, 2, 3] as const; }\n", + } + + // The bound exceeds a full run's poll count for this project, so the sweep + // reaches check, the second global-diagnostics pass, and emit. + for threshold := int32(1); threshold <= 150; threshold++ { + sys := newTestSys(&tscInput{ + commandLineArgs: []string{"--singleThreaded"}, + files: files, + }, false) + ctx := newCancelAfterNPolls(threshold) + + var result tsc.CommandLineResult + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("threshold=%d: panicked (want clean abort): %v", threshold, r) + } + }() + result = execute.CommandLine(ctx, sys, []string{"--singleThreaded"}, sys) + }() + + // Success is only legitimate if the build outran the cancellation; + // anything else means partial state leaked out. + if ctx.tripped.Load() { + if result.Status != tsc.ExitStatusCanceled { + t.Fatalf("threshold=%d: status = %v, want ExitStatusCanceled", threshold, result.Status) + } + } else if result.Status != tsc.ExitStatusSuccess { + t.Fatalf("threshold=%d: status = %v, want ExitStatusSuccess (cancellation never tripped)", threshold, result.Status) + } + } + }) + } +} diff --git a/internal/execute/tsctests/watcher_race_test.go b/internal/execute/tsctests/watcher_race_test.go index fbce8019218..daa31566ea5 100644 --- a/internal/execute/tsctests/watcher_race_test.go +++ b/internal/execute/tsctests/watcher_race_test.go @@ -286,8 +286,10 @@ func TestBuildWatchStopsWhenContextIsCancelled(t *testing.T) { select { case result := <-resultCh: + // Success, because cancellation is the expected way to end a watch; no watcher, + // because the initial build aborted before establishing one. assert.Equal(t, result.Status, tsc.ExitStatusSuccess) - assert.Assert(t, result.Watcher != nil) + assert.Assert(t, result.Watcher == nil) case <-time.After(2 * time.Second): t.Fatal("build watch did not stop after context cancellation") } diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go index af2aaa481c7..5e8f0e7224d 100644 --- a/internal/execute/watcher.go +++ b/internal/execute/watcher.go @@ -527,7 +527,8 @@ func (w *Watcher) evictChangedSourceFiles(changedPaths map[string]fswatch.EventK } func (w *Watcher) compileAndEmit() tsc.CompileAndEmitResult { - return tsc.EmitFilesAndReportErrors(tsc.EmitInput{ + // TODO: propagate a proper context here to better support cancellation + return tsc.EmitFilesAndReportErrors(context.Background(), tsc.EmitInput{ Sys: w.sys, ProgramLike: w.program, Program: w.program.GetProgram(),