From 5d630d279e655538eeadd16932c0ff2c724a7e51 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:39:44 -0700 Subject: [PATCH 1/4] Allow API shutdown while input is blocked Context cancellation must let the API session release its resources even when stdin is blocked or no pipe client has connected. Closing stdin is not sufficient to interrupt every OS read, so shutdown must not depend on that read completing. --- tsc/internal/api/server.go | 70 +++++++++++++++++++++++++++++++-- tsc/internal/api/server_test.go | 39 ++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/tsc/internal/api/server.go b/tsc/internal/api/server.go index 3a0996ed79aed..1e6d3eecbc04d 100644 --- a/tsc/internal/api/server.go +++ b/tsc/internal/api/server.go @@ -61,8 +61,10 @@ func NewStdioServer(options *StdioServerOptions) *StdioServer { } } -// Run starts the server and blocks until the connection closes. +// Run starts the server and blocks until the connection closes or ctx is cancelled. func (s *StdioServer) Run(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() var transport ipc.Transport if s.options.PipePath != "" { t, err := ipc.NewPipeTransport(s.options.PipePath) @@ -76,6 +78,8 @@ func (s *StdioServer) Run(ctx context.Context) error { defer t.Close() transport = t } + stopAccept := context.AfterFunc(ctx, func() { _ = transport.Close() }) + defer stopAccept() fs := bundled.WrapFS(osvfs.FS()) @@ -108,18 +112,21 @@ func (s *StdioServer) Run(ctx context.Context) error { // Accept connection from transport rwc, err := transport.Accept() if err != nil { - return fmt.Errorf("failed to accept connection: %w", err) + return serverRunError(ctx, fmt.Errorf("failed to accept connection: %w", err)) } + defer rwc.Close() + stopConnection := context.AfterFunc(ctx, func() { _ = rwc.Close() }) + defer stopConnection() // Create protocol and connection based on async mode var conn ipc.Conn if s.options.Async { - protocol := ipc.NewJSONRPCProtocol(rwc) + protocol := newCancellableProtocol(ctx, ipc.NewJSONRPCProtocol(rwc)) asyncConn := ipc.NewAsyncConnWithProtocol(rwc, protocol, session) asyncConn.SetCollectTiming(s.options.CollectTiming) conn = asyncConn } else { - protocol := NewMessagePackProtocol(rwc) + protocol := newCancellableProtocol(ctx, NewMessagePackProtocol(rwc)) syncConn := ipc.NewSyncConn(rwc, protocol, session) syncConn.SetCollectTiming(s.options.CollectTiming) conn = syncConn @@ -134,6 +141,61 @@ func (s *StdioServer) Run(ctx context.Context) error { return serverRunError(ctx, conn.Run(ctx)) } +type protocolReadResult struct { + message *ipc.Message + err error +} + +type cancellableProtocol struct { + ipc.Protocol + ctx context.Context + requests chan struct{} + results chan protocolReadResult +} + +func newCancellableProtocol(ctx context.Context, protocol ipc.Protocol) *cancellableProtocol { + p := &cancellableProtocol{ + Protocol: protocol, + ctx: ctx, + requests: make(chan struct{}), + results: make(chan protocolReadResult), + } + go func() { + for { + select { + case <-ctx.Done(): + return + case <-p.requests: + message, err := protocol.ReadMessage() + select { + case <-ctx.Done(): + return + case p.results <- protocolReadResult{message: message, err: err}: + continue + } + } + } + }() + return p +} + +// Stdin reads may remain blocked even after Close. Keep that read separate from +// request handling so cancellation can finish handlers and close the session. +// Reads are demand-driven because synchronous callbacks also read responses. +func (p *cancellableProtocol) ReadMessage() (*ipc.Message, error) { + select { + case <-p.ctx.Done(): + return nil, p.ctx.Err() + case p.requests <- struct{}{}: + select { + case <-p.ctx.Done(): + return nil, p.ctx.Err() + case result := <-p.results: + return result.message, result.err + } + } +} + func serverRunError(ctx context.Context, err error) error { if ctx.Err() != nil { return nil diff --git a/tsc/internal/api/server_test.go b/tsc/internal/api/server_test.go index 00607cdde5c90..ffbeedf2659f4 100644 --- a/tsc/internal/api/server_test.go +++ b/tsc/internal/api/server_test.go @@ -4,10 +4,49 @@ import ( "context" "errors" "testing" + "time" + "github.com/microsoft/TypeScript/tsc/internal/ipc" "gotest.tools/v3/assert" ) +type blockingReadProtocol struct { + ipc.Protocol + started chan struct{} + release chan struct{} +} + +func (p *blockingReadProtocol) ReadMessage() (*ipc.Message, error) { + close(p.started) + <-p.release + return nil, errors.New("read released") +} + +func TestCancellableProtocol(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + underlying := &blockingReadProtocol{ + started: make(chan struct{}), + release: make(chan struct{}), + } + defer close(underlying.release) + protocol := newCancellableProtocol(ctx, underlying) + done := make(chan error, 1) + go func() { + _, err := protocol.ReadMessage() + done <- err + }() + <-underlying.started + cancel() + select { + case err := <-done: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(10 * time.Second): + t.Fatal("cancellation did not unblock ReadMessage") + } +} + func TestServerRunError(t *testing.T) { t.Parallel() From 51b7463b2967632750094168982757b1c962718d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:48:20 -0700 Subject: [PATCH 2/4] Coordinate CLI cleanup before signal termination Watch and server shutdown must finish cleanup and flush profiles before preserving signal termination. Competing signal handlers can bypass cleanup or race the final exit, while an inherited ignored signal can prevent termination. Use one CLI-owned shutdown policy, with a second signal forcing exit. Keep subprocess fixtures small enough to exercise this policy reliably under the race detector. --- tsc/cmd/tsc/api.go | 7 +- tsc/cmd/tsc/lsp.go | 6 +- tsc/cmd/tsc/main.go | 51 ++++- tsc/cmd/tsc/sys_unix_test.go | 280 ++++++++++++++++++++++++++++ tsc/internal/osutil/signal_other.go | 10 + tsc/internal/osutil/signal_unix.go | 31 +++ tsc/internal/pprof/pprof.go | 31 +-- tsc/internal/pprof/pprof_test.go | 44 +++++ 8 files changed, 432 insertions(+), 28 deletions(-) create mode 100644 tsc/internal/osutil/signal_other.go create mode 100644 tsc/internal/osutil/signal_unix.go create mode 100644 tsc/internal/pprof/pprof_test.go diff --git a/tsc/cmd/tsc/api.go b/tsc/cmd/tsc/api.go index 71cfe9e944022..fc588747f6920 100644 --- a/tsc/cmd/tsc/api.go +++ b/tsc/cmd/tsc/api.go @@ -5,9 +5,7 @@ import ( "flag" "fmt" "os" - "os/signal" "strings" - "syscall" "github.com/microsoft/TypeScript/tsc/internal/api" "github.com/microsoft/TypeScript/tsc/internal/bundled" @@ -42,7 +40,7 @@ func parseAPIFlags(args []string) (apiFlags, error) { return result, nil } -func runAPI(args []string) int { +func runAPI(ctx context.Context, args []string) int { flags, err := parseAPIFlags(args) if err != nil { return 2 @@ -77,9 +75,6 @@ func runAPI(args []string) int { s := api.NewStdioServer(options) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - if err := s.Run(ctx); err != nil { fmt.Fprintln(os.Stderr, err) return 1 diff --git a/tsc/cmd/tsc/lsp.go b/tsc/cmd/tsc/lsp.go index b294ad5688416..a89e7fa016c9e 100644 --- a/tsc/cmd/tsc/lsp.go +++ b/tsc/cmd/tsc/lsp.go @@ -6,8 +6,6 @@ import ( "fmt" "os" "os/exec" - "os/signal" - "syscall" "time" "github.com/microsoft/TypeScript/tsc/internal/bundled" @@ -18,7 +16,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/vfs/osvfs" ) -func runLSP(args []string) int { +func runLSP(ctx context.Context, args []string) int { flag := flag.NewFlagSet("lsp", flag.ContinueOnError) stdio := flag.Bool("stdio", false, "use stdio for communication") pprofDir := flag.String("pprofDir", "", "Generate pprof CPU/memory profiles to the given directory.") @@ -47,7 +45,7 @@ func runLSP(args []string) int { typingsLocation := osvfs.GetGlobalTypingsCacheLocation() cwd := tspath.RootedDirectoryPathFromAbsolute(core.Must(os.Getwd())) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, stop := context.WithCancel(ctx) defer stop() s := lsp.NewServer(&lsp.ServerOptions{ diff --git a/tsc/cmd/tsc/main.go b/tsc/cmd/tsc/main.go index cbe3f0f4e567b..3db23af2484bf 100644 --- a/tsc/cmd/tsc/main.go +++ b/tsc/cmd/tsc/main.go @@ -17,17 +17,60 @@ func main() { func runMain() int { core.ApplyDebugStackLimit() + return runWithSignals(run) +} + +// The first signal requests cancellation and waits for the command's cleanup, +// including profile flushing. A second signal terminates without waiting. +func runWithSignals(run func(context.Context) int) int { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + signals := make(chan os.Signal, 2) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signals) + + done := make(chan int, 1) + go func() { + done <- run(ctx) + }() + + var interrupted os.Signal + for { + select { + case sig := <-signals: + if interrupted != nil { + osutil.ExitWithSignal(sig) + } + interrupted = sig + cancel() + case status := <-done: + cancel() + signal.Stop(signals) + if interrupted == nil { + // Account for a signal queued as the command finished. + select { + case sig := <-signals: + interrupted = sig + default: + return status + } + } + osutil.ExitWithSignal(interrupted) + return status + } + } +} + +func run(ctx context.Context) int { args := osutil.Args()[1:] if len(args) > 0 { switch args[0] { case "--lsp": - return runLSP(args[1:]) + return runLSP(ctx, args[1:]) case "--api": - return runAPI(args[1:]) + return runAPI(ctx, args[1:]) } } - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() result := execute.CommandLine(ctx, newSystem(), args, nil) return int(result.Status) } diff --git a/tsc/cmd/tsc/sys_unix_test.go b/tsc/cmd/tsc/sys_unix_test.go index c2a52b69575fd..84f2b3fc983e8 100644 --- a/tsc/cmd/tsc/sys_unix_test.go +++ b/tsc/cmd/tsc/sys_unix_test.go @@ -5,18 +5,66 @@ package main import ( "bufio" "bytes" + "compress/gzip" + "context" + "errors" "fmt" + "io" "os" "os/exec" + "os/signal" + "path/filepath" "strconv" "strings" "syscall" "testing" "time" + "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/osutil" "gotest.tools/v3/assert" ) +func TestMain(m *testing.M) { + if os.Getenv("TSGO_IGNORE_INTERRUPT") != "" { + signal.Ignore(syscall.SIGINT) + if err := os.Unsetenv("TSGO_IGNORE_INTERRUPT"); err != nil { + panic(err) + } + executable, err := osutil.Executable() + if err != nil { + panic(err) + } + if err := syscall.Exec(executable, os.Args, os.Environ()); err != nil { + panic(err) + } + } + if args := os.Getenv("TSGO_COMMAND_LINE_HELPER"); args != "" { + var commandLine []string + if err := json.Unmarshal([]byte(args), &commandLine); err != nil { + panic(err) + } + os.Args = append([]string{os.Args[0]}, commandLine...) + os.Exit(runMain()) + } + if mode := os.Getenv("TSGO_TERMINATION_HELPER"); mode != "" { + os.Exit(runWithSignals(func(ctx context.Context) int { + if mode == "normal" { + return 42 + } + defer fmt.Println("cleanup complete") + fmt.Println("ready") + <-ctx.Done() + fmt.Println("cancelled") + if _, err := io.ReadFull(os.Stdin, make([]byte, 1)); err != nil { + panic(err) + } + return 0 + })) + } + os.Exit(m.Run()) +} + func TestChildProcessCloseDoesNotWaitForLauncherDescendants(t *testing.T) { const ( launcherArg = "child-process-launcher" @@ -67,3 +115,235 @@ func TestChildProcessCloseDoesNotWaitForLauncherDescendants(t *testing.T) { _ = syscall.Kill(descendantPID, syscall.SIGKILL) } } + +func TestCommandLineTermination(t *testing.T) { + t.Parallel() + + executable, executableErr := osutil.Executable() + assert.NilError(t, executableErr) + + for _, test := range []struct { + name string + args string + profiled bool + ignoreInterrupt bool + lsp bool + api bool + syncAPI bool + pipeAPI bool + }{ + {name: "watch", args: "--watch --project tsconfig.json"}, + {name: "buildWatch", args: "--build --watch tsconfig.json"}, + {name: "watchProfile", args: "--watch --project tsconfig.json", profiled: true}, + {name: "buildWatchProfile", args: "--build --watch tsconfig.json", profiled: true}, + {name: "watchIgnoredInterrupt", args: "--watch --project tsconfig.json", ignoreInterrupt: true}, + {name: "watchProfileIgnoredInterrupt", args: "--watch --project tsconfig.json", profiled: true, ignoreInterrupt: true}, + {name: "lsp", args: "--lsp --stdio", lsp: true}, + {name: "lspProfile", args: "--lsp --stdio", lsp: true, profiled: true}, + {name: "apiAsync", args: "--api --async", api: true}, + {name: "apiSync", args: "--api", api: true, syncAPI: true}, + {name: "apiWaitingForConnection", args: "--api --pipe", pipeAPI: true}, + } { + for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGTERM} { + t.Run(fmt.Sprintf("%s/%s", test.name, sig), func(t *testing.T) { + t.Parallel() + + projectDir := t.TempDir() + assert.NilError(t, os.WriteFile(filepath.Join(projectDir, "index.ts"), []byte("export const value = 1;\n"), 0o666)) + // Exercise termination without loading and checking libraries in every subprocess. + assert.NilError(t, os.WriteFile(filepath.Join(projectDir, "tsconfig.json"), []byte(`{"compilerOptions":{"pretty":false,"noLib":true,"noCheck":true},"files":["index.ts"]}`), 0o666)) + + output, outputErr := os.CreateTemp(t.TempDir(), "watch-output") + assert.NilError(t, outputErr) + defer output.Close() + + args := strings.Fields(test.args) + pipePath := filepath.Join(projectDir, "api.sock") + if test.pipeAPI { + args = append(args, pipePath) + } + if test.profiled { + args = append(args, "--pprofDir", filepath.Join(projectDir, "profiles")) + } + encodedArgs, err := json.Marshal(args) + assert.NilError(t, err) + cmd := exec.Command(executable, "-test.run=^TestCommandLineTermination$") + cmd.Dir = projectDir + cmd.Env = append(os.Environ(), "TSGO_COMMAND_LINE_HELPER="+string(encodedArgs)) + if test.ignoreInterrupt { + cmd.Env = append(cmd.Env, "TSGO_IGNORE_INTERRUPT=1") + } + cmd.Stdout = output + cmd.Stderr = output + stdin, err := cmd.StdinPipe() + assert.NilError(t, err) + defer stdin.Close() + assert.NilError(t, cmd.Start()) + t.Cleanup(func() { + if cmd.ProcessState == nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + }) + + if test.pipeAPI { + deadline := time.Now().Add(10 * time.Second) + for { + _, err := os.Stat(pipePath) + if err == nil { + break + } + assert.Assert(t, os.IsNotExist(err), "stat pipe: %v", err) + if time.Now().After(deadline) { + t.Fatal("timed out waiting for API pipe") + } + time.Sleep(10 * time.Millisecond) + } + } else if test.lsp || test.api { + request := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":null,"rootUri":null,"capabilities":{}}}` + expected := `"capabilities"` + if test.api { + request = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` + expected = `"currentDirectory"` + } + if test.syncAPI { + // MessagePack request tuple: [1, "initialize", "{}"]. + _, err = io.WriteString(stdin, "\x93\x01\xc4\x0ainitialize\xc4\x02{}") + } else { + _, err = fmt.Fprintf(stdin, "Content-Length: %d\r\n\r\n%s", len(request), request) + } + assert.NilError(t, err) + waitForWatchOutput(t, output.Name(), expected) + } else { + waitForWatchOutput(t, output.Name(), "Watching for file changes.") + } + assert.NilError(t, cmd.Process.Signal(sig)) + status := waitForSignalExit(t, cmd) + if test.ignoreInterrupt && sig == syscall.SIGINT { + assert.Equal(t, status.ExitStatus(), 128+int(sig)) + } else { + assert.Assert(t, status.Signaled(), "expected signal termination, got %v", status) + assert.Equal(t, status.Signal(), sig) + } + if test.profiled { + profiles, err := filepath.Glob(filepath.Join(projectDir, "profiles", "*.pb.gz")) + assert.NilError(t, err) + assert.Equal(t, len(profiles), 2) + for _, profile := range profiles { + data, err := os.ReadFile(profile) + assert.NilError(t, err) + reader, err := gzip.NewReader(bytes.NewReader(data)) + assert.NilError(t, err) + content, err := io.ReadAll(reader) + assert.NilError(t, err) + assert.NilError(t, reader.Close()) + assert.Assert(t, len(content) > 0, "empty profile: %s", profile) + } + } + }) + } + } +} + +func waitForSignalExit(t *testing.T, cmd *exec.Cmd) syscall.WaitStatus { + t.Helper() + waitDone := make(chan error, 1) + go func() { waitDone <- cmd.Wait() }() + var waitErr error + select { + case result := <-waitDone: + waitErr = result + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + <-waitDone + t.Fatal("timed out waiting for process to terminate") + } + var exitErr *exec.ExitError + if !errors.As(waitErr, &exitErr) { + t.Fatalf("process returned %v instead of terminating", waitErr) + } + return exitErr.ProcessState.Sys().(syscall.WaitStatus) +} + +func TestTerminationCleanup(t *testing.T) { + t.Parallel() + executable, executableErr := osutil.Executable() + assert.NilError(t, executableErr) + for _, test := range []struct { + name string + secondSignal syscall.Signal + ignoreInterrupt bool + }{ + {name: "cleanup"}, + {name: "secondSignal", secondSignal: syscall.SIGTERM}, + {name: "secondIgnoredInterrupt", secondSignal: syscall.SIGINT, ignoreInterrupt: true}, + {name: "normal"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + output, err := os.CreateTemp(t.TempDir(), "termination-output") + assert.NilError(t, err) + defer output.Close() + cmd := exec.Command(executable, "-test.run=^TestTerminationCleanup$") + cmd.Env = append(os.Environ(), "TSGO_TERMINATION_HELPER="+test.name) + if test.ignoreInterrupt { + cmd.Env = append(cmd.Env, "TSGO_IGNORE_INTERRUPT=1") + } + cmd.Stdout = output + cmd.Stderr = output + stdin, err := cmd.StdinPipe() + assert.NilError(t, err) + defer stdin.Close() + assert.NilError(t, cmd.Start()) + t.Cleanup(func() { + if cmd.ProcessState == nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + }) + if test.name == "normal" { + assert.Equal(t, waitForSignalExit(t, cmd).ExitStatus(), 42) + return + } + waitForWatchOutput(t, output.Name(), "ready") + assert.NilError(t, cmd.Process.Signal(syscall.SIGINT)) + waitForWatchOutput(t, output.Name(), "cancelled") + if test.secondSignal != 0 { + assert.NilError(t, cmd.Process.Signal(test.secondSignal)) + } else { + _, writeErr := stdin.Write([]byte{1}) + assert.NilError(t, writeErr) + } + status := waitForSignalExit(t, cmd) + expected := syscall.SIGINT + if test.secondSignal != 0 { + expected = test.secondSignal + } + if test.ignoreInterrupt { + assert.Equal(t, status.ExitStatus(), 128+int(expected)) + } else { + assert.Assert(t, status.Signaled(), "expected signal termination, got %v", status) + assert.Equal(t, status.Signal(), expected) + } + text, err := os.ReadFile(output.Name()) + assert.NilError(t, err) + assert.Equal(t, strings.Contains(string(text), "cleanup complete"), test.secondSignal == 0) + }) + } +} + +func waitForWatchOutput(t *testing.T, path string, expected string) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + output, err := os.ReadFile(path) + assert.NilError(t, err) + if strings.Contains(string(output), expected) { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %q in output:\n%s", expected, output) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/tsc/internal/osutil/signal_other.go b/tsc/internal/osutil/signal_other.go new file mode 100644 index 0000000000000..8ef20ce2dae67 --- /dev/null +++ b/tsc/internal/osutil/signal_other.go @@ -0,0 +1,10 @@ +//go:build !unix + +package osutil + +import "os" + +// ExitWithSignal terminates unsuccessfully on platforms without Unix signals. +func ExitWithSignal(sig os.Signal) { + os.Exit(1) +} diff --git a/tsc/internal/osutil/signal_unix.go b/tsc/internal/osutil/signal_unix.go new file mode 100644 index 0000000000000..6cfd91951638c --- /dev/null +++ b/tsc/internal/osutil/signal_unix.go @@ -0,0 +1,31 @@ +//go:build unix + +package osutil + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" +) + +// ExitWithSignal terminates with sig, falling back to an exit code if the signal +// is ignored or cannot terminate the process. +func ExitWithSignal(sig os.Signal) { + syscallSignal, ok := sig.(syscall.Signal) + if !ok { + fmt.Fprintf(os.Stderr, "Cannot re-raise signal %v\n", sig) + os.Exit(1) + } + signal.Reset(syscallSignal) + if !signal.Ignored(syscallSignal) { + if err := syscall.Kill(os.Getpid(), syscallSignal); err != nil { + fmt.Fprintf(os.Stderr, "Cannot re-raise signal %v: %v\n", sig, err) + } else { + // Signal delivery is asynchronous, but must not leave shutdown stuck. + time.Sleep(time.Second) + } + } + os.Exit(128 + int(syscallSignal)) +} diff --git a/tsc/internal/pprof/pprof.go b/tsc/internal/pprof/pprof.go index bcb68df180ec8..154c9610552ec 100644 --- a/tsc/internal/pprof/pprof.go +++ b/tsc/internal/pprof/pprof.go @@ -17,6 +17,7 @@ type ProfileSession struct { memFilePath string cpuFile *os.File logWriter io.Writer + stopOnce sync.Once } // BeginProfiling starts CPU and memory profiling, writing the profiles to the specified directory. @@ -47,22 +48,24 @@ func BeginProfiling(profileDir string, logWriter io.Writer) *ProfileSession { } func (p *ProfileSession) Stop() { - pprof.StopCPUProfile() - p.cpuFile.Close() - - if p.memFilePath != "" { - memFile, err := os.Create(p.memFilePath) - if err != nil { - panic(err) - } - if err := pprof.Lookup("allocs").WriteTo(memFile, 0); err != nil { - panic(err) + p.stopOnce.Do(func() { + pprof.StopCPUProfile() + p.cpuFile.Close() + + if p.memFilePath != "" { + memFile, err := os.Create(p.memFilePath) + if err != nil { + panic(err) + } + if err := pprof.Lookup("allocs").WriteTo(memFile, 0); err != nil { + panic(err) + } + memFile.Close() + fmt.Fprintf(p.logWriter, "Memory profile: %v\n", p.memFilePath) } - memFile.Close() - fmt.Fprintf(p.logWriter, "Memory profile: %v\n", p.memFilePath) - } - fmt.Fprintf(p.logWriter, "CPU profile: %v\n", p.cpuFilePath) + fmt.Fprintf(p.logWriter, "CPU profile: %v\n", p.cpuFilePath) + }) } // CPUProfiler manages on-demand CPU profiling. diff --git a/tsc/internal/pprof/pprof_test.go b/tsc/internal/pprof/pprof_test.go new file mode 100644 index 0000000000000..525bffb4a7c05 --- /dev/null +++ b/tsc/internal/pprof/pprof_test.go @@ -0,0 +1,44 @@ +package pprof + +import ( + "bytes" + "compress/gzip" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "gotest.tools/v3/assert" +) + +func TestProfileSessionStop(t *testing.T) { //nolint:paralleltest // CPU profiling is process-global. + var output bytes.Buffer + profileDir := t.TempDir() + session := BeginProfiling(profileDir, &output) + t.Cleanup(session.Stop) + + var wg sync.WaitGroup + for range 8 { + wg.Go(session.Stop) + } + wg.Wait() + session.Stop() + + assert.Equal(t, strings.Count(output.String(), "CPU profile:"), 1) + assert.Equal(t, strings.Count(output.String(), "Memory profile:"), 1) + profiles, err := filepath.Glob(filepath.Join(profileDir, "*.pb.gz")) + assert.NilError(t, err) + assert.Equal(t, len(profiles), 2) + for _, profile := range profiles { + data, err := os.ReadFile(profile) + assert.NilError(t, err) + reader, err := gzip.NewReader(bytes.NewReader(data)) + assert.NilError(t, err) + content, err := io.ReadAll(reader) + assert.NilError(t, err) + assert.NilError(t, reader.Close()) + assert.Assert(t, len(content) > 0, "empty profile: %s", profile) + } +} From 05503f09e60c3d3e3b3e3aff3ef249d0a526f734 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:48:20 -0700 Subject: [PATCH 3/4] Propagate cancellation through compiler execution Cancelling the CLI context previously left program loading and other compiler work running, delaying interruption and reporting compilation as though it had completed normally. Use one context-aware program constructor and carry cancellation through loading, diagnostics, emit, and execution callers. Snapshot construction must still complete to preserve cache ownership; failed or cancelled resolver callbacks must reject the resulting snapshot update. --- tsc/cmd/tsc/sys_unix_test.go | 46 ++++++- .../api/session_module_resolution_test.go | 80 +++++++----- tsc/internal/checker/checker_test.go | 9 +- tsc/internal/compiler/checkerpool.go | 10 ++ tsc/internal/compiler/checkerpool_test.go | 54 ++++++++ tsc/internal/compiler/contentmapper_test.go | 4 +- tsc/internal/compiler/emit_test.go | 50 +++++++- tsc/internal/compiler/fileloader.go | 41 ++++-- tsc/internal/compiler/filesparser.go | 51 ++++++-- tsc/internal/compiler/program.go | 119 +++++++++++++++--- tsc/internal/compiler/program_test.go | 88 +++++++++++-- .../compiler/projectreferenceparser.go | 37 ++++-- tsc/internal/execute/build/buildtask.go | 39 ++++-- tsc/internal/execute/build/orchestrator.go | 41 ++++-- tsc/internal/execute/tsc.go | 22 +++- tsc/internal/execute/tsc/compile.go | 3 +- tsc/internal/execute/tsc/emit.go | 52 +++++++- tsc/internal/execute/tsc/emit_test.go | 92 +++++++++++++- .../tsctests/contentmapper_watch_test.go | 24 ++-- tsc/internal/execute/tsctests/runner.go | 4 +- .../execute/tsctests/tscbuild_test.go | 65 ++++++++++ .../execute/tsctests/watch_shallow_test.go | 12 +- .../execute/tsctests/watcher_race_test.go | 79 +++++++++--- tsc/internal/execute/watcher.go | 37 ++++-- .../execute/watchmanager/watchmanager.go | 4 +- tsc/internal/ls/findallreferences_test.go | 3 +- tsc/internal/project/project.go | 15 ++- .../project/projectcollectionbuilder.go | 2 +- tsc/internal/project/refcountcache_test.go | 4 +- .../testutil/harnessutil/harnessutil.go | 17 ++- tsc/internal/transpile/transpile.go | 5 +- 31 files changed, 908 insertions(+), 201 deletions(-) diff --git a/tsc/cmd/tsc/sys_unix_test.go b/tsc/cmd/tsc/sys_unix_test.go index 84f2b3fc983e8..e278cabe553da 100644 --- a/tsc/cmd/tsc/sys_unix_test.go +++ b/tsc/cmd/tsc/sys_unix_test.go @@ -20,8 +20,11 @@ import ( "testing" "time" + "github.com/microsoft/TypeScript/tsc/internal/execute" "github.com/microsoft/TypeScript/tsc/internal/json" "github.com/microsoft/TypeScript/tsc/internal/osutil" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" "gotest.tools/v3/assert" ) @@ -45,6 +48,13 @@ func TestMain(m *testing.M) { panic(err) } os.Args = append([]string{os.Args[0]}, commandLine...) + if os.Getenv("TSGO_ACTIVE_COMPILATION") != "" { + os.Exit(runWithSignals(func(ctx context.Context) int { + sys := newSystem() + sys.fs = &cancellableCompilerFS{FS: sys.fs, ctx: ctx} + return int(execute.CommandLine(ctx, sys, commandLine, nil).Status) + })) + } os.Exit(runMain()) } if mode := os.Getenv("TSGO_TERMINATION_HELPER"); mode != "" { @@ -65,6 +75,19 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } +type cancellableCompilerFS struct { + vfs.FS + ctx context.Context +} + +func (fs *cancellableCompilerFS) ReadFile(path tspath.RootedFilePath) (string, bool) { + if strings.HasSuffix(path.AsString(), "/index.ts") { + fmt.Println("compiler-loading") + <-fs.ctx.Done() + } + return fs.FS.ReadFile(path) +} + func TestChildProcessCloseDoesNotWaitForLauncherDescendants(t *testing.T) { const ( launcherArg = "child-process-launcher" @@ -131,6 +154,7 @@ func TestCommandLineTermination(t *testing.T) { api bool syncAPI bool pipeAPI bool + active bool }{ {name: "watch", args: "--watch --project tsconfig.json"}, {name: "buildWatch", args: "--build --watch tsconfig.json"}, @@ -143,6 +167,16 @@ func TestCommandLineTermination(t *testing.T) { {name: "apiAsync", args: "--api --async", api: true}, {name: "apiSync", args: "--api", api: true, syncAPI: true}, {name: "apiWaitingForConnection", args: "--api --pipe", pipeAPI: true}, + {name: "compileActive", args: "--project tsconfig.json --extendedDiagnostics", active: true}, + {name: "incrementalActive", args: "--project tsconfig.json --incremental --extendedDiagnostics", active: true}, + {name: "buildActive", args: "--build tsconfig.json --extendedDiagnostics", active: true}, + {name: "watchActive", args: "--watch --project tsconfig.json --extendedDiagnostics", active: true}, + {name: "buildWatchActive", args: "--build --watch tsconfig.json --extendedDiagnostics", active: true}, + {name: "compileActiveProfile", args: "--project tsconfig.json --extendedDiagnostics", active: true, profiled: true}, + {name: "incrementalActiveProfile", args: "--project tsconfig.json --incremental --extendedDiagnostics", active: true, profiled: true}, + {name: "buildActiveProfile", args: "--build tsconfig.json --extendedDiagnostics", active: true, profiled: true}, + {name: "watchActiveProfile", args: "--watch --project tsconfig.json --extendedDiagnostics", active: true, profiled: true}, + {name: "buildWatchActiveProfile", args: "--build --watch tsconfig.json --extendedDiagnostics", active: true, profiled: true}, } { for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGTERM} { t.Run(fmt.Sprintf("%s/%s", test.name, sig), func(t *testing.T) { @@ -170,6 +204,9 @@ func TestCommandLineTermination(t *testing.T) { cmd := exec.Command(executable, "-test.run=^TestCommandLineTermination$") cmd.Dir = projectDir cmd.Env = append(os.Environ(), "TSGO_COMMAND_LINE_HELPER="+string(encodedArgs)) + if test.active { + cmd.Env = append(cmd.Env, "TSGO_ACTIVE_COMPILATION=1") + } if test.ignoreInterrupt { cmd.Env = append(cmd.Env, "TSGO_IGNORE_INTERRUPT=1") } @@ -186,7 +223,9 @@ func TestCommandLineTermination(t *testing.T) { } }) - if test.pipeAPI { + if test.active { + waitForWatchOutput(t, output.Name(), "compiler-loading") + } else if test.pipeAPI { deadline := time.Now().Add(10 * time.Second) for { _, err := os.Stat(pipePath) @@ -219,6 +258,11 @@ func TestCommandLineTermination(t *testing.T) { } assert.NilError(t, cmd.Process.Signal(sig)) status := waitForSignalExit(t, cmd) + if test.active { + text, readErr := os.ReadFile(output.Name()) + assert.NilError(t, readErr) + assert.Assert(t, !strings.Contains(string(text), "Total time:"), "cancelled compilation reported completion") + } if test.ignoreInterrupt && sig == syscall.SIGINT { assert.Equal(t, status.ExitStatus(), 128+int(sig)) } else { diff --git a/tsc/internal/api/session_module_resolution_test.go b/tsc/internal/api/session_module_resolution_test.go index a8f72e3a02217..0256e9c173fe3 100644 --- a/tsc/internal/api/session_module_resolution_test.go +++ b/tsc/internal/api/session_module_resolution_test.go @@ -25,6 +25,9 @@ func (c *failingModuleResolutionConn) Run(context.Context) error { func (c *failingModuleResolutionConn) Call(ctx context.Context, _ string, _ any) (json.Value, error) { c.calls++ c.contexts = append(c.contexts, ctx) + if err := ctx.Err(); err != nil { + return nil, err + } return nil, errors.New("callback error") } @@ -288,42 +291,57 @@ func TestModuleResolutionFactoryUsesCurrentContext(t *testing.T) { func TestModuleResolutionCallbackErrorRejectsLanguageServerUpdate(t *testing.T) { t.Parallel() - - projectSession, _ := projecttestutil.Setup(map[string]any{ - "/src/index.ts": `import "pkg";`, - }) - defer projectSession.Close() - session := NewLSPSession(projectSession, nil) - defer session.Close() - session.conn = &failingModuleResolutionConn{} - resolver, err := session.handleCreateModuleResolver(&CreateModuleResolverParams{ - CompilerOptions: core.CompilerOptions{ - NoLib: core.TSTrue, - Module: core.ModuleKindNodeNext, - ModuleResolution: core.ModuleResolutionKindNodeNext, - }, - ResolveModuleNameCallback: "resolveModuleName/1", - }) - assert.NilError(t, err) - baseSnapshot := projectSession.Snapshot() - - _, err = session.handleGetCurrentLanguageServerSnapshot(context.Background(), &GetCurrentLanguageServerSnapshotParams{ - Changes: &LanguageServerSnapshotChanges{SnapshotRequestChangesParams{ - CreatePrograms: []*CreateSnapshotProgramParams{{ - RootFiles: []DocumentIdentifier{{FileName: "/src/index.ts"}}, + for _, name := range []string{"callback error", "cancelled context"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/src/index.ts": `import "pkg";`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + conn := &failingModuleResolutionConn{} + session.conn = conn + resolver, err := session.handleCreateModuleResolver(&CreateModuleResolverParams{ CompilerOptions: core.CompilerOptions{ NoLib: core.TSTrue, Module: core.ModuleKindNodeNext, ModuleResolution: core.ModuleResolutionKindNodeNext, }, - Options: &CreateProgramOptions{ModuleResolver: resolver}, - }}, - }}, - }) - assert.ErrorContains(t, err, "callback error") - assert.Equal(t, len(session.programResolutionContexts), 0) - assert.Assert(t, projectSession.Snapshot() == baseSnapshot) - assert.Equal(t, len(projectSession.Snapshot().ProjectCollection.SyntheticProjects()), 0) + ResolveModuleNameCallback: "resolveModuleName/1", + }) + assert.NilError(t, err) + baseSnapshot := projectSession.Snapshot() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if name == "cancelled context" { + cancel() + } + + _, err = session.handleGetCurrentLanguageServerSnapshot(ctx, &GetCurrentLanguageServerSnapshotParams{ + Changes: &LanguageServerSnapshotChanges{SnapshotRequestChangesParams{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: "/src/index.ts"}}, + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Module: core.ModuleKindNodeNext, + ModuleResolution: core.ModuleResolutionKindNodeNext, + }, + Options: &CreateProgramOptions{ModuleResolver: resolver}, + }}, + }}, + }) + if name == "cancelled context" { + assert.Assert(t, errors.Is(err, context.Canceled)) + } else { + assert.ErrorContains(t, err, "callback error") + } + assert.Equal(t, conn.calls, 1) + assert.Equal(t, len(session.programResolutionContexts), 0) + assert.Assert(t, projectSession.Snapshot() == baseSnapshot) + assert.Equal(t, len(projectSession.Snapshot().ProjectCollection.SyntheticProjects()), 0) + }) + } } func staticResolutionEntry(moduleName string, directory string, mode *core.ModuleKind, fileName string) *ModuleResolutionEntry { diff --git a/tsc/internal/checker/checker_test.go b/tsc/internal/checker/checker_test.go index 2ed189904d64b..42a316684d5b8 100644 --- a/tsc/internal/checker/checker_test.go +++ b/tsc/internal/checker/checker_test.go @@ -40,10 +40,11 @@ foo.bar;` parsed, errors := tsoptions.GetParsedCommandLineOfConfigFile("/tsconfig.json", &core.CompilerOptions{}, nil, fs, nil) assert.Equal(t, len(errors), 0, "Expected no errors in parsed command line") - p := compiler.NewProgram(compiler.ProgramOptions{ + p, err := compiler.NewProgram(t.Context(), compiler.ProgramOptions{ Config: parsed, Host: host, }) + assert.NilError(t, err) p.BindSourceFiles() c, done := p.GetTypeChecker(t.Context()) defer done() @@ -88,10 +89,11 @@ export type E = D;`, parsed, errors := tsoptions.GetParsedCommandLineOfConfigFile("/tsconfig.json", &core.CompilerOptions{}, nil, fs, nil) assert.Equal(t, len(errors), 0, "Expected no errors in parsed command line") - p := compiler.NewProgram(compiler.ProgramOptions{ + p, err := compiler.NewProgram(t.Context(), compiler.ProgramOptions{ Config: parsed, Host: host, }) + assert.NilError(t, err) p.BindSourceFiles() c, done := p.GetTypeChecker(t.Context()) defer done() @@ -121,10 +123,11 @@ func BenchmarkNewChecker(b *testing.B) { host := compiler.NewCompilerHost(fs, bundled.LibPath(), nil, nil, nil) parsed, errors := tsoptions.GetParsedCommandLineOfConfigFile(rootPath.ResolveFile("tsconfig.json"), &core.CompilerOptions{}, nil, fs, nil) assert.Equal(b, len(errors), 0, "Expected no errors in parsed command line") - program := compiler.NewProgram(compiler.ProgramOptions{ + program, err := compiler.NewProgram(b.Context(), compiler.ProgramOptions{ Config: parsed, Host: host, }) + assert.NilError(b, err) b.ReportAllocs() for b.Loop() { diff --git a/tsc/internal/compiler/checkerpool.go b/tsc/internal/compiler/checkerpool.go index e129951e423df..bef3cc80b3215 100644 --- a/tsc/internal/compiler/checkerpool.go +++ b/tsc/internal/compiler/checkerpool.go @@ -474,6 +474,13 @@ func (p *checkerPool) GetGlobalDiagnostics() []*ast.Diagnostic { // the provided files, processing only those assigned to its checker. Within each // checker's set, files are visited in their original order. func (p *checkerPool) forEachCheckerGroupDo(ctx context.Context, files []*ast.SourceFile, singleThreaded bool, cb func(c *checker.Checker, fileIndex int, file *ast.SourceFile)) { + if ctx.Err() != nil { + return + } + p.program.bindSourceFiles(ctx) + if ctx.Err() != nil { + return + } p.createCheckers() checkerCount := len(p.checkers) @@ -483,6 +490,9 @@ func (p *checkerPool) forEachCheckerGroupDo(ctx context.Context, files []*ast.So p.locks[checkerIdx].Lock() defer p.locks[checkerIdx].Unlock() for i, file := range files { + if ctx.Err() != nil { + return + } if checker := p.checkers[checkerIdx]; checker == p.fileAssociations[file] { cb(checker, i, file) } diff --git a/tsc/internal/compiler/checkerpool_test.go b/tsc/internal/compiler/checkerpool_test.go index 213da456891e9..e69e7a764f326 100644 --- a/tsc/internal/compiler/checkerpool_test.go +++ b/tsc/internal/compiler/checkerpool_test.go @@ -1,14 +1,68 @@ package compiler import ( + "context" "slices" "testing" "github.com/microsoft/TypeScript/tsc/internal/ast" + "github.com/microsoft/TypeScript/tsc/internal/checker" + "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" + "gotest.tools/v3/assert" ) +func TestCheckerGroupStopsOnCancellation(t *testing.T) { + t.Parallel() + fs := vfstest.FromMap(map[string]any{ + "/src/a.ts": "export const a = 1;", + "/src/b.ts": "export const b = 2;", + }, tspath.CaseSensitive) + config := tsoptions.NewParsedCommandLine(&core.CompilerOptions{NoLib: core.TSTrue}, []tspath.RootedFilePath{"/src/a.ts", "/src/b.ts"}, nil, "/src", tspath.CaseSensitive) + program, err := NewProgram(t.Context(), ProgramOptions{ + Config: config, + SingleThreaded: core.TSTrue, + Host: NewCompilerHost(fs, "/", nil, nil, nil), + }) + assert.NilError(t, err) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + visited := 0 + program.compilerCheckerPool.forEachCheckerGroupDo(ctx, program.files, true, func(c *checker.Checker, _ int, file *ast.SourceFile) { + visited++ + cancel() + c.GetDiagnostics(ctx, file) + }) + assert.Equal(t, visited, 1) +} + +func TestSemanticDiagnosticsWithCancelledContext(t *testing.T) { + t.Parallel() + for _, singleThreaded := range []core.Tristate{core.TSTrue, core.TSFalse} { + t.Run(singleThreaded.String(), func(t *testing.T) { + t.Parallel() + fs := vfstest.FromMap(map[string]any{ + "/src/a.ts": "export const a: string = 1;", + "/src/b.ts": "export const b: string = 2;", + }, tspath.CaseSensitive) + config := tsoptions.NewParsedCommandLine(&core.CompilerOptions{NoLib: core.TSTrue}, []tspath.RootedFilePath{"/src/a.ts", "/src/b.ts"}, nil, "/src", tspath.CaseSensitive) + program, err := NewProgram(t.Context(), ProgramOptions{ + Config: config, + SingleThreaded: singleThreaded, + Host: NewCompilerHost(fs, "/", nil, nil, nil), + }) + assert.NilError(t, err) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + assert.Equal(t, len(program.GetSemanticDiagnostics(ctx, nil)), 0) + assert.Equal(t, len(program.GetSemanticDiagnostics(t.Context(), nil)), 2) + }) + } +} + func TestGetSourceFileForResolvedModuleUsesResolvedPath(t *testing.T) { t.Parallel() path := tspath.PathKey("/resolved.ts") diff --git a/tsc/internal/compiler/contentmapper_test.go b/tsc/internal/compiler/contentmapper_test.go index 31b8f831870e8..f9fba1a559059 100644 --- a/tsc/internal/compiler/contentmapper_test.go +++ b/tsc/internal/compiler/contentmapper_test.go @@ -58,12 +58,14 @@ func newContentMapperProgramWithOptions(t *testing.T, contentMapperProject conte config := tsoptions.NewParsedCommandLine(options, testFileNames(rootFiles...), nil, "/", fs.CaseSensitivity()) config.ParsedConfig.ContentMappers = []*contentmapper.Mapper{{Package: "vue", Extensions: []string{".vue"}, Name: "vue-mapper", Version: "1.0.0"}} - return compiler.NewProgram(compiler.ProgramOptions{ + program, err := compiler.NewProgram(t.Context(), compiler.ProgramOptions{ Config: config, Host: compiler.NewCompilerHost(fs, bundled.LibPath(), nil, nil, contentMapperProject), // Load files on the calling goroutine for deterministic diagnostics ordering. SingleThreaded: core.TSTrue, }) + assert.NilError(t, err) + return program } func TestContentMapperVirtualExtensionSetsImpliedNodeFormat(t *testing.T) { diff --git a/tsc/internal/compiler/emit_test.go b/tsc/internal/compiler/emit_test.go index 5326c72d018c1..7d1bb829ea011 100644 --- a/tsc/internal/compiler/emit_test.go +++ b/tsc/internal/compiler/emit_test.go @@ -14,6 +14,41 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" ) +func TestEmitStopsWritingOnCancellation(t *testing.T) { + t.Parallel() + fs := vfstest.FromMap(map[string]string{ + "/src/a.ts": "export const a = 1;", + "/src/b.ts": "export const b = 2;", + }, tspath.CaseSensitive) + program, err := compiler.NewProgram(t.Context(), compiler.ProgramOptions{ + Config: tsoptions.NewParsedCommandLine( + &core.CompilerOptions{NoLib: core.TSTrue, Declaration: core.TSTrue, SourceMap: core.TSTrue}, + testFileNames("/src/a.ts", "/src/b.ts"), nil, "/src", tspath.CaseSensitive, + ), + SingleThreaded: core.TSTrue, + Host: compiler.NewCompilerHost(fs, "/", nil, nil, nil), + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + writes := 0 + result := program.Emit(ctx, compiler.EmitOptions{ + WriteFile: func(fileName tspath.RootedFilePath, text string, data *compiler.WriteFileData) error { + writes++ + cancel() + return nil + }, + }) + if writes != 1 { + t.Fatalf("wrote %d files after cancellation; want 1", writes) + } + if result != nil { + t.Fatal("cancelled emit returned a completed result") + } +} + // generateLongLineTS generates TypeScript source code that produces a single very long line. // This simulates generated code (e.g., from code generators) that has no line breaks, // which triggers O(n²) behavior in source map generation due to @@ -54,10 +89,13 @@ func BenchmarkEmitLongLines(b *testing.B) { host := compiler.NewCompilerHost(fs, bundled.LibPath(), nil, nil, nil) - p := compiler.NewProgram(compiler.ProgramOptions{ + p, err := compiler.NewProgram(b.Context(), compiler.ProgramOptions{ Config: tsoptions.NewParsedCommandLine(&opts, testFileNames("/dev/src/index.ts"), nil, "/dev/src", fs.CaseSensitivity()), Host: host, }) + if err != nil { + b.Fatal(err) + } // Discard written files — we only care about emit performance. nopWriteFile := func(fileName tspath.RootedFilePath, text string, data *compiler.WriteFileData) error { @@ -104,10 +142,13 @@ func BenchmarkEmitManyFiles(b *testing.B) { host := compiler.NewCompilerHost(fs, bundled.LibPath(), nil, nil, nil) - p := compiler.NewProgram(compiler.ProgramOptions{ + p, err := compiler.NewProgram(b.Context(), compiler.ProgramOptions{ Config: tsoptions.NewParsedCommandLine(&opts, testFileNames(fileNames...), nil, "/dev/src", fs.CaseSensitivity()), Host: host, }) + if err != nil { + b.Fatal(err) + } nopWriteFile := func(fileName tspath.RootedFilePath, text string, data *compiler.WriteFileData) error { return nil @@ -157,10 +198,13 @@ func BenchmarkEmitLongLinesWithLineBreaks(b *testing.B) { host := compiler.NewCompilerHost(fs, bundled.LibPath(), nil, nil, nil) - p := compiler.NewProgram(compiler.ProgramOptions{ + p, err := compiler.NewProgram(b.Context(), compiler.ProgramOptions{ Config: tsoptions.NewParsedCommandLine(&opts, testFileNames("/dev/src/index.ts"), nil, "/dev/src", fs.CaseSensitivity()), Host: host, }) + if err != nil { + b.Fatal(err) + } nopWriteFile := func(fileName tspath.RootedFilePath, text string, data *compiler.WriteFileData) error { return nil diff --git a/tsc/internal/compiler/fileloader.go b/tsc/internal/compiler/fileloader.go index d4e159246c6ca..5c7891a9e88d0 100644 --- a/tsc/internal/compiler/fileloader.go +++ b/tsc/internal/compiler/fileloader.go @@ -2,6 +2,7 @@ package compiler import ( "cmp" + "context" "errors" "fmt" "slices" @@ -164,6 +165,7 @@ type jsxRuntimeImportSpecifier struct { } func processAllProgramFiles( + ctx context.Context, opts ProgramOptions, singleThreaded bool, ) (processedFiles, *module.ResolutionData, error) { @@ -190,7 +192,10 @@ func processAllProgramFiles( supportedExtensionsWithJsonIfResolveJsonModule: supportedExtensionsWithJsonIfResolveJsonModule, contentMapperExtensions: opts.Config.ContentMapperExtensions(), } - loader.addProjectReferenceTasks(singleThreaded) + loader.addProjectReferenceTasks(ctx, singleThreaded) + if err := ctx.Err(); err != nil { + return processedFiles{}, nil, err + } resolverOptions := module.ResolverOptions{ Host: loader.projectReferences.host, CompilerOptions: compilerOptions, @@ -207,6 +212,9 @@ func processAllProgramFiles( defer opts.Tracing.Push(tracing.PhaseProgram, "processRootFiles", map[string]any{"count": len(rootFiles)}, false)() } for index, rootFile := range rootFiles { + if ctx.Err() != nil { + break + } loader.addRootFileTask(rootFile, opts.Config.RootFileNameForDiagnostic(index), nil, &FileIncludeReason{kind: fileIncludeKindRootFile, index: index}) } if len(rootFiles) > 0 && compilerOptions.NoLib.IsFalseOrUnknown() { @@ -230,9 +238,12 @@ func processAllProgramFiles( loader.addAutomaticTypeDirectiveTasks() } - loader.filesParser.parse(&loader, loader.rootTasks) + loader.filesParser.parse(ctx, &loader, loader.rootTasks) + if ctx.Err() != nil { + return processedFiles{}, nil, ctx.Err() + } - return loader.filesParser.getProcessedFiles(&loader), loader.resolver.GetResolutionData(), loader.moduleResolutionError + return loader.filesParser.getProcessedFiles(ctx, &loader), loader.resolver.GetResolutionData(), loader.moduleResolutionError } func (p *fileLoader) toPath(file tspath.RootedPath) tspath.PathKey { @@ -283,7 +294,7 @@ func (p *fileLoader) addAutomaticTypeDirectiveTasks() { }) } -func (p *fileLoader) resolveAutomaticTypeDirectives(containingFileName tspath.RootedFilePath) ( +func (p *fileLoader) resolveAutomaticTypeDirectives(ctx context.Context, containingFileName tspath.RootedFilePath) ( toParse []resolvedRef, typeResolutionsInFile module.ModeAwareCache[*module.ResolvedTypeReferenceDirective], typeResolutionsTrace []module.DiagAndArgs, @@ -294,6 +305,9 @@ func (p *fileLoader) resolveAutomaticTypeDirectives(containingFileName tspath.Ro toParse = make([]resolvedRef, 0, len(automaticTypeDirectiveNames)) typeResolutionsInFile = make(module.ModeAwareCache[*module.ResolvedTypeReferenceDirective], len(automaticTypeDirectiveNames)) for _, name := range automaticTypeDirectiveNames { + if ctx.Err() != nil { + break + } // Under node16/nodenext module resolution, load `types`/ata include names as cjs resolution results by passing an `undefined` mode. // Under bundler module resolution, this also triggers the "import" condition to be used. resolutionMode := core.ResolutionModeNone @@ -337,7 +351,7 @@ func (p *fileLoader) resolveAutomaticTypeDirectives(containingFileName tspath.Ro return toParse, typeResolutionsInFile, typeResolutionsTrace, pDiagnostics } -func (p *fileLoader) addProjectReferenceTasks(singleThreaded bool) { +func (p *fileLoader) addProjectReferenceTasks(ctx context.Context, singleThreaded bool) { p.projectReferences = &projectReferenceFileMapperBuilder{ projectReferenceFileMapper: &projectReferenceFileMapper{ config: p.opts.Config, @@ -355,7 +369,7 @@ func (p *fileLoader) addProjectReferenceTasks(singleThreaded bool) { wg: core.NewWorkGroup(singleThreaded), } rootTasks := createProjectReferenceParseTasks(projectReferences) - parser.parse(rootTasks) + parser.parse(ctx, rootTasks) } func (p *fileLoader) sortLibs(libFiles []*ast.SourceFile) { @@ -409,7 +423,10 @@ func (p *fileLoader) loadSourceFileMetaData(fileName tspath.RootedFilePath) ast. } } -func (p *fileLoader) parseSourceFile(t *parseTask) *ast.SourceFile { +func (p *fileLoader) parseSourceFile(ctx context.Context, t *parseTask) *ast.SourceFile { + if ctx.Err() != nil { + return nil + } if p.tracing != nil { defer p.tracing.Push(tracing.PhaseParse, "createSourceFile", map[string]any{"path": t.normalizedFilePath.AsString()}, true)() } @@ -762,7 +779,7 @@ func (p *fileLoader) resolveTripleslashPathReference(moduleName string, containi }, nil } -func (p *fileLoader) resolveTypeReferenceDirectives(t *parseTask) { +func (p *fileLoader) resolveTypeReferenceDirectives(ctx context.Context, t *parseTask) { file := t.file if len(file.TypeReferenceDirectives) == 0 { return @@ -775,6 +792,9 @@ func (p *fileLoader) resolveTypeReferenceDirectives(t *parseTask) { typeResolutionsInFile := make(module.ModeAwareCache[*module.ResolvedTypeReferenceDirective], len(file.TypeReferenceDirectives)) var typeResolutionsTrace []module.DiagAndArgs for index, ref := range file.TypeReferenceDirectives { + if ctx.Err() != nil { + break + } redirect, fileName := p.projectReferences.getRedirectForResolution(file) resolutionMode := getModeForTypeReferenceDirectiveInFile(ref, file, meta, module.GetCompilerOptionsWithRedirect(p.opts.Config.CompilerOptions(), redirect)) resolved, trace := p.resolver.ResolveTypeReferenceDirective(ref.FileName, fileName, resolutionMode, redirect) @@ -818,7 +838,7 @@ func (p *fileLoader) resolveTypeReferenceDirectives(t *parseTask) { const externalHelpersModuleNameText = "tslib" // TODO(jakebailey): dedupe -func (p *fileLoader) resolveImportsAndModuleAugmentations(t *parseTask) { +func (p *fileLoader) resolveImportsAndModuleAugmentations(ctx context.Context, t *parseTask) { if p.tracing != nil { defer p.tracing.Push(tracing.PhaseProgram, "resolveModuleNamesWorker", map[string]any{"containingFileName": t.file.FileName()}, false)() } @@ -871,6 +891,9 @@ func (p *fileLoader) resolveImportsAndModuleAugmentations(t *parseTask) { var resolutionsTrace []module.DiagAndArgs for index, entry := range moduleNames { + if ctx.Err() != nil { + break + } moduleName := entry.Text() if moduleName == "" || ast.IsSourcePhaseImport(entry.Parent) { continue diff --git a/tsc/internal/compiler/filesparser.go b/tsc/internal/compiler/filesparser.go index 03a21cbf71961..bb725af7fb916 100644 --- a/tsc/internal/compiler/filesparser.go +++ b/tsc/internal/compiler/filesparser.go @@ -1,6 +1,7 @@ package compiler import ( + "context" "math" "slices" "strings" @@ -56,10 +57,10 @@ func (t *parseTask) PathKey() tspath.PathKey { return t.path } -func (t *parseTask) load(loader *fileLoader) { +func (t *parseTask) load(ctx context.Context, loader *fileLoader) { t.loaded = true if t.isForAutomaticTypeDirective { - t.loadAutomaticTypeDirectives(loader) + t.loadAutomaticTypeDirectives(ctx, loader) return } if t.failedLookup { @@ -118,13 +119,16 @@ func (t *parseTask) load(loader *fileLoader) { file := t.file if file == nil { - file = loader.parseSourceFile(t) + file = loader.parseSourceFile(ctx, t) } if file == nil { return } t.file = file + if ctx.Err() != nil { + return + } if virtualFileName := file.VirtualFileName(); virtualFileName != "" { t.metadata.ImpliedNodeFormat = ast.GetImpliedNodeFormatForFile(virtualFileName, t.metadata.PackageJsonType) } @@ -133,6 +137,9 @@ func (t *parseTask) load(loader *fileLoader) { compilerOptions := loader.opts.Config.CompilerOptions() if !compilerOptions.NoResolve.IsTrue() && !loader.opts.SkipModuleResolution { for index, ref := range file.ReferencedFiles { + if ctx.Err() != nil { + return + } resolvedRef, processingDiagnostic := loader.resolveTripleslashPathReference(ref.FileName, file.FileName(), index) if processingDiagnostic != nil { t.processingDiagnostics = append(t.processingDiagnostics, processingDiagnostic) @@ -141,11 +148,14 @@ func (t *parseTask) load(loader *fileLoader) { t.addSubTask(*resolvedRef, nil) } - loader.resolveTypeReferenceDirectives(t) + loader.resolveTypeReferenceDirectives(ctx, t) } if compilerOptions.NoLib != core.TSTrue && !loader.opts.SkipModuleResolution { for index, lib := range file.LibReferenceDirectives { + if ctx.Err() != nil { + return + } includeReason := &FileIncludeReason{ kind: fileIncludeKindLibReferenceDirective, referencedFile: &referencedFileData{ @@ -169,7 +179,7 @@ func (t *parseTask) load(loader *fileLoader) { } } - loader.resolveImportsAndModuleAugmentations(t) + loader.resolveImportsAndModuleAugmentations(ctx, t) for _, supplemental := range file.SupplementalSourceFiles() { t.subTasks = append(t.subTasks, &parseTask{ normalizedFilePath: supplemental.FileName(), @@ -195,11 +205,11 @@ func (t *parseTask) redirect(loader *fileLoader, fileName tspath.RootedFilePath, t.subTasks = []*parseTask{t.redirectedParseTask} } -func (t *parseTask) loadAutomaticTypeDirectives(loader *fileLoader) { +func (t *parseTask) loadAutomaticTypeDirectives(ctx context.Context, loader *fileLoader) { if loader.tracing != nil { defer loader.tracing.Push(tracing.PhaseProgram, "processTypeReferences", nil, false)() } - toParseTypeRefs, typeResolutionsInFile, typeResolutionsTrace, pDiagnostics := loader.resolveAutomaticTypeDirectives(t.normalizedFilePath) + toParseTypeRefs, typeResolutionsInFile, typeResolutionsTrace, pDiagnostics := loader.resolveAutomaticTypeDirectives(ctx, t.normalizedFilePath) t.typeResolutionsInFile = typeResolutionsInFile t.typeResolutionsTrace = typeResolutionsTrace t.processingDiagnostics = append(t.processingDiagnostics, pDiagnostics...) @@ -265,13 +275,16 @@ type parseTaskData struct { packageId module.PackageId } -func (w *filesParser) parse(loader *fileLoader, tasks []*parseTask) { - w.start(loader, tasks, 0) +func (w *filesParser) parse(ctx context.Context, loader *fileLoader, tasks []*parseTask) { + w.start(ctx, loader, tasks, 0) w.wg.RunAndWait() } -func (w *filesParser) start(loader *fileLoader, tasks []*parseTask, depth int) { +func (w *filesParser) start(ctx context.Context, loader *fileLoader, tasks []*parseTask, depth int) { for i, task := range tasks { + if ctx.Err() != nil { + return + } if task.path == "" { panic("parse task must have a path key: " + task.normalizedFilePath.AsString()) } @@ -284,6 +297,9 @@ func (w *filesParser) start(loader *fileLoader, tasks []*parseTask, depth int) { w.wg.Queue(func() { data.mu.Lock() defer data.mu.Unlock() + if ctx.Err() != nil { + return + } startSubtasks := false if loaded { @@ -315,9 +331,12 @@ func (w *filesParser) start(loader *fileLoader, tasks []*parseTask, depth int) { } for _, taskByFileName := range data.tasks { + if ctx.Err() != nil { + return + } loadSubTasks := startSubtasks if !taskByFileName.loaded { - taskByFileName.load(loader) + taskByFileName.load(ctx, loader) if taskByFileName.redirectedParseTask != nil { // Always load redirected task loadSubTasks = true @@ -326,14 +345,14 @@ func (w *filesParser) start(loader *fileLoader, tasks []*parseTask, depth int) { } if !taskByFileName.startedSubTasks && loadSubTasks { taskByFileName.startedSubTasks = true - w.start(loader, taskByFileName.subTasks, data.lowestDepth) + w.start(ctx, loader, taskByFileName.subTasks, data.lowestDepth) } } }) } } -func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles { +func (w *filesParser) getProcessedFiles(ctx context.Context, loader *fileLoader) processedFiles { totalFileCount := int(loader.totalFileCount.Load()) libFileCount := int(loader.libFileCount.Load()) @@ -384,6 +403,9 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles { var recordedDuplicates map[*parseTaskData]*collections.Set[tspath.RootedFilePath] collectFiles = func(tasks []*parseTask, seen map[*parseTaskData]tspath.RootedFilePath) { for _, task := range tasks { + if ctx.Err() != nil { + return + } includeReason := task.includeReason // Exclude automatic type directive tasks from include reason processing, // as these are internal implementation details and should not contribute @@ -549,6 +571,9 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles { } collectFiles(loader.rootTasks, make(map[*parseTaskData]tspath.RootedFilePath, totalFileCount)) + if ctx.Err() != nil { + return processedFiles{} + } loader.sortLibs(libFiles) allFiles := append(libFiles, files...) diff --git a/tsc/internal/compiler/program.go b/tsc/internal/compiler/program.go index 31363f95afc88..32933ac9be82c 100644 --- a/tsc/internal/compiler/program.go +++ b/tsc/internal/compiler/program.go @@ -310,7 +310,12 @@ func (p *Program) GetSourceFileFromReference(origin *ast.SourceFile, ref *ast.Fi return nil } -func NewProgram(opts ProgramOptions) *Program { +// NewProgram stops loading new source files when ctx is cancelled. +// An in-progress file read, parse, or module resolution must finish first. +func NewProgram(ctx context.Context, opts ProgramOptions) (*Program, error) { + if err := ctx.Err(); err != nil { + return nil, err + } p := &Program{ opts: opts.ProgramConfig, hosts: opts.ProgramHosts, @@ -319,34 +324,46 @@ func NewProgram(opts ProgramOptions) *Program { if opts.Tracing != nil { defer opts.Tracing.Push(tracing.PhaseProgram, "createProgram", map[string]any{"configFilePath": opts.Config.CompilerOptions().ConfigFilePath}, true)() } - p.processedFiles, p.resolutionData, p.moduleResolutionError = processAllProgramFiles(opts, p.SingleThreaded()) + p.processedFiles, p.resolutionData, p.moduleResolutionError = processAllProgramFiles(ctx, opts, p.SingleThreaded()) + if err := ctx.Err(); err != nil { + return nil, err + } p.initCheckerPool(opts.CreateCheckerPool) p.verifyCompilerOptions() p.collectContentMapperOptionDiagnostics() - return p + if err := ctx.Err(); err != nil { + return nil, err + } + return p, nil } // Return an updated program for which it is known that only the file with the given path has changed. // In addition to a new program, return a boolean indicating whether the data of the old program was reused. // The returned *ast.SourceFile is the changed file as acquired through newHost; it is nil -// only if the host cannot locate the file (e.g. it was deleted). Callers that manage -// host-side parse caches must release this exact pointer when the old program could not be -// reused, since it was acquired speculatively before that decision was made. +// if cancellation precedes acquisition or the host cannot locate the file (e.g. it was deleted). +// Callers that manage host-side parse caches must release this exact pointer when the old program could not be +// reused, even if a fallback rebuild was cancelled, since it was acquired speculatively +// before that decision was made. func (p *Program) UpdateProgram( + ctx context.Context, changedFilePath tspath.PathKey, newHost CompilerHost, createCheckerPool func(*Program) CheckerPool, createModuleResolver func(module.ResolverOptions) module.Resolver, -) (*Program, *ast.SourceFile, bool) { +) (*Program, *ast.SourceFile, bool, error) { + if err := ctx.Err(); err != nil { + return nil, nil, false, err + } if result, newFile, reused := p.ReuseProgram(changedFilePath, newHost, createCheckerPool, createModuleResolver); reused { - return result, newFile, true + return result, newFile, true, nil } else { - return NewProgram(ProgramOptions{ + program, err := NewProgram(ctx, ProgramOptions{ ProgramConfig: p.opts, Host: newHost, CreateCheckerPool: createCheckerPool, CreateModuleResolver: createModuleResolver, - }), newFile, false + }) + return program, newFile, false, err } } @@ -593,10 +610,20 @@ func (p *Program) SingleThreaded() bool { } func (p *Program) BindSourceFiles() { + p.bindSourceFiles(context.Background()) +} + +func (p *Program) bindSourceFiles(ctx context.Context) { wg := core.NewWorkGroup(p.SingleThreaded()) for _, file := range p.files { + if ctx.Err() != nil { + break + } if !file.IsBound() { wg.Queue(func() { + if ctx.Err() != nil { + return + } if p.hosts.Tracing != nil { defer p.hosts.Tracing.Push(tracing.PhaseBind, "bindSourceFile", map[string]any{"path": string(file.PathKey())}, true)() } @@ -703,7 +730,13 @@ func (p *Program) collectDiagnosticsFromFiles(ctx context.Context, sourceFiles [ diagnostics := make([][]*ast.Diagnostic, len(sourceFiles)) wg := core.NewWorkGroup(!concurrent || p.SingleThreaded()) for i, file := range sourceFiles { + if ctx.Err() != nil { + break + } wg.Queue(func() { + if ctx.Err() != nil { + return + } diagnostics[i] = collect(ctx, file) }) } @@ -717,13 +750,19 @@ func (p *Program) collectDiagnosticsFromFiles(ctx context.Context, sourceFiles [ // processed in parallel with one task per checker, reducing contention and improving // cache locality. Otherwise, falls back to per-file concurrent collection. func (p *Program) collectCheckerDiagnostics(ctx context.Context, sourceFile *ast.SourceFile, collect func(context.Context, *checker.Checker, *ast.SourceFile) []*ast.Diagnostic) []*ast.Diagnostic { + if ctx.Err() != nil { + return nil + } if sourceFile != nil { if p.SkipTypeChecking(sourceFile, false) { return nil } c, done := p.GetTypeCheckerForFileExclusive(ctx, sourceFile) + defer done() + if ctx.Err() != nil { + return nil + } result := collect(ctx, c, sourceFile) - done() return filterAndSortDiagnostics(result) } return filterAndSortDiagnostics(slices.Concat(p.collectCheckerDiagnosticsFromFiles(ctx, p.files, collect)...)) @@ -750,13 +789,22 @@ func (p *Program) collectCheckerDiagnosticsFromFiles(ctx context.Context, source } else { wg := core.NewWorkGroup(p.SingleThreaded()) for i, file := range sourceFiles { + if ctx.Err() != nil { + break + } if p.SkipTypeChecking(file, false) { continue } wg.Queue(func() { + if ctx.Err() != nil { + return + } c, done := p.checkerPool.GetChecker(ctx, file) + defer done() + if ctx.Err() != nil { + return + } diagnostics[i] = collect(ctx, c, file) - done() }) } wg.RunAndWait() @@ -809,10 +857,13 @@ func getAdditionalJSSyntacticDiagnostics(file *ast.SourceFile, options *core.Com } func (p *Program) GetBindDiagnostics(ctx context.Context, sourceFile *ast.SourceFile) []*ast.Diagnostic { + if ctx.Err() != nil { + return nil + } if sourceFile != nil { binder.BindSourceFile(sourceFile) } else { - p.BindSourceFiles() + p.bindSourceFiles(ctx) } return p.collectDiagnostics(ctx, sourceFile, false /*concurrent*/, func(_ context.Context, file *ast.SourceFile) []*ast.Diagnostic { return file.BindDiagnostics() @@ -1477,10 +1528,14 @@ func emitModuleKindIsNonNodeESM(moduleKind core.ModuleKind) bool { } func (p *Program) GetGlobalDiagnostics(ctx context.Context) []*ast.Diagnostic { - if len(p.files) == 0 { + if len(p.files) == 0 || ctx.Err() != nil { return nil } if p.compilerCheckerPool != nil { + p.bindSourceFiles(ctx) + if ctx.Err() != nil { + return nil + } return p.compilerCheckerPool.GetGlobalDiagnostics() } // For external pools (project system), global diagnostics are collected @@ -1888,6 +1943,9 @@ type SourceMapEmitResult struct { } func (p *Program) Emit(ctx context.Context, options EmitOptions) *EmitResult { + if ctx.Err() != nil { + return nil + } if tr := p.hosts.Tracing; tr != nil { defer tr.Push(tracing.PhaseEmit, "emit", nil, true)() } @@ -1917,18 +1975,35 @@ func (p *Program) Emit(ctx context.Context, options EmitOptions) *EmitResult { sourceFiles := p.getSourceFilesToEmit(options.TargetSourceFiles, forceDtsEmit, forceJsEmit) for _, sourceFile := range sourceFiles { + if ctx.Err() != nil { + break + } emitter := &emitter{ writer: nil, sourceFile: sourceFile, emitOnly: options.EmitOnly, forceEmit: options.ForceEmit, - writeFile: options.WriteFile, - tr: p.hosts.Tracing, + writeFile: func(fileName tspath.RootedFilePath, text string, data *WriteFileData) error { + if err := ctx.Err(); err != nil { + return err + } + if options.WriteFile != nil { + return options.WriteFile(fileName, text, data) + } + return p.Host().FS().WriteFile(fileName, text) + }, + tr: p.hosts.Tracing, } emitters = append(emitters, emitter) wg.Queue(func() { + if ctx.Err() != nil { + return + } host, done := newEmitHost(ctx, p, sourceFile) defer done() + if ctx.Err() != nil { + return + } emitter.host = host // take an unused writer @@ -1952,6 +2027,9 @@ func (p *Program) Emit(ctx context.Context, options EmitOptions) *EmitResult { // wait for emit to complete wg.RunAndWait() + if ctx.Err() != nil { + return nil + } // collect results from emit, preserving input order return CombineEmitResults(core.Map(emitters, func(e *emitter) *EmitResult { @@ -2038,14 +2116,23 @@ func GetDiagnosticsOfAnyProgram( getBindDiagnostics func(context.Context, *ast.SourceFile) []*ast.Diagnostic, getSemanticDiagnostics func(context.Context, *ast.SourceFile) []*ast.Diagnostic, ) []*ast.Diagnostic { + if ctx.Err() != nil { + return nil + } allDiagnostics := slices.Clip(program.GetConfigFileParsingDiagnostics()) configFileParsingDiagnosticsLength := len(allDiagnostics) appendDiagnosticsForAllFiles := func(diagnostics []*ast.Diagnostic, getDiagnostics func(context.Context, *ast.SourceFile) []*ast.Diagnostic) []*ast.Diagnostic { + if ctx.Err() != nil { + return diagnostics + } if files == nil { return append(diagnostics, getDiagnostics(ctx, nil)...) } for _, file := range files { + if ctx.Err() != nil { + break + } diagnostics = append(diagnostics, getDiagnostics(ctx, file)...) } return diagnostics diff --git a/tsc/internal/compiler/program_test.go b/tsc/internal/compiler/program_test.go index 0139968b1591b..826196f1d2250 100644 --- a/tsc/internal/compiler/program_test.go +++ b/tsc/internal/compiler/program_test.go @@ -1,6 +1,7 @@ package compiler import ( + "context" "fmt" "maps" "path/filepath" @@ -26,6 +27,47 @@ import ( "gotest.tools/v3/assert" ) +type cancellingCompilerHost struct { + CompilerHost + cancel context.CancelFunc + reads int +} + +func (h *cancellingCompilerHost) GetSourceFile(options ast.SourceFileParseOptions) *ast.SourceFile { + h.reads++ + file := h.CompilerHost.GetSourceFile(options) + h.cancel() + return file +} + +func TestProgramLoadingStopsOnCancellation(t *testing.T) { + t.Parallel() + fs := vfstest.FromMap(map[string]any{ + "/src/a.ts": "export const a = 1;", + "/src/b.ts": "export const b = 2;", + }, tspath.CaseSensitive) + config := tsoptions.NewParsedCommandLine(&core.CompilerOptions{NoLib: core.TSTrue}, []tspath.RootedFilePath{"/src/a.ts", "/src/b.ts"}, nil, "/src", tspath.CaseSensitive) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + host := &cancellingCompilerHost{CompilerHost: NewCompilerHost(fs, "/", nil, nil, nil), cancel: cancel} + program, err := NewProgram(ctx, ProgramOptions{ + Config: config, + SingleThreaded: core.TSTrue, + Host: host, + }) + assert.ErrorIs(t, err, context.Canceled) + assert.Assert(t, program == nil) + assert.Equal(t, host.reads, 1) + + program, err = NewProgram(ctx, ProgramOptions{ + Config: config, + Host: host, + }) + assert.ErrorIs(t, err, context.Canceled) + assert.Assert(t, program == nil) + assert.Equal(t, host.reads, 1) +} + func TestProgramSharedData(t *testing.T) { t.Parallel() // These types use tagged or JSON payloads. @@ -107,7 +149,7 @@ import { value } from "./dep.js"; export const result = value;`, assert.Equal(t, len(diagnostics), 0) var pools, resolvers int tr := new(tracing.Tracing) - p := NewProgram(ProgramOptions{ + p, err := NewProgram(t.Context(), ProgramOptions{ Config: config, Host: host, Tracing: tr, @@ -127,8 +169,16 @@ import { value } from "./dep.js"; export const result = value;`, return module.NewResolver(options) }, }) + assert.NilError(t, err) assert.Equal(t, pools, 1) assert.Equal(t, resolvers, 1) + cancelledCtx, cancel := context.WithCancel(t.Context()) + cancel() + cancelled, acquired, reused, err := p.UpdateProgram(cancelledCtx, "/src/index.ts", host, nil, nil) + assert.ErrorIs(t, err, context.Canceled) + assert.Assert(t, cancelled == nil) + assert.Assert(t, acquired == nil) + assert.Assert(t, !reused) assert.Equal(t, p.Tracing(), tr) assert.Assert(t, p.GetPackageJsonInfo("/factory-only/package.json") == nil, "lazy lookups must not retain the factory's host") oldFile := p.GetSourceFile("/src/index.ts") @@ -174,14 +224,16 @@ import { value } from "./dep.js"; export const result = value;`, assert.Assert(t, defaults.compilerCheckerPool.tracing == nil) assert.Equal(t, pools, 2) - traced := NewProgram(ProgramOptions{Config: config, Host: newHost, Tracing: tr}) + traced, err := NewProgram(t.Context(), ProgramOptions{Config: config, Host: newHost, Tracing: tr}) + assert.NilError(t, err) assert.Equal(t, traced.Tracing(), tr) assert.Equal(t, traced.compilerCheckerPool.tracing, tr) newFiles["/src/index.ts"] = `import "./other.js";` newFiles["/src/other.ts"] = "export {};" rebuildHost := NewCompilerHost(vfstest.FromMap(newFiles, tspath.CaseSensitive), "/", nil, nil, nil) - rebuilt, _, reused := p.UpdateProgram("/src/index.ts", rebuildHost, nil, nil) + rebuilt, _, reused, err := p.UpdateProgram(t.Context(), "/src/index.ts", rebuildHost, nil, nil) + assert.NilError(t, err) assert.Assert(t, !reused) assert.Assert(t, rebuilt.compilerCheckerPool != nil) assert.Equal(t, rebuilt.Host(), rebuildHost) @@ -211,7 +263,8 @@ func TestClonedProgramProjectReferenceResolution(t *testing.T) { PreserveSymlinks: core.BoolToTristate(preserveSymlinks), }, nil, fs, nil) assert.Equal(t, len(diagnostics), 0) - p := NewProgram(ProgramOptions{Config: config, Host: host, UseSourceOfProjectReference: true}) + p, err := NewProgram(t.Context(), ProgramOptions{Config: config, Host: host, UseSourceOfProjectReference: true}) + assert.NilError(t, err) assert.Assert(t, p.GetSourceFile("/reference/index.ts") != nil) assert.Assert(t, !host.FS().FileExists("/reference/dist/index.d.ts")) newFiles := maps.Clone(files) @@ -464,11 +517,12 @@ func TestProgram(t *testing.T) { opts := core.CompilerOptions{Target: testCase.target} - program := NewProgram(ProgramOptions{ + program, err := NewProgram(t.Context(), ProgramOptions{ Config: tsoptions.NewParsedCommandLine(&opts, []tspath.RootedFilePath{"c:/dev/src/index.ts"}, nil, "c:/dev/src", fs.CaseSensitivity()), Host: NewCompilerHost(fs, bundled.LibPath(), nil, nil, nil), }) + assert.NilError(t, err) actualFiles := []string{} for _, file := range program.GetSourceFiles() { actualFiles = append(actualFiles, strings.TrimPrefix(file.FileName().AsString(), libPrefix)) @@ -501,7 +555,8 @@ func TestImportSourceProgram(t *testing.T) { host := NewCompilerHost(fs, "/", nil, nil, nil) config, diagnostics := tsoptions.GetParsedCommandLineOfConfigFile("/src/tsconfig.json", nil, nil, fs, nil) assert.Equal(t, len(diagnostics), 0) - program := NewProgram(ProgramOptions{Config: config, Host: host}) + program, err := NewProgram(t.Context(), ProgramOptions{Config: config, Host: host}) + assert.NilError(t, err) file := program.GetSourceFile("/src/index.ts") assert.Assert(t, program.GetSourceFile("/src/a.ts") == nil) assert.Equal(t, len(program.GetResolvedModules()[fs.CaseSensitivity().PathKey(file.FileName().AsPath())]), 0) @@ -510,7 +565,8 @@ func TestImportSourceProgram(t *testing.T) { files["/src/index.ts"] = test.evaluation + strings.TrimPrefix(content, test.source) host = NewCompilerHost(vfstest.FromMap(files, tspath.CaseSensitive), "/", nil, nil, nil) - program, file, reused := program.UpdateProgram("/src/index.ts", host, nil, nil) + program, file, reused, err := program.UpdateProgram(t.Context(), "/src/index.ts", host, nil, nil) + assert.NilError(t, err) assert.Assert(t, !reused) assert.Assert(t, program.GetSourceFile("/src/a.ts") != nil) for _, specifier := range file.Imports() { @@ -520,13 +576,15 @@ func TestImportSourceProgram(t *testing.T) { files["/src/index.ts"] = content host = NewCompilerHost(vfstest.FromMap(files, tspath.CaseSensitive), "/", nil, nil, nil) - program, _, reused = program.UpdateProgram("/src/index.ts", host, nil, nil) + program, _, reused, err = program.UpdateProgram(t.Context(), "/src/index.ts", host, nil, nil) + assert.NilError(t, err) assert.Assert(t, !reused) assert.Assert(t, program.GetSourceFile("/src/a.ts") == nil) files["/src/index.ts"] = test.evaluation + content host = NewCompilerHost(vfstest.FromMap(files, tspath.CaseSensitive), "/", nil, nil, nil) - program = NewProgram(ProgramOptions{Config: config, Host: host}) + program, err = NewProgram(t.Context(), ProgramOptions{Config: config, Host: host}) + assert.NilError(t, err) file = program.GetSourceFile("/src/index.ts") for _, specifier := range file.Imports() { resolved := program.GetResolvedModuleFromModuleSpecifier(file, specifier) @@ -556,11 +614,13 @@ func TestIncludeProcessorDiagnosticsWithMissingFileCasing(t *testing.T) { // List both casings as root files. The first one (/src/MyFile.ts) will fail // to load because it does not exist on the case-sensitive filesystem. - program := NewProgram(ProgramOptions{ + program, err := NewProgram(t.Context(), ProgramOptions{ Config: tsoptions.NewParsedCommandLine(&opts, []tspath.RootedFilePath{"/src/MyFile.ts", "/src/myFile.ts"}, nil, "/", fs.CaseSensitivity()), Host: NewCompilerHost(fs, bundled.LibPath(), nil, nil, nil), }) + assert.NilError(t, err) + // GetProgramDiagnostics triggers getDiagnostics which processes all // include processor diagnostics including the casing diagnostic whose // file path points to the missing /src/MyFile.ts. Before the fix this @@ -599,7 +659,9 @@ func BenchmarkNewProgram(b *testing.B) { } for b.Loop() { - NewProgram(programOpts) + if _, err := NewProgram(b.Context(), programOpts); err != nil { + b.Fatal(err) + } } }) } @@ -616,7 +678,9 @@ func BenchmarkNewProgram(b *testing.B) { } for b.Loop() { - NewProgram(opts) + if _, err := NewProgram(b.Context(), opts); err != nil { + b.Fatal(err) + } } }) } diff --git a/tsc/internal/compiler/projectreferenceparser.go b/tsc/internal/compiler/projectreferenceparser.go index 24d41a725c10d..cef2440eccfff 100644 --- a/tsc/internal/compiler/projectreferenceparser.go +++ b/tsc/internal/compiler/projectreferenceparser.go @@ -1,6 +1,7 @@ package compiler import ( + "context" "maps" "github.com/microsoft/TypeScript/tsc/internal/collections" @@ -16,13 +17,13 @@ type projectReferenceParseTask struct { subTasks []*projectReferenceParseTask } -func (t *projectReferenceParseTask) parse(projectReferenceParser *projectReferenceParser) { +func (t *projectReferenceParseTask) parse(ctx context.Context, projectReferenceParser *projectReferenceParser) { loader := projectReferenceParser.loader if tr := loader.tracing; tr != nil { defer tr.Push(tracing.PhaseParse, "parseJsonSourceFileConfigFileContent", map[string]any{"path": t.configName.AsString()}, false)() } t.resolved = loader.host.GetResolvedProjectReference(t.configName, loader.toPath(t.configName.AsPath())) - if t.resolved == nil { + if t.resolved == nil || ctx.Err() != nil { return } t.resolved.ParseInputOutputNames() @@ -45,43 +46,55 @@ type projectReferenceParser struct { tasksByFileName collections.SyncMap[tspath.PathKey, *projectReferenceParseTask] } -func (p *projectReferenceParser) parse(tasks []*projectReferenceParseTask) { - p.start(tasks) +func (p *projectReferenceParser) parse(ctx context.Context, tasks []*projectReferenceParseTask) { + p.start(ctx, tasks) p.wg.RunAndWait() - p.initMapper(tasks) + if ctx.Err() != nil { + return + } + p.initMapper(ctx, tasks) } -func (p *projectReferenceParser) start(tasks []*projectReferenceParseTask) { +func (p *projectReferenceParser) start(ctx context.Context, tasks []*projectReferenceParseTask) { for i, task := range tasks { + if ctx.Err() != nil { + return + } path := p.loader.toPath(task.configName.AsPath()) if loadedTask, loaded := p.tasksByFileName.LoadOrStore(path, task); loaded { // dedup tasks to ensure correct file order, regardless of which task would be started first tasks[i] = loadedTask } else { p.wg.Queue(func() { - task.parse(p) - p.start(task.subTasks) + if ctx.Err() != nil { + return + } + task.parse(ctx, p) + p.start(ctx, task.subTasks) }) } } } -func (p *projectReferenceParser) initMapper(tasks []*projectReferenceParseTask) { +func (p *projectReferenceParser) initMapper(ctx context.Context, tasks []*projectReferenceParseTask) { totalReferences := p.tasksByFileName.Size() + 1 p.loader.projectReferences.configToProjectReference = make(map[tspath.PathKey]*tsoptions.ParsedCommandLine, totalReferences) p.loader.projectReferences.referencesInConfigFile = make(map[tspath.PathKey][]tspath.PathKey, totalReferences) p.loader.projectReferences.sourceToProjectReference = make(map[tspath.PathKey]*tsoptions.SourceOutputAndProjectReference) p.loader.projectReferences.outputDtsToProjectReference = make(map[tspath.PathKey]*tsoptions.SourceOutputAndProjectReference) - p.loader.projectReferences.referencesInConfigFile[p.loader.projectReferences.rootConfigPath()] = p.initMapperWorker(tasks, &collections.Set[*projectReferenceParseTask]{}) + p.loader.projectReferences.referencesInConfigFile[p.loader.projectReferences.rootConfigPath()] = p.initMapperWorker(ctx, tasks, &collections.Set[*projectReferenceParseTask]{}) p.loader.projectReferences.host = p.loader.projectReferences.resolutionHost(p.loader.projectReferences.host) } -func (p *projectReferenceParser) initMapperWorker(tasks []*projectReferenceParseTask, seen *collections.Set[*projectReferenceParseTask]) []tspath.PathKey { +func (p *projectReferenceParser) initMapperWorker(ctx context.Context, tasks []*projectReferenceParseTask, seen *collections.Set[*projectReferenceParseTask]) []tspath.PathKey { if len(tasks) == 0 { return nil } results := make([]tspath.PathKey, 0, len(tasks)) for _, task := range tasks { + if ctx.Err() != nil { + return results + } path := p.loader.toPath(task.configName.AsPath()) results = append(results, path) // ensure we only walk each task once @@ -105,7 +118,7 @@ func (p *projectReferenceParser) initMapperWorker(tasks []*projectReferenceParse } } } - referencesInConfig := p.initMapperWorker(task.subTasks, seen) + referencesInConfig := p.initMapperWorker(ctx, task.subTasks, seen) p.loader.projectReferences.referencesInConfigFile[path] = referencesInConfig } return results diff --git a/tsc/internal/execute/build/buildtask.go b/tsc/internal/execute/build/buildtask.go index bd3cddee85c25..97bad9454861e 100644 --- a/tsc/internal/execute/build/buildtask.go +++ b/tsc/internal/execute/build/buildtask.go @@ -1,6 +1,7 @@ package build import ( + "context" "fmt" "iter" "slices" @@ -101,9 +102,14 @@ func (t *BuildTask) refreshContentMapperProject(orchestrator *Orchestrator) { } } -func (t *BuildTask) waitOnUpstream() { +func (t *BuildTask) waitOnUpstream(ctx context.Context) { for _, upstream := range t.upStream { - <-upstream.task.done + select { + case <-ctx.Done(): + return + case <-upstream.task.done: + continue + } } } @@ -144,15 +150,22 @@ func (t *BuildTask) report(orchestrator *Orchestrator, configPath tspath.PathKey t.result = nil } -func (t *BuildTask) buildProject(orchestrator *Orchestrator, path tspath.PathKey) { +func (t *BuildTask) buildProject(ctx context.Context, orchestrator *Orchestrator, path tspath.PathKey) { + defer t.unblockDownstream() // Wait on upstream tasks to complete - t.waitOnUpstream() + t.waitOnUpstream(ctx) + if ctx.Err() != nil { + t.result.exitStatus = tsc.ExitStatusCancelled + return + } 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) + if ctx.Err() == nil { + t.updateDownstream(orchestrator, path) + } } else { if t.resolved != nil { for _, diagnostic := range t.resolved.GetConfigFileParsingDiagnostics() { @@ -172,7 +185,6 @@ func (t *BuildTask) buildProject(orchestrator *Orchestrator, path tspath.PathKey } } } - t.unblockDownstream() } func (t *BuildTask) updateDownstream(orchestrator *Orchestrator, path tspath.PathKey) { @@ -220,7 +232,7 @@ func (t *BuildTask) updateDownstream(orchestrator *Orchestrator, path tspath.Pat } } -func (t *BuildTask) compileAndEmit(orchestrator *Orchestrator, path tspath.PathKey) { +func (t *BuildTask) compileAndEmit(ctx context.Context, orchestrator *Orchestrator, path tspath.PathKey) { t.errors = nil if orchestrator.opts.Command.BuildOptions.Verbose.IsTrue() { t.result.reportStatus(ast.NewCompilerDiagnostic(diagnostics.Building_project_0, orchestrator.relativeFileName(t.config))) @@ -249,16 +261,20 @@ func (t *BuildTask) compileAndEmit(orchestrator *Orchestrator, path tspath.PathK } compileTimes.BuildInfoReadTime = orchestrator.opts.Sys.Now().Sub(buildInfoReadStart) parseStart := orchestrator.opts.Sys.Now() - program := compiler.NewProgram(compiler.ProgramOptions{ + program, programErr := compiler.NewProgram(ctx, compiler.ProgramOptions{ Config: t.resolved, Host: compilerHost, }) + if programErr != nil { + t.result.exitStatus = tsc.ExitStatusCancelled + return + } compileTimes.ParseTime = orchestrator.opts.Sys.Now().Sub(parseStart) changesComputeStart := orchestrator.opts.Sys.Now() 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, @@ -275,6 +291,9 @@ func (t *BuildTask) compileAndEmit(orchestrator *Orchestrator, path tspath.PathK }) t.result.exitStatus = result.Status t.result.statistics = statistics + if ctx.Err() != nil { + 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) { diff --git a/tsc/internal/execute/build/orchestrator.go b/tsc/internal/execute/build/orchestrator.go index db33676e55597..277901ab89a07 100644 --- a/tsc/internal/execute/build/orchestrator.go +++ b/tsc/internal/execute/build/orchestrator.go @@ -326,11 +326,14 @@ func (o *Orchestrator) start(ctx context.Context, project string, onlyReferences } order = order[:len(order)-1] } - result := o.buildOrCleanOrder(order) - if o.opts.Command.CompilerOptions.Watch.IsTrue() { + result := o.buildOrCleanOrder(ctx, order) + if o.opts.Command.CompilerOptions.Watch.IsTrue() && ctx.Err() == nil { o.Watch(ctx) result.Result.Watcher = o } + if ctx.Err() != nil { + result.Result.Status = tsc.ExitStatusCancelled + } return result } @@ -809,9 +812,12 @@ func (o *Orchestrator) addPackageJsonWatchDirs(desiredDirs *watchmanager.DirWatc } } -func (o *Orchestrator) DoCycle() { +func (o *Orchestrator) DoCycle(ctx context.Context) { o.wm.Lock() defer o.wm.Unlock() + if ctx.Err() != nil { + return + } changedPaths, overflow := o.wm.DrainEvents() hasEvents := len(changedPaths) > 0 || overflow @@ -851,7 +857,7 @@ func (o *Orchestrator) DoCycle() { o.GenerateGraphReusingOldTasks() } - o.buildOrClean() + o.buildOrClean(ctx) o.updateWatch() desiredDirs := o.computeDesiredWatches() if err := o.wm.ReconcileWatches(desiredDirs); err != nil { @@ -862,11 +868,14 @@ func (o *Orchestrator) DoCycle() { o.resetCaches() } -func (o *Orchestrator) buildOrClean() tsc.CommandLineResult { - return o.buildOrCleanOrder(o.order).Result +func (o *Orchestrator) buildOrClean(ctx context.Context) tsc.CommandLineResult { + return o.buildOrCleanOrder(ctx, o.order).Result } -func (o *Orchestrator) buildOrCleanOrder(order []*BuildTask) *OrchestratorResult { +func (o *Orchestrator) buildOrCleanOrder(ctx context.Context, order []*BuildTask) *OrchestratorResult { + if ctx.Err() != nil { + return &OrchestratorResult{Result: tsc.CommandLineResult{Status: tsc.ExitStatusCancelled}} + } 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, @@ -890,11 +899,15 @@ func (o *Orchestrator) buildOrCleanOrder(order []*BuildTask) *OrchestratorResult defer close(reported) for _, task := range order { <-task.built - task.report(o, task.path, buildResult) + if ctx.Err() == nil { + task.report(o, task.path, buildResult) + } else { + task.result = nil + } } }() o.rangeTasks(order, func(path tspath.PathKey, task *BuildTask) { - o.buildOrCleanProject(task, path) + o.buildOrCleanProject(ctx, task, path) }) <-reported } else { @@ -906,7 +919,11 @@ func (o *Orchestrator) buildOrCleanOrder(order []*BuildTask) *OrchestratorResult } buildResult.Errors = o.errors } - buildResult.report(o) + if ctx.Err() != nil { + buildResult.Result.Status = tsc.ExitStatusCancelled + } else { + buildResult.report(o) + } return buildResult } @@ -948,12 +965,12 @@ func (o *Orchestrator) rangeTasks(order []*BuildTask, f func(path tspath.PathKey } } -func (o *Orchestrator) buildOrCleanProject(task *BuildTask, path tspath.PathKey) { +func (o *Orchestrator) buildOrCleanProject(ctx context.Context, task *BuildTask, path tspath.PathKey) { 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) } diff --git a/tsc/internal/execute/tsc.go b/tsc/internal/execute/tsc.go index 4303ee466531d..3629008b12034 100644 --- a/tsc/internal/execute/tsc.go +++ b/tsc/internal/execute/tsc.go @@ -51,6 +51,9 @@ func stopTracing(sys tsc.System, tr *tracing.Tracing) { } func CommandLine(ctx context.Context, sys tsc.System, commandLineArgs []string, testing tsc.CommandLineTesting) tsc.CommandLineResult { + if ctx.Err() != nil { + return tsc.CommandLineResult{Status: tsc.ExitStatusCancelled} + } if len(commandLineArgs) > 0 { switch strings.ToLower(commandLineArgs[0]) { case "-b", "--b", "-build", "--build": @@ -246,6 +249,9 @@ func tscCompilation(ctx context.Context, sys tsc.System, commandLine *tsoptions. testing, ) watcher.start(ctx) + if ctx.Err() != nil { + return tsc.CommandLineResult{Status: tsc.ExitStatusCancelled, Watcher: watcher} + } return tsc.CommandLineResult{Status: tsc.ExitStatusSuccess, Watcher: watcher} } else if configForCompilation.CompilerOptions().IsIncremental() { return performIncrementalCompilation( @@ -318,11 +324,15 @@ func performIncrementalCompilation( tr := startTracingIfNeeded(sys, config, testing) parseStart := sys.Now() - program := compiler.NewProgram(compiler.ProgramOptions{ + program, err := compiler.NewProgram(ctx, compiler.ProgramOptions{ Config: config, Host: host, Tracing: tr, }) + if err != nil { + stopTracing(sys, tr) + return tsc.CommandLineResult{Status: tsc.ExitStatusCancelled} + } compileTimes.ParseTime = sys.Now().Sub(parseStart) changesComputeStart := sys.Now() incrementalProgram := incremental.NewProgram(program, oldProgram, incremental.CreateHost(host), sys.Now, testing != nil) @@ -330,7 +340,7 @@ func performIncrementalCompilation( if contentMapperHost != nil { compileTimes.ContentMapperTimes = contentMapperHost.Timings() } - result, _ := tsc.EmitAndReportStatistics(tsc.EmitInput{ + result, _ := tsc.EmitAndReportStatistics(ctx, tsc.EmitInput{ Sys: sys, ProgramLike: incrementalProgram, Program: incrementalProgram.GetProgram(), @@ -379,16 +389,20 @@ func performCompilation( tr := startTracingIfNeeded(sys, config, testing) parseStart := sys.Now() - program := compiler.NewProgram(compiler.ProgramOptions{ + program, err := compiler.NewProgram(ctx, compiler.ProgramOptions{ Config: config, Host: host, Tracing: tr, }) + if err != nil { + stopTracing(sys, tr) + return tsc.CommandLineResult{Status: tsc.ExitStatusCancelled} + } compileTimes.ParseTime = sys.Now().Sub(parseStart) if contentMapperHost != nil { compileTimes.ContentMapperTimes = contentMapperHost.Timings() } - result, _ := tsc.EmitAndReportStatistics(tsc.EmitInput{ + result, _ := tsc.EmitAndReportStatistics(ctx, tsc.EmitInput{ Sys: sys, ProgramLike: program, Program: program, diff --git a/tsc/internal/execute/tsc/compile.go b/tsc/internal/execute/tsc/compile.go index 7ea291edc3f36..bf8bf8e659cad 100644 --- a/tsc/internal/execute/tsc/compile.go +++ b/tsc/internal/execute/tsc/compile.go @@ -56,10 +56,11 @@ const ( ExitStatusInvalidProject_OutputsSkipped ExitStatus = 3 ExitStatusProjectReferenceCycle_OutputsSkipped ExitStatus = 4 ExitStatusNotImplemented ExitStatus = 5 + ExitStatusCancelled ExitStatus = 6 ) type Watcher interface { - DoCycle() + DoCycle(ctx context.Context) } type CommandLineResult struct { diff --git a/tsc/internal/execute/tsc/emit.go b/tsc/internal/execute/tsc/emit.go index 4f30b1617ada1..20a80efe2317a 100644 --- a/tsc/internal/execute/tsc/emit.go +++ b/tsc/internal/execute/tsc/emit.go @@ -43,9 +43,9 @@ 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) + result := EmitFilesAndReportErrors(ctx, input) if result.Status != ExitStatusSuccess { // compile exited early return result, nil @@ -56,10 +56,19 @@ func EmitAndReportStatistics(input EmitInput) (CompileAndEmitResult, *Statistics var memStats runtime.MemStats // GC must be called twice to allow things to settle. runtime.GC() + if ctx.Err() != nil { + return cancelledCompilation(input.CompileTimes), nil + } runtime.GC() + if ctx.Err() != nil { + return cancelledCompilation(input.CompileTimes), nil + } runtime.ReadMemStats(&memStats) statistics = statisticsFromProgram(input, &memStats) + if ctx.Err() != nil { + return cancelledCompilation(input.CompileTimes), nil + } statistics.Report(input.Writer, input.Testing) } @@ -71,9 +80,19 @@ func EmitAndReportStatistics(input EmitInput) (CompileAndEmitResult, *Statistics return result, statistics } -func EmitFilesAndReportErrors(input EmitInput) (result CompileAndEmitResult) { +func cancelledCompilation(times *CompileTimes) CompileAndEmitResult { + return CompileAndEmitResult{ + Status: ExitStatusCancelled, + EmitResult: &compiler.EmitResult{EmitSkipped: true}, + times: times, + } +} + +func EmitFilesAndReportErrors(ctx context.Context, input EmitInput) (result CompileAndEmitResult) { result.times = input.CompileTimes - ctx := context.Background() + if ctx.Err() != nil { + return cancelledCompilation(input.CompileTimes) + } allDiagnostics := compiler.GetDiagnosticsOfAnyProgram( ctx, @@ -111,6 +130,9 @@ func EmitFilesAndReportErrors(input EmitInput) (result CompileAndEmitResult) { return diags }, ) + if ctx.Err() != nil { + return cancelledCompilation(input.CompileTimes) + } emitResult := &compiler.EmitResult{EmitSkipped: true, Diagnostics: []*ast.Diagnostic{}} if !input.ProgramLike.Options().ListFilesOnly.IsTrue() { @@ -120,6 +142,9 @@ func EmitFilesAndReportErrors(input EmitInput) (result CompileAndEmitResult) { }) result.times.emitTime += input.Sys.Now().Sub(emitStart) } + if ctx.Err() != nil { + return cancelledCompilation(input.CompileTimes) + } if emitResult != nil { allDiagnostics = append(allDiagnostics, emitResult.Diagnostics...) } @@ -129,10 +154,19 @@ func EmitFilesAndReportErrors(input EmitInput) (result CompileAndEmitResult) { allDiagnostics = compiler.SortAndDeduplicateDiagnostics(allDiagnostics) for _, diagnostic := range allDiagnostics { + if ctx.Err() != nil { + return cancelledCompilation(input.CompileTimes) + } input.ReportDiagnostic(diagnostic) } + if ctx.Err() != nil { + return cancelledCompilation(input.CompileTimes) + } - listFiles(input, emitResult) + listFiles(ctx, input, emitResult) + if ctx.Err() != nil { + return cancelledCompilation(input.CompileTimes) + } input.ReportErrorSummary(allDiagnostics) result.Diagnostics = allDiagnostics @@ -141,7 +175,7 @@ func EmitFilesAndReportErrors(input EmitInput) (result CompileAndEmitResult) { return result } -func listFiles(input EmitInput, emitResult *compiler.EmitResult) { +func listFiles(ctx context.Context, input EmitInput, emitResult *compiler.EmitResult) { if input.Testing != nil { input.Testing.OnListFilesStart(input.Writer) defer input.Testing.OnListFilesEnd(input.Writer) @@ -149,6 +183,9 @@ func listFiles(input EmitInput, emitResult *compiler.EmitResult) { options := input.Program.Options() if options.ListEmittedFiles.IsTrue() { for _, file := range emitResult.EmittedFiles { + if ctx.Err() != nil { + return + } fmt.Fprintln(input.Writer, "TSFILE:", file.AsString()) } } @@ -156,6 +193,9 @@ func listFiles(input EmitInput, emitResult *compiler.EmitResult) { input.Program.ExplainFiles(input.Writer, input.Config.Locale(), input.Sys.GetCurrentDirectory()) } else if options.ListFiles.IsTrue() || options.ListFilesOnly.IsTrue() { for _, file := range input.Program.GetSourceFiles() { + if ctx.Err() != nil { + return + } fmt.Fprintln(input.Writer, file.FileName()) } } diff --git a/tsc/internal/execute/tsc/emit_test.go b/tsc/internal/execute/tsc/emit_test.go index 0c79585483852..c4880e693b15c 100644 --- a/tsc/internal/execute/tsc/emit_test.go +++ b/tsc/internal/execute/tsc/emit_test.go @@ -2,6 +2,7 @@ package tsc import ( "bytes" + "context" "errors" "io" "strings" @@ -9,6 +10,7 @@ import ( "testing" "time" + "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/compiler" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/execute/incremental" @@ -19,6 +21,91 @@ import ( "gotest.tools/v3/assert" ) +type cancellingProgram struct { + compiler.ProgramLike + cancel context.CancelFunc + phase string + checks int + emits int +} + +func (p *cancellingProgram) GetGlobalDiagnostics(context.Context) []*ast.Diagnostic { + return nil +} + +func (p *cancellingProgram) GetSemanticDiagnostics(ctx context.Context, _ *ast.SourceFile) []*ast.Diagnostic { + p.checks++ + if p.phase == "check" { + p.cancel() + assertContextCancelled(ctx) + } + return nil +} + +func assertContextCancelled(ctx context.Context) { + if ctx.Err() == nil { + panic("compilation did not pass the cancellation context") + } +} + +func (p *cancellingProgram) Emit(ctx context.Context, _ compiler.EmitOptions) *compiler.EmitResult { + p.emits++ + if p.phase == "emit" { + p.cancel() + assertContextCancelled(ctx) + return nil + } + return &compiler.EmitResult{} +} + +func TestEmitStopsOnCancellation(t *testing.T) { + t.Parallel() + for _, phase := range []string{"before", "check", "emit"} { + t.Run(phase, func(t *testing.T) { + t.Parallel() + fs := vfstest.FromMap(map[string]any{"/project/a.ts": "export const a = 1;"}, tspath.CaseSensitive) + config := tsoptions.NewParsedCommandLine(&core.CompilerOptions{NoLib: core.TSTrue, ExtendedDiagnostics: core.TSTrue}, []tspath.RootedFilePath{"/project/a.ts"}, nil, "/project", tspath.CaseSensitive) + program, err := compiler.NewProgram(t.Context(), compiler.ProgramOptions{ + Config: config, + Host: compiler.NewCompilerHost(fs, "/lib", nil, nil, nil), + }) + assert.NilError(t, err) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if phase == "before" { + cancel() + } + cancelling := &cancellingProgram{ProgramLike: program, cancel: cancel, phase: phase} + var output bytes.Buffer + reported := false + result, statistics := EmitAndReportStatistics(ctx, EmitInput{ + Sys: &timingTestSystem{fs: fs, clock: &controlledClock{now: time.Unix(0, 0)}}, + ProgramLike: cancelling, + Program: program, + Config: config, + ReportDiagnostic: func(*ast.Diagnostic) { + reported = true + }, + ReportErrorSummary: func([]*ast.Diagnostic) { + reported = true + }, + Writer: &output, + CompileTimes: &CompileTimes{}, + }) + assert.Equal(t, result.Status, ExitStatusCancelled) + assert.Assert(t, result.EmitResult.EmitSkipped) + assert.Assert(t, statistics == nil) + assert.Assert(t, !reported) + assert.Equal(t, output.Len(), 0) + if phase == "emit" { + assert.Equal(t, cancelling.emits, 1) + } else { + assert.Equal(t, cancelling.emits, 0) + } + }) + } +} + type contentMapperLoggingTestSystem struct { *timingTestSystem enabled bool @@ -166,16 +253,17 @@ export const make = (): Box => ({ value: "ok" }); compile := func(oldProgram *incremental.Program) (*incremental.Program, *CompileTimes) { host := compiler.NewCachedFSCompilerHost(sys.FS(), sys.DefaultLibraryPath(), nil, nil, nil) - program := compiler.NewProgram(compiler.ProgramOptions{ + program, err := compiler.NewProgram(t.Context(), compiler.ProgramOptions{ Config: config, Host: host, }) + assert.NilError(t, err) if program.GetSourceFile("/lib/lib.d.ts") == nil { t.Fatal("default library was not loaded") } 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/tsc/internal/execute/tsctests/contentmapper_watch_test.go b/tsc/internal/execute/tsctests/contentmapper_watch_test.go index bfbbb739cc7a2..14ccea6d841c6 100644 --- a/tsc/internal/execute/tsctests/contentmapper_watch_test.go +++ b/tsc/internal/execute/tsctests/contentmapper_watch_test.go @@ -202,7 +202,7 @@ func TestContentMapperWatchLifecycle(t *testing.T) { "contentMappers": [{ "package": "mapper-b", "extensions": [".vue"] }] }`) testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: configFileName}}) - result.Watcher.DoCycle() + result.Watcher.DoCycle(ctx) assert.Equal(t, spawner.spawns.Load(), int32(2)) assert.Equal(t, spawner.closes.Load(), int32(1)) @@ -210,7 +210,7 @@ func TestContentMapperWatchLifecycle(t *testing.T) { testSys.writeFileNoError(configFileName, `{ "compilerOptions": { "composite": true } }`) testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: configFileName}}) - result.Watcher.DoCycle() + result.Watcher.DoCycle(ctx) assert.Equal(t, spawner.closes.Load(), int32(2)) <-closed @@ -220,7 +220,7 @@ func TestContentMapperWatchLifecycle(t *testing.T) { "contentMappers": [{ "package": "mapper-a", "extensions": [".vue"] }] }`) testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: configFileName}}) - result.Watcher.DoCycle() + result.Watcher.DoCycle(ctx) assert.Equal(t, spawner.spawns.Load(), int32(3)) assert.Equal(t, spawner.closes.Load(), int32(2)) @@ -261,12 +261,12 @@ func TestContentMapperSupplementalCollisionWatch(t *testing.T) { testSys.writeFileNoError(supplementalFileName, "export {};\n") testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: supplementalFileName}}) - w.DoCycle() + w.DoCycle(ctx) assert.Equal(t, w.FullBuilds(), fullBuilds+1, "creating a supplemental filename collision must force a full rebuild") assert.NilError(t, testSys.fsFromFileMap().Remove(supplementalFileName)) testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: supplementalFileName}}) - w.DoCycle() + w.DoCycle(ctx) assert.Equal(t, w.FullBuilds(), fullBuilds+2, "removing a supplemental filename collision must force a full rebuild") } @@ -295,7 +295,7 @@ func TestDynamicContentMapperWatchDependency(t *testing.T) { testSys.writeFileNoError(mapperConfigFileName, `{ "version": 2 }`) testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: mapperConfigFileName}}) - w.DoCycle() + w.DoCycle(ctx) assert.Equal(t, w.FullBuilds(), fullBuilds+1) assert.Equal(t, lifecycle.Opens.Load(), int32(2)) @@ -338,7 +338,7 @@ func TestContentMapperMixedWatchBatchForcesFullRebuild(t *testing.T) { {Kind: fswatch.EventUpdate, Path: mappedFileName}, {Kind: fswatch.EventUpdate, Path: mainFileName}, }) - w.DoCycle() + w.DoCycle(ctx) assert.Equal(t, w.FullBuilds(), fullBuilds+1) assert.Equal(t, w.FastPathBuilds(), fastBuilds) @@ -369,7 +369,7 @@ func TestDynamicContentMapperBuildWatchDependency(t *testing.T) { testSys.writeFileNoError(mapperConfigFileName, `{ "version": 2 }`) testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: mapperConfigFileName}}) - result.Watcher.DoCycle() + result.Watcher.DoCycle(ctx) assert.Equal(t, lifecycle.Opens.Load(), int32(2)) assert.Equal(t, lifecycle.Closes.Load(), int32(1)) @@ -402,7 +402,7 @@ func TestContentMapperBuildWatchSymlinkedManifestChange(t *testing.T) { updatedManifest := strings.Replace(contentmappertest.PackageJSON(contentmappertest.VerbatimMapper), `"version": "1.0.0"`, `"version": "2.0.0"`, 1) testSys.writeFileNoError(manifestTarget, updatedManifest) testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: manifestTarget}}) - result.Watcher.DoCycle() + result.Watcher.DoCycle(ctx) assert.Equal(t, spawner.spawns.Load(), int32(2)) assert.Equal(t, spawner.closes.Load(), int32(1)) @@ -438,7 +438,7 @@ func TestContentMapperWatchManifestChangeIgnoresCase(t *testing.T) { updatedManifest := strings.Replace(contentmappertest.PackageJSON(contentmappertest.VerbatimMapper), `"version": "1.0.0"`, `"version": "2.0.0"`, 1) testSys.writeFileNoError(manifestEvent, updatedManifest) testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: manifestEvent}}) - result.Watcher.DoCycle() + result.Watcher.DoCycle(ctx) assert.Equal(t, spawner.spawns.Load(), int32(2)) assert.Equal(t, spawner.closes.Load(), int32(1)) @@ -469,7 +469,7 @@ func TestContentMapperBuildWatchSymlinkedManifestDelete(t *testing.T) { testSys.clearOutput() assert.NilError(t, testSys.fsFromFileMap().Remove(manifestTarget)) testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventDelete, Path: manifestTarget}}) - result.Watcher.DoCycle() + result.Watcher.DoCycle(ctx) assert.Equal(t, spawner.spawns.Load(), int32(1)) assert.Equal(t, spawner.closes.Load(), int32(1)) @@ -508,7 +508,7 @@ func TestContentMapperBuildWatchSharedLifecycle(t *testing.T) { configFileName := "/home/src/workspaces/project/" + project + "/tsconfig.json" testSys.writeFileNoError(configFileName, `{ "compilerOptions": { "composite": true } }`) testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: configFileName}}) - result.Watcher.DoCycle() + result.Watcher.DoCycle(ctx) if project == "a" { assert.Equal(t, spawner.closes.Load(), int32(0)) } else { diff --git a/tsc/internal/execute/tsctests/runner.go b/tsc/internal/execute/tsctests/runner.go index 2e65925135e82..340e76e63bdd3 100644 --- a/tsc/internal/execute/tsctests/runner.go +++ b/tsc/internal/execute/tsctests/runner.go @@ -58,6 +58,8 @@ func (test *tscInput) executeCommand(ctx context.Context, sys *TestSys, baseline baselineBuilder.WriteString("ExitStatus:: ProjectReferenceCycle_OutputsSkipped") case tsc.ExitStatusNotImplemented: baselineBuilder.WriteString("ExitStatus:: NotImplemented") + case tsc.ExitStatusCancelled: + baselineBuilder.WriteString("ExitStatus:: Cancelled") default: panic(fmt.Sprintf("UnknownExitStatus %d", result.Status)) } @@ -107,7 +109,7 @@ func (test *tscInput) run(t *testing.T, scenario string) { test.executeCommand(ctx, sys, baselineBuilder, commandLineArgs) } else { sys.mockWatchBackend.SendChangedPaths(changedPaths) - result.Watcher.DoCycle() + result.Watcher.DoCycle(ctx) } sys.serializeState(baselineBuilder) if result.Watcher != nil && sys.mockWatchBackend.HasWatches() { diff --git a/tsc/internal/execute/tsctests/tscbuild_test.go b/tsc/internal/execute/tsctests/tscbuild_test.go index 1e6c958bd396f..41c946adeeeb5 100644 --- a/tsc/internal/execute/tsctests/tscbuild_test.go +++ b/tsc/internal/execute/tsctests/tscbuild_test.go @@ -1,23 +1,88 @@ package tsctests import ( + "context" "fmt" "slices" "strconv" "strings" + "sync" "testing" "time" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/execute/build" + "github.com/microsoft/TypeScript/tsc/internal/execute/tsc" "github.com/microsoft/TypeScript/tsc/internal/testutil/contentmappertest" "github.com/microsoft/TypeScript/tsc/internal/testutil/harnessutil" "github.com/microsoft/TypeScript/tsc/internal/testutil/stringtestutil" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) +type cancellingBuildFS struct { + vfs.FS + cancel context.CancelFunc + once sync.Once +} + +func (f *cancellingBuildFS) ReadFile(path tspath.RootedFilePath) (string, bool) { + content, ok := f.FS.ReadFile(path) + if path == "/home/src/workspaces/project/leaf/a.ts" { + f.once.Do(f.cancel) + } + return content, ok +} + +type cancellingBuildSystem struct { + *TestSys + fs vfs.FS +} + +func (s *cancellingBuildSystem) FS() vfs.FS { + return s.fs +} + +func TestBuildCancellationAcrossReferencesAndRetry(t *testing.T) { + t.Parallel() + sys := newTestSys(&tscInput{ + files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{"files":[],"references":[{"path":"./middle"}]}`, + "/home/src/workspaces/project/middle/tsconfig.json": `{"compilerOptions":{"composite":true},"references":[{"path":"../leaf"}]}`, + "/home/src/workspaces/project/middle/b.ts": `import { a } from "../leaf/a"; export const b = a;`, + "/home/src/workspaces/project/leaf/tsconfig.json": `{"compilerOptions":{"composite":true}}`, + "/home/src/workspaces/project/leaf/a.ts": `export const a = 1;`, + }, + commandLineArgs: []string{"--build", "--extendedDiagnostics"}, + }, false) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + wrapped := &cancellingBuildSystem{ + TestSys: sys, + fs: &cancellingBuildFS{FS: sys.FS(), cancel: cancel}, + } + orchestrator := build.NewOrchestrator(build.Options{ + Sys: wrapped, + Command: tsoptions.ParseBuildCommandLine([]string{"--extendedDiagnostics"}, wrapped.FS(), sys.GetCurrentDirectory()), + Testing: sys, + }) + result := orchestrator.Build(ctx, "") + assert.Equal(t, result.Result.Status, tsc.ExitStatusCancelled) + assert.Equal(t, len(result.Errors), 0) + assert.Assert(t, !strings.Contains(sys.currentWrite.String(), "Total time:")) + assert.Assert(t, !wrapped.FS().FileExists("/home/src/workspaces/project/leaf/a.js")) + assert.Assert(t, !wrapped.FS().FileExists("/home/src/workspaces/project/middle/b.js")) + + result = orchestrator.Build(t.Context(), "") + assert.Equal(t, result.Result.Status, tsc.ExitStatusSuccess) + assert.Equal(t, len(result.Errors), 0) + assert.Assert(t, wrapped.FS().FileExists("/home/src/workspaces/project/leaf/a.js")) + assert.Assert(t, wrapped.FS().FileExists("/home/src/workspaces/project/middle/b.js")) +} + func TestBuildCommandLine(t *testing.T) { t.Parallel() getBuildCommandLineDifferentOptionsMap := func(optionName string) FileMap { diff --git a/tsc/internal/execute/tsctests/watch_shallow_test.go b/tsc/internal/execute/tsctests/watch_shallow_test.go index 3465f4f8534eb..48eeac0f0f1c5 100644 --- a/tsc/internal/execute/tsctests/watch_shallow_test.go +++ b/tsc/internal/execute/tsctests/watch_shallow_test.go @@ -38,19 +38,19 @@ func assertShallowProjectWatches(t *testing.T, sys *TestSys) { assert.Assert(t, !isWatched(sys, "/"), "/ must never be watched") } -func editShallowProjectFiles(t *testing.T, sys *TestSys, w interface{ DoCycle() }) { +func editShallowProjectFiles(t *testing.T, sys *TestSys, w interface{ DoCycle(ctx context.Context) }) { t.Helper() fs := sys.fsFromFileMap() sys.writeFileNoError("/shared/s.ts", `export const s = 2;`) sys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: "/shared/s.ts"}}) - w.DoCycle() + w.DoCycle(t.Context()) out, _ := fs.ReadFile("/app/out/shared/s.js") assert.Assert(t, strings.Contains(out, "s = 2"), "editing /shared/s.ts must rebuild, got:\n%s", out) sys.writeFileNoError("/app/index.ts", `import { s } from "../shared/s"; export const y = s;`) sys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: "/app/index.ts"}}) - w.DoCycle() + w.DoCycle(t.Context()) out, _ = fs.ReadFile("/app/out/app/index.js") assert.Assert(t, strings.Contains(out, "y = "), "editing /app/index.ts must rebuild, got:\n%s", out) } @@ -92,20 +92,20 @@ func shallowRootFileProjectFiles(compilerOptions string) FileMap { // deleteAndRecreateShallowRootFile deletes /shared/root.ts and writes it back. While the file is missing it is not // part of the program, but it is still a root file, so /shared must stay watched for the rebuild on recreation. -func deleteAndRecreateShallowRootFile(t *testing.T, sys *TestSys, w interface{ DoCycle() }) { +func deleteAndRecreateShallowRootFile(t *testing.T, sys *TestSys, w interface{ DoCycle(ctx context.Context) }) { t.Helper() fs := sys.fsFromFileMap() assert.Assert(t, isWatched(sys, "/shared"), "the directory of the root file /shared/root.ts must be watched") sys.removeNoError("/shared/root.ts") sys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventDelete, Path: "/shared/root.ts"}}) - w.DoCycle() + w.DoCycle(t.Context()) assert.Assert(t, isWatched(sys, "/shared"), "/shared must stay watched while the root file /shared/root.ts is missing") assert.Assert(t, !isWatched(sys, "/"), "/ must never be watched") sys.writeFileNoError("/shared/root.ts", `export const r = 2;`) sys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: "/shared/root.ts"}}) - w.DoCycle() + w.DoCycle(t.Context()) out, _ := fs.ReadFile("/app/out/shared/root.js") assert.Assert(t, strings.Contains(out, "r = 2"), "recreating /shared/root.ts must rebuild, got:\n%s", out) } diff --git a/tsc/internal/execute/tsctests/watcher_race_test.go b/tsc/internal/execute/tsctests/watcher_race_test.go index 39ff61f3c6b85..506b8a16350ff 100644 --- a/tsc/internal/execute/tsctests/watcher_race_test.go +++ b/tsc/internal/execute/tsctests/watcher_race_test.go @@ -40,6 +40,45 @@ func createTestWatcher(t *testing.T) (*execute.Watcher, *TestSys) { return w, sys } +func TestWatchCycleStopsWithCancelledContext(t *testing.T) { + t.Parallel() + for _, build := range []bool{false, true} { + t.Run(fmt.Sprintf("build=%t", build), func(t *testing.T) { + t.Parallel() + args := []string{"--watch"} + if build { + args = append([]string{"--build"}, args...) + } + sys := newTestSys(&tscInput{ + files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{"compilerOptions":{"composite":true,"outDir":"out"}}`, + "/home/src/workspaces/project/a.ts": `export const a = 1;`, + }, + commandLineArgs: args, + }, false) + result := execute.CommandLine(t.Context(), sys, args, sys) + assert.Assert(t, result.Watcher != nil) + output, ok := sys.FS().ReadFile("/home/src/workspaces/project/out/a.js") + assert.Assert(t, ok) + sys.writeFileNoError("/home/src/workspaces/project/a.ts", `export const a = 2;`) + sys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: "/home/src/workspaces/project/a.ts"}}) + sys.clearOutput() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + result.Watcher.DoCycle(ctx) + after, ok := sys.FS().ReadFile("/home/src/workspaces/project/out/a.js") + assert.Assert(t, ok) + assert.Equal(t, after, output) + assert.Equal(t, sys.currentWrite.Len(), 0) + + result.Watcher.DoCycle(t.Context()) + after, ok = sys.FS().ReadFile("/home/src/workspaces/project/out/a.js") + assert.Assert(t, ok) + assert.Assert(t, strings.Contains(after, "a = 2")) + }) + } +} + // TestWatcherConcurrentDoCycle calls DoCycle from multiple goroutines // while modifying source files, exposing data races on Watcher fields // such as configModified, program, config, and the underlying @@ -59,7 +98,7 @@ func TestWatcherConcurrentDoCycle(t *testing.T) { "/home/src/workspaces/project/a.ts", fmt.Sprintf("const a: number = %d;", i*10+j), ) - w.DoCycle() + w.DoCycle(t.Context()) } }(i) } @@ -86,7 +125,7 @@ func TestWatcherDoCycleWithConcurrentStateReads(t *testing.T) { "/home/src/workspaces/project/a.ts", fmt.Sprintf("const a: number = %d;", i*15+j), ) - w.DoCycle() + w.DoCycle(t.Context()) } }(i) } @@ -95,10 +134,10 @@ func TestWatcherDoCycleWithConcurrentStateReads(t *testing.T) { for range 8 { wg.Go(func() { for range 50 { - w.DoCycle() - w.DoCycle() - w.DoCycle() - w.DoCycle() + w.DoCycle(t.Context()) + w.DoCycle(t.Context()) + w.DoCycle(t.Context()) + w.DoCycle(t.Context()) } }) } @@ -140,7 +179,7 @@ func TestWatcherConcurrentFileChangesAndDoCycle(t *testing.T) { for range 4 { wg.Go(func() { for range 10 { - w.DoCycle() + w.DoCycle(t.Context()) } }) } @@ -175,7 +214,7 @@ func TestWatcherRapidConfigChanges(t *testing.T) { "/home/src/workspaces/project/tsconfig.json", configs[(i+j)%len(configs)], ) - w.DoCycle() + w.DoCycle(t.Context()) } }(i) } @@ -190,7 +229,7 @@ func TestWatcherRapidConfigChanges(t *testing.T) { "/home/src/workspaces/project/a.ts", fmt.Sprintf("const a: number = %d;", i*15+j), ) - w.DoCycle() + w.DoCycle(t.Context()) } }(i) } @@ -199,8 +238,8 @@ func TestWatcherRapidConfigChanges(t *testing.T) { for range 4 { wg.Go(func() { for range 30 { - w.DoCycle() - w.DoCycle() + w.DoCycle(t.Context()) + w.DoCycle(t.Context()) } }) } @@ -220,7 +259,7 @@ func TestWatcherConcurrentDoCycleNoChanges(t *testing.T) { for range 16 { wg.Go(func() { for range 50 { - w.DoCycle() + w.DoCycle(t.Context()) } }) } @@ -251,7 +290,7 @@ func TestWatcherAlternatingModifyAndDoCycle(t *testing.T) { for range 4 { wg.Go(func() { for range 25 { - w.DoCycle() + w.DoCycle(t.Context()) } }) } @@ -260,7 +299,7 @@ func TestWatcherAlternatingModifyAndDoCycle(t *testing.T) { for range 4 { wg.Go(func() { for range 100 { - w.DoCycle() + w.DoCycle(t.Context()) } }) } @@ -287,8 +326,8 @@ func TestBuildWatchStopsWhenContextIsCancelled(t *testing.T) { select { case result := <-resultCh: - assert.Equal(t, result.Status, tsc.ExitStatusSuccess) - assert.Assert(t, result.Watcher != nil) + assert.Equal(t, result.Status, tsc.ExitStatusCancelled) + assert.Assert(t, result.Watcher == nil) case <-time.After(2 * time.Second): t.Fatal("build watch did not stop after context cancellation") } @@ -345,7 +384,7 @@ export const x =
;`) sys.mockWatchBackend.SendEvents([]fswatch.Event{ {Kind: fswatch.EventUpdate, Path: "/home/src/workspaces/project/index.tsx"}, }) - w.DoCycle() + w.DoCycle(t.Context()) out := sys.currentWrite.String() assert.Assert(t, strings.Contains(out, "bar/jsx-runtime"), "expected updated JSX runtime diagnostic, got: %s", out) @@ -384,7 +423,7 @@ func TestWatcherUpdateProgramFastPath(t *testing.T) { sys.mockWatchBackend.SendEvents([]fswatch.Event{ {Kind: fswatch.EventUpdate, Path: path}, }) - w.DoCycle() + w.DoCycle(t.Context()) return sys.currentWrite.String() } @@ -462,7 +501,7 @@ func TestWatcherOverflowForcesFullRebuild(t *testing.T) { _ = fs.WriteFile("/home/src/workspaces/project/dep.ts", `export const dep: number = 1;`) full := w.FullBuilds() sys.mockWatchBackend.SendOverflow() - w.DoCycle() + w.DoCycle(t.Context()) assert.Equal(t, w.FullBuilds(), full+1, "overflow must force a full rebuild, not the single-file fast path") assert.Assert(t, fs.FileExists("/home/src/workspaces/project/out/dep.js"), @@ -512,7 +551,7 @@ func TestWatcherNonSourceDependencyForcesFullRebuild(t *testing.T) { {Kind: fswatch.EventUpdate, Path: "/home/src/workspaces/project/index.ts"}, {Kind: fswatch.EventUpdate, Path: "/home/src/workspaces/project/dep.ts"}, }) - w.DoCycle() + w.DoCycle(t.Context()) assert.Equal(t, w.FullBuilds(), full+1, "a changed non-source dependency must force a full rebuild, not the fast path") diff --git a/tsc/internal/execute/watcher.go b/tsc/internal/execute/watcher.go index 880849560a782..d7b25b8e00130 100644 --- a/tsc/internal/execute/watcher.go +++ b/tsc/internal/execute/watcher.go @@ -164,7 +164,7 @@ func (w *Watcher) start(ctx context.Context) { w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode)) w.watchSetDirty = true - if err := w.doBuild(); err != nil { + if err := w.doBuild(ctx); err != nil { w.wm.ForceOverflow() } w.wm.Unlock() @@ -288,9 +288,12 @@ func (w *Watcher) caseSensitivity() tspath.CaseSensitivity { return w.sys.FS().CaseSensitivity() } -func (w *Watcher) DoCycle() { +func (w *Watcher) DoCycle(ctx context.Context) { w.wm.Lock() defer w.wm.Unlock() + if ctx.Err() != nil { + return + } changedPaths, overflow := w.wm.DrainEvents() hasEvents := len(changedPaths) > 0 || overflow @@ -381,7 +384,7 @@ func (w *Watcher) DoCycle() { } w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.File_change_detected_Starting_incremental_compilation)) - if err := w.doBuild(); err != nil { + if err := w.doBuild(ctx); err != nil { // Mid-cycle watch failure; force a full rebuild on the next event w.wm.ForceOverflow() } @@ -416,7 +419,10 @@ func (w *Watcher) isRelevantChange(changedPaths map[tspath.RootedPath]fswatch.Ev return false } -func (w *Watcher) doBuild() error { +func (w *Watcher) doBuild(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } if w.configModified { w.sourceFileCache = &collections.SyncMap[tspath.PathKey, *cachedSourceFile]{} w.watchSetDirty = true @@ -451,8 +457,11 @@ func (w *Watcher) doBuild() error { if w.tryUpdateProgram(host) { w.fastPathBuilds++ - result := w.compileAndEmit() + result := w.compileAndEmit(ctx) cached.DisableAndClearCache() + if err := ctx.Err(); err != nil { + return err + } w.configMtimes = make(map[tspath.RootedFilePath]time.Time, len(w.configFilePaths)) for _, cfgPath := range w.configFilePaths { @@ -502,15 +511,23 @@ func (w *Watcher) doBuild() error { tfs.SeenFiles.Add(path.AsPath()) } - w.program = incremental.NewProgram(compiler.NewProgram(compiler.ProgramOptions{ + program, err := compiler.NewProgram(ctx, compiler.ProgramOptions{ Config: w.config, Host: host, - }), w.program, nil, w.sys.Now, w.testing != nil) + }) + if err != nil { + cached.DisableAndClearCache() + return err + } + w.program = incremental.NewProgram(program, w.program, nil, w.sys.Now, w.testing != nil) w.programReady = true w.fullBuilds++ - result := w.compileAndEmit() + result := w.compileAndEmit(ctx) cached.DisableAndClearCache() + if err := ctx.Err(); err != nil { + return err + } caseSensitivity := w.sys.FS().CaseSensitivity() seenSlice := tfs.SeenFiles.ToSlice() @@ -625,8 +642,8 @@ func (w *Watcher) evictChangedSourceFiles(changedPaths map[tspath.RootedPath]fsw } } -func (w *Watcher) compileAndEmit() tsc.CompileAndEmitResult { - return tsc.EmitFilesAndReportErrors(tsc.EmitInput{ +func (w *Watcher) compileAndEmit(ctx context.Context) tsc.CompileAndEmitResult { + return tsc.EmitFilesAndReportErrors(ctx, tsc.EmitInput{ Sys: w.sys, ProgramLike: w.program, Program: w.program.GetProgram(), diff --git a/tsc/internal/execute/watchmanager/watchmanager.go b/tsc/internal/execute/watchmanager/watchmanager.go index c8eb3f1c4abd0..f9c3b1ca482bc 100644 --- a/tsc/internal/execute/watchmanager/watchmanager.go +++ b/tsc/internal/execute/watchmanager/watchmanager.go @@ -379,14 +379,14 @@ func (wm *WatchManager) IsPathUnderWatch(path tspath.RootedPath) bool { return false } -func (wm *WatchManager) RunLoop(ctx context.Context, doCycle func()) { +func (wm *WatchManager) RunLoop(ctx context.Context, doCycle func(context.Context)) { for { select { case <-ctx.Done(): wm.CloseAllWatches() return case <-wm.doCycleCh: - doCycle() + doCycle(ctx) } } } diff --git a/tsc/internal/ls/findallreferences_test.go b/tsc/internal/ls/findallreferences_test.go index 2065d683ab85f..223a5c61125f7 100644 --- a/tsc/internal/ls/findallreferences_test.go +++ b/tsc/internal/ls/findallreferences_test.go @@ -54,7 +54,8 @@ func TestImplementationsWorklistDoesNotBlowUp(t *testing.T) { host := compiler.NewCompilerHost(fs, bundled.LibPath(), nil, nil, nil) parsed, errors := tsoptions.GetParsedCommandLineOfConfigFile("/tsconfig.json", &core.CompilerOptions{}, nil, fs, nil) assert.Equal(t, len(errors), 0) - program := compiler.NewProgram(compiler.ProgramOptions{Config: parsed, Host: host}) + program, err := compiler.NewProgram(t.Context(), compiler.ProgramOptions{Config: parsed, Host: host}) + assert.NilError(t, err) program.BindSourceFiles() program.GetSemanticDiagnostics(context.Background(), program.GetSourceFile("/repro.ts")) diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index f88a09b297a35..80fcf3e782ca3 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -513,10 +513,13 @@ type CreateProgramResult struct { UpdateKind ProgramUpdateKind } -func (p *Project) CreateProgram() CreateProgramResult { +func (p *Project) CreateProgram(ctx context.Context) CreateProgramResult { + // Snapshot construction must finish so all acquired parse-cache entries have an owner. + programCtx := context.WithoutCancel(ctx) updateKind := ProgramUpdateKindNewFiles var programCloned bool var newProgram *compiler.Program + var err error createCheckerPool := func(program *compiler.Program) compiler.CheckerPool { return newCheckerPool(p.host.sessionOptions.CheckerPoolOptions, program, p.log) @@ -526,7 +529,7 @@ func (p *Project) CreateProgram() CreateProgramResult { if p.moduleResolverFactory == nil { return module.NewResolver(options) } - resolver, cleanup := p.moduleResolverFactory.NewResolver(p.host.builder.ctx, options) + resolver, cleanup := p.moduleResolverFactory.NewResolver(ctx, options) cleanupModuleResolver = cleanup return resolver } @@ -540,7 +543,7 @@ func (p *Project) CreateProgram() CreateProgramResult { commandLine := p.getCommandLineWithTypingsFiles() if p.dirtyFilePath != "" && p.Program != nil && p.Program.CommandLine() == commandLine { var dirtyFile *ast.SourceFile - newProgram, dirtyFile, programCloned = p.Program.UpdateProgram(p.dirtyFilePath, p.host, createCheckerPool, createModuleResolver) + newProgram, dirtyFile, programCloned, err = p.Program.UpdateProgram(programCtx, p.dirtyFilePath, p.host, createCheckerPool, createModuleResolver) if programCloned { updateKind = ProgramUpdateKindCloned for _, file := range newProgram.SourceFiles() { @@ -579,7 +582,8 @@ func (p *Project) CreateProgram() CreateProgramResult { if p.GetTypeAcquisition().Enable.IsTrue() { typingsLocation = p.host.sessionOptions.TypingsLocation } - newProgram = compiler.NewProgram( + newProgram, err = compiler.NewProgram( + programCtx, compiler.ProgramOptions{ Host: p.host, Config: commandLine, @@ -590,6 +594,9 @@ func (p *Project) CreateProgram() CreateProgramResult { }, ) } + if err != nil { + panic(err) + } if !programCloned && p.Program != nil && p.Program.HasSameFileNames(newProgram) { updateKind = ProgramUpdateKindSameFileNames diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 2a190503e8468..126755995b6a7 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -1504,7 +1504,7 @@ func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo oldProgram := project.Program oldCheckerPool := project.checkerPool project.host = newCompilerHost(project, b, logger.Fork("CompilerHost")) - result := project.CreateProgram() + result := project.CreateProgram(b.ctx) var watchedFiles []tspath.RootedFilePath for _, mapper := range project.CommandLine.ContentMappers() { if mapper.Package != "" && mapper.ContributionID == "" && mapper.PackageDirectory != "" { diff --git a/tsc/internal/project/refcountcache_test.go b/tsc/internal/project/refcountcache_test.go index 396a1a3921878..6e2f36a3b1b21 100644 --- a/tsc/internal/project/refcountcache_test.go +++ b/tsc/internal/project/refcountcache_test.go @@ -594,7 +594,9 @@ func TestRefCountingCaches(t *testing.T) { uri := lsproto.DocumentUri("file:///user/username/projects/myproject/src/main.ts") baseSnapshot := session.Snapshot() extendedConfigPath := tspath.PathKey("/user/username/projects/myproject/tsconfig.base.json") - clone := baseSnapshot.Clone(context.Background(), SnapshotChange{ + ctx, cancel := context.WithCancel(t.Context()) + cancel() + clone := baseSnapshot.Clone(ctx, SnapshotChange{ reason: UpdateReasonRequestedLanguageServiceProjectNotLoaded, Documents: []lsproto.DocumentUri{uri}, }, baseSnapshot.overlays(), nil, nil) diff --git a/tsc/internal/testutil/harnessutil/harnessutil.go b/tsc/internal/testutil/harnessutil/harnessutil.go index 3b86a398b281b..eac042872b6de 100644 --- a/tsc/internal/testutil/harnessutil/harnessutil.go +++ b/tsc/internal/testutil/harnessutil/harnessutil.go @@ -223,7 +223,7 @@ func CompileFilesEx( defer contentMapperProject.Close() } host := createCompilerHost(fs, bundled.LibPath(), currentDirectory, contentMapperProject) - result := compileFilesWithHost(host, config, harnessOptions) + result := compileFilesWithHost(t, host, config, harnessOptions) result.Symlinks = symlinks result.Trace = host.tracer.String() result.Repeat = func(testConfig TestConfiguration) *CompilationResult { @@ -600,6 +600,7 @@ func createCompilerHost(fs vfs.FS, defaultLibraryPath tspath.RootedDirectoryPath } func compileFilesWithHost( + t *testing.T, host compiler.CompilerHost, config *tsoptions.ParsedCommandLine, harnessOptions *HarnessOptions, @@ -619,7 +620,7 @@ func compileFilesWithHost( // delete compilerOptions.project; // } - ctx := context.Background() + ctx := t.Context() var preErrors []*ast.Diagnostic preCompilerOptions := config.CompilerOptions().Clone() @@ -634,7 +635,7 @@ func compileFilesWithHost( preConfig.ParsedConfig.ContentMappers = config.ContentMappers() preConfig.ConfigFile = config.ConfigFile preConfig.Errors = config.Errors - preProgram := createProgram(host, preConfig) + preProgram := createProgram(t, host, preConfig) preErrors = append(preErrors, preProgram.GetConfigFileParsingDiagnostics()...) preErrors = append(preErrors, preProgram.GetProgramDiagnostics()...) preErrors = append(preErrors, preProgram.GetSyntacticDiagnostics(ctx, nil)...) @@ -648,7 +649,7 @@ func compileFilesWithHost( } preErrors = compiler.SortAndDeduplicateDiagnostics(preErrors) - postProgram := createProgram(host, config) + postProgram := createProgram(t, host, config) emitResult := postProgram.Emit(ctx, compiler.EmitOptions{}) var postErrors []*ast.Diagnostic postErrors = append(postErrors, postProgram.GetConfigFileParsingDiagnostics()...) @@ -964,7 +965,8 @@ func getTestBuildInfoReader(host compiler.CompilerHost) *testBuildInfoReader { return &testBuildInfoReader{inner: incremental.NewBuildInfoReader(host)} } -func createProgram(host compiler.CompilerHost, config *tsoptions.ParsedCommandLine) compiler.ProgramLike { +func createProgram(t *testing.T, host compiler.CompilerHost, config *tsoptions.ParsedCommandLine) compiler.ProgramLike { + t.Helper() var singleThreaded core.Tristate if testutil.TestProgramIsSingleThreaded() { singleThreaded = core.TSTrue @@ -975,7 +977,10 @@ func createProgram(host compiler.CompilerHost, config *tsoptions.ParsedCommandLi Host: host, SingleThreaded: singleThreaded, } - program := compiler.NewProgram(programOptions) + program, err := compiler.NewProgram(t.Context(), programOptions) + if err != nil { + t.Fatal(err) + } if config.CompilerOptions().Incremental.IsTrue() { oldProgram := incremental.ReadBuildInfoProgram(config, getTestBuildInfoReader(host), host) incrementalProgram := incremental.NewProgram(program, oldProgram, incremental.CreateHost(host), nil, false) diff --git a/tsc/internal/transpile/transpile.go b/tsc/internal/transpile/transpile.go index a7e040e44bf68..e1a95a2875e11 100644 --- a/tsc/internal/transpile/transpile.go +++ b/tsc/internal/transpile/transpile.go @@ -150,11 +150,14 @@ func transpileWorker(ctx context.Context, input string, options Options, declara programFS := &transpileFS{files: files} host := compiler.NewCompilerHost(programFS, libDirectory, nil, nil, nil) - program := compiler.NewProgram(compiler.ProgramOptions{ + program, err := compiler.NewProgram(ctx, compiler.ProgramOptions{ Config: tsoptions.NewParsedCommandLine(opts, []tspath.RootedFilePath{inputFileName}, nil, inputDirectory, programFS.CaseSensitivity()), Host: host, SkipModuleResolution: true, }) + if err != nil { + return nil + } var allDiagnostics []*ast.Diagnostic if options.ReportDiagnostics { From 7df07c92898a956723c0d121bc494bcf165a0567 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:48:20 -0700 Subject: [PATCH 4/4] Stop build graph and cleanup work on cancellation Interruption could still wait for the complete project reference graph, watch reconciliation, or deletion of every project output after compiler work had stopped. These build phases must also observe cancellation. Incomplete graphs must not survive for a later request, and cleanup must leave remaining outputs intact rather than continue deleting after interruption. --- tsc/internal/api/session.go | 4 +- tsc/internal/execute/build/buildtask.go | 24 ++- tsc/internal/execute/build/clean_test.go | 109 +++++++++-- tsc/internal/execute/build/graph_test.go | 6 +- tsc/internal/execute/build/orchestrator.go | 171 +++++++++++++----- .../execute/tsctests/tscbuild_test.go | 114 ++++++++---- .../execute/tsctests/watcher_race_test.go | 47 +++++ 7 files changed, 371 insertions(+), 104 deletions(-) diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index e44b35895353b..b4fd02a9a0ca8 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -1923,7 +1923,7 @@ func (s *Session) handleCleanBuild(ctx context.Context, params *CleanBuildParams if s.buildOrchestrators[params.BuildOrchestratorID] == nil { return nil, fmt.Errorf("build orchestrator not found while cleaning %s", params.Project) } - result := s.buildOrchestrators[params.BuildOrchestratorID].Clean(params.Project) + result := s.buildOrchestrators[params.BuildOrchestratorID].Clean(ctx, params.Project) return &CleanBuildResponse{ Status: result.Result.Status, Diagnostics: NewDiagnosticResponses(result.Errors), @@ -1938,7 +1938,7 @@ func (s *Session) handleCleanReferences(ctx context.Context, params *CleanBuildP if s.buildOrchestrators[params.BuildOrchestratorID] == nil { return nil, fmt.Errorf("build orchestrator not found while cleaning references for %s", params.Project) } - result := s.buildOrchestrators[params.BuildOrchestratorID].CleanReferences(params.Project) + result := s.buildOrchestrators[params.BuildOrchestratorID].CleanReferences(ctx, params.Project) return &CleanBuildResponse{ Status: result.Result.Status, Diagnostics: NewDiagnosticResponses(result.Errors), diff --git a/tsc/internal/execute/build/buildtask.go b/tsc/internal/execute/build/buildtask.go index 97bad9454861e..1b05863b74cb6 100644 --- a/tsc/internal/execute/build/buildtask.go +++ b/tsc/internal/execute/build/buildtask.go @@ -807,7 +807,11 @@ func (t *BuildTask) updateTimeStamps(orchestrator *Orchestrator, emittedFiles [] updateTimeStamp(t.resolved.GetBuildInfoFileName()) } -func (t *BuildTask) cleanProject(orchestrator *Orchestrator, path tspath.PathKey) { +func (t *BuildTask) cleanProject(ctx context.Context, orchestrator *Orchestrator, path tspath.PathKey) { + if ctx.Err() != nil { + t.result.exitStatus = tsc.ExitStatusCancelled + return + } if t.resolved == nil { t.reportDiagnostic(ast.NewCompilerDiagnostic(diagnostics.File_0_not_found, t.config)) t.result.exitStatus = tsc.ExitStatusDiagnosticsPresent_OutputsSkipped @@ -818,18 +822,30 @@ func (t *BuildTask) cleanProject(orchestrator *Orchestrator, path tspath.PathKey return orchestrator.caseSensitivity.PathKey(fileName.AsPath()) })...) for outputFile := range t.resolved.GetOutputFileNames() { - t.cleanProjectOutput(orchestrator, outputFile, inputs) + if ctx.Err() != nil { + break + } + t.cleanProjectOutput(ctx, orchestrator, outputFile, inputs) + } + t.cleanProjectOutput(ctx, orchestrator, t.resolved.GetBuildInfoFileName(), inputs) + if ctx.Err() != nil { + t.result.exitStatus = tsc.ExitStatusCancelled } - t.cleanProjectOutput(orchestrator, t.resolved.GetBuildInfoFileName(), inputs) } -func (t *BuildTask) cleanProjectOutput(orchestrator *Orchestrator, outputFile tspath.RootedFilePath, inputs *collections.Set[tspath.PathKey]) { +func (t *BuildTask) cleanProjectOutput(ctx context.Context, orchestrator *Orchestrator, outputFile tspath.RootedFilePath, inputs *collections.Set[tspath.PathKey]) { + if ctx.Err() != nil { + return + } outputPath := orchestrator.caseSensitivity.PathKey(tspath.RootedPath(outputFile)) // If output name is same as input file name, do not delete and ignore the error if inputs.Has(outputPath) { return } if orchestrator.host.FS().FileExists(outputFile) { + if ctx.Err() != nil { + return + } if !orchestrator.opts.Command.BuildOptions.Dry.IsTrue() { err := orchestrator.host.FS().Remove(outputFile.AsPath()) if err != nil { diff --git a/tsc/internal/execute/build/clean_test.go b/tsc/internal/execute/build/clean_test.go index e1303b93aa5ba..5a35c9741c14a 100644 --- a/tsc/internal/execute/build/clean_test.go +++ b/tsc/internal/execute/build/clean_test.go @@ -1,6 +1,7 @@ package build_test import ( + "context" "io" "strings" "testing" @@ -10,6 +11,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/execute/tsctests" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" "gotest.tools/v3/assert" ) @@ -21,7 +23,7 @@ func TestClean(t *testing.T) { sys := newCleanTestSystem() orchestrator := newCleanTestOrchestrator(sys, "a", "c") - result := orchestrator.Clean("a") + result := orchestrator.Clean(t.Context(), "a") assert.Equal(t, result.Result.Status, tsc.ExitStatusSuccess) assert.Equal(t, result.Statistics.Projects, 2) assert.Assert(t, !sys.FS().FileExists("/project/a/dist/index.js")) @@ -34,7 +36,7 @@ func TestClean(t *testing.T) { sys := newCleanTestSystem() orchestrator := newCleanTestOrchestrator(sys, "--dry", "a") - result := orchestrator.Clean("a") + result := orchestrator.Clean(t.Context(), "a") assert.Equal(t, result.Result.Status, tsc.ExitStatusSuccess) assert.Equal(t, result.Statistics.Projects, 2) assert.Assert(t, len(result.FilesToDelete) > 0) @@ -47,7 +49,7 @@ func TestClean(t *testing.T) { sys := newCleanTestSystem() orchestrator := newCleanTestOrchestrator(sys, "a") - result := orchestrator.Clean("c") + result := orchestrator.Clean(t.Context(), "c") assert.Equal(t, result.Result.Status, tsc.ExitStatusInvalidProject_OutputsSkipped) assert.Assert(t, sys.FS().FileExists("/project/a/dist/index.js")) assert.Assert(t, sys.FS().FileExists("/project/b/dist/index.js")) @@ -59,7 +61,7 @@ func TestClean(t *testing.T) { sys := newCleanTestSystem() orchestrator := newCleanTestOrchestrator(sys, "cycle1") - result := orchestrator.Clean("cycle1") + result := orchestrator.Clean(t.Context(), "cycle1") assert.Equal(t, result.Result.Status, tsc.ExitStatusProjectReferenceCycle_OutputsSkipped) assert.Assert(t, len(result.Errors) > 0) assert.Assert(t, sys.FS().FileExists("/project/cycle1/dist/index.js")) @@ -67,9 +69,80 @@ func TestClean(t *testing.T) { }) } +func TestCleanCancellation(t *testing.T) { + t.Parallel() + for _, entrypoint := range []string{"CLI", "Clean", "CleanReferences"} { + for _, phase := range []string{"before clean", "before remove", "after remove"} { + t.Run(entrypoint+"/"+phase, func(t *testing.T) { + t.Parallel() + sys := newCleanTestSystem() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + fs := &cancellingCleanFS{FS: sys.FS(), cancel: cancel, cancelBeforeRemove: phase == "before remove"} + sys.fs = fs + if phase == "before clean" { + cancel() + } + orchestrator := newCleanTestOrchestrator(sys, "--clean", "--singleThreaded", "--extendedDiagnostics", "a") + + var result tsc.CommandLineResult + switch entrypoint { + case "CLI": + result = orchestrator.Start(ctx) + case "Clean": + result = orchestrator.Clean(ctx, "a").Result + case "CleanReferences": + result = orchestrator.CleanReferences(ctx, "a").Result + } + + assert.Equal(t, result.Status, tsc.ExitStatusCancelled) + expectedRemovals := 0 + if phase == "after remove" { + expectedRemovals = 1 + } + assert.Equal(t, fs.removals, expectedRemovals) + assert.Assert(t, !strings.Contains(sys.output.String(), "Total time:")) + assert.Assert(t, sys.FS().FileExists("/project/a/dist/index.js")) + assert.Assert(t, sys.FS().FileExists("/project/a/dist/index.d.ts")) + assert.Assert(t, sys.FS().FileExists("/project/a/dist/tsconfig.tsbuildinfo")) + assert.Assert(t, sys.FS().FileExists("/project/b/dist/tsconfig.tsbuildinfo")) + }) + } + } +} + +type cancellingCleanFS struct { + vfs.FS + cancel context.CancelFunc + cancelBeforeRemove bool + removals int +} + +func (fs *cancellingCleanFS) FileExists(path tspath.RootedFilePath) bool { + exists := fs.FS.FileExists(path) + if exists && fs.cancelBeforeRemove && strings.Contains(path.AsString(), "/dist/") { + fs.cancel() + } + return exists +} + +func (fs *cancellingCleanFS) Remove(path tspath.RootedPath) error { + err := fs.FS.Remove(path) + if err == nil { + fs.removals++ + fs.cancel() + } + return err +} + type cleanTestSystem struct { *tsctests.TestSys output strings.Builder + fs vfs.FS +} + +func (s *cleanTestSystem) FS() vfs.FS { + return s.fs } func (s *cleanTestSystem) Writer() io.Writer { @@ -81,23 +154,25 @@ func (s *cleanTestSystem) ErrorWriter() io.Writer { } func newCleanTestSystem() *cleanTestSystem { - return &cleanTestSystem{TestSys: tsctests.NewTscSystem(tsctests.FileMap{ + sys := &cleanTestSystem{TestSys: tsctests.NewTscSystem(tsctests.FileMap{ "/project/a/tsconfig.json": `{ "compilerOptions": { "composite": true, "noLib": true, "outDir": "dist" }, "files": ["index.ts"], "references": [{ "path": "../b" }] }`, - "/project/a/index.ts": "export const a = 1;", - "/project/a/dist/index.js": "export const a = 1;", - "/project/a/dist/index.d.ts": "export declare const a = 1;", - "/project/b/tsconfig.json": `{ "compilerOptions": { "composite": true, "noLib": true, "outDir": "dist" }, "files": ["index.ts"] }`, - "/project/b/index.ts": "export const b = 1;", - "/project/b/dist/index.js": "export const b = 1;", - "/project/b/dist/index.d.ts": "export declare const b = 1;", - "/project/c/tsconfig.json": `{ "compilerOptions": { "composite": true, "noLib": true, "outDir": "dist" }, "files": ["index.ts"] }`, - "/project/c/index.ts": "export const c = 1;", - "/project/c/dist/index.js": "export const c = 1;", - "/project/c/dist/index.d.ts": "export declare const c = 1;", + "/project/a/index.ts": "export const a = 1;", + "/project/a/dist/index.js": "export const a = 1;", + "/project/a/dist/index.d.ts": "export declare const a = 1;", + "/project/a/dist/tsconfig.tsbuildinfo": `{}`, + "/project/b/tsconfig.json": `{ "compilerOptions": { "composite": true, "noLib": true, "outDir": "dist" }, "files": ["index.ts"] }`, + "/project/b/index.ts": "export const b = 1;", + "/project/b/dist/index.js": "export const b = 1;", + "/project/b/dist/index.d.ts": "export declare const b = 1;", + "/project/b/dist/tsconfig.tsbuildinfo": `{}`, + "/project/c/tsconfig.json": `{ "compilerOptions": { "composite": true, "noLib": true, "outDir": "dist" }, "files": ["index.ts"] }`, + "/project/c/index.ts": "export const c = 1;", + "/project/c/dist/index.js": "export const c = 1;", + "/project/c/dist/index.d.ts": "export declare const c = 1;", "/project/cycle1/tsconfig.json": `{ "compilerOptions": { "composite": true, "noLib": true, "outDir": "dist" }, "files": ["index.ts"], @@ -113,6 +188,8 @@ func newCleanTestSystem() *cleanTestSystem { "/project/cycle2/index.ts": "export const cycle2 = 1;", "/project/cycle2/dist/index.js": "export const cycle2 = 1;", }, tspath.CaseSensitive, "/project")} + sys.fs = sys.TestSys.FS() + return sys } func newCleanTestOrchestrator(sys tsc.System, args ...string) *build.Orchestrator { diff --git a/tsc/internal/execute/build/graph_test.go b/tsc/internal/execute/build/graph_test.go index ab7198a903d67..1de1f80d50a6f 100644 --- a/tsc/internal/execute/build/graph_test.go +++ b/tsc/internal/execute/build/graph_test.go @@ -117,7 +117,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(), func(config tspath.RootedFilePath) string { return b.projectName(config.AsString()) }) assert.DeepEqual(t, buildOrder, b.expected) verifyDeps(orchestrator, buildOrder, false) @@ -141,7 +141,7 @@ func (b *buildOrderTestCase) run(t *testing.T) { } } - orchestrator.GenerateGraphReusingOldTasks() + orchestrator.GenerateGraphReusingOldTasks(t.Context()) buildOrder2 := core.Map(orchestrator.Order(), func(config tspath.RootedFilePath) string { return b.projectName(config.AsString()) }) assert.DeepEqual(t, buildOrder2, b.expected) @@ -151,7 +151,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(), func(config tspath.RootedFilePath) string { return b.projectName(config.AsString()) }) verifyDeps(orchestrator, buildOrder3, true) }) diff --git a/tsc/internal/execute/build/orchestrator.go b/tsc/internal/execute/build/orchestrator.go index 277901ab89a07..075177845f37b 100644 --- a/tsc/internal/execute/build/orchestrator.go +++ b/tsc/internal/execute/build/orchestrator.go @@ -124,7 +124,7 @@ func (o *Orchestrator) ScheduleOrder() []string { // picked up may not be done yet, so a builder can take a dependent of a slow project and // wait on that project while a later project's upstream has already finished. The stable // sort preserves the original order within a depth, and reporting still follows Order(). -func (o *Orchestrator) computeScheduleOrder() []*BuildTask { +func (o *Orchestrator) computeScheduleOrder(ctx context.Context) []*BuildTask { type scheduleEntry struct { task *BuildTask depth int @@ -132,8 +132,14 @@ func (o *Orchestrator) computeScheduleOrder() []*BuildTask { entries := make([]scheduleEntry, len(o.order)) depths := make(map[*BuildTask]int, len(o.order)) for i, task := range o.order { + if ctx.Err() != nil { + return nil + } depth := 0 for _, upstream := range task.upStream { + if ctx.Err() != nil { + return nil + } depth = max(depth, depths[upstream.task]+1) } depths[task] = depth @@ -142,9 +148,14 @@ func (o *Orchestrator) computeScheduleOrder() []*BuildTask { slices.SortStableFunc(entries, func(a, b scheduleEntry) int { return a.depth - b.depth }) - return core.Map(entries, func(entry scheduleEntry) *BuildTask { - return entry.task - }) + order := make([]*BuildTask, len(entries)) + for i, entry := range entries { + if ctx.Err() != nil { + return nil + } + order[i] = entry.task + } + return order } func (o *Orchestrator) Upstream(configName string) []string { @@ -171,9 +182,15 @@ func (o *Orchestrator) getTask(path tspath.PathKey) *BuildTask { return task } -func (o *Orchestrator) createBuildTasks(oldTasks *collections.SyncMap[tspath.PathKey, *BuildTask], configs []tspath.RootedFilePath, wg core.WorkGroup) { +func (o *Orchestrator) createBuildTasks(ctx context.Context, oldTasks *collections.SyncMap[tspath.PathKey, *BuildTask], configs []tspath.RootedFilePath, wg core.WorkGroup) { for _, config := range configs { + if ctx.Err() != nil { + return + } wg.Queue(func() { + if ctx.Err() != nil { + return + } path := o.caseSensitivity.PathKey(tspath.RootedPath(config)) var task *BuildTask var buildInfo *buildInfoEntry @@ -201,13 +218,14 @@ 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) } }) } } func (o *Orchestrator) setupBuildTask( + ctx context.Context, configName tspath.RootedFilePath, downStream *BuildTask, inCircularContext bool, @@ -215,6 +233,9 @@ func (o *Orchestrator) setupBuildTask( analyzing *collections.Set[tspath.PathKey], circularityStack []string, ) *BuildTask { + if ctx.Err() != nil { + return nil + } path := o.caseSensitivity.PathKey(tspath.RootedPath(configName)) task := o.getTask(path) if !completed.Has(path) { @@ -231,7 +252,10 @@ func (o *Orchestrator) setupBuildTask( circularityStack = append(circularityStack, configName.AsString()) if task.resolved != nil { for index, subReference := range task.resolved.ResolvedProjectReferencePaths() { - upstream := o.setupBuildTask(subReference, task, inCircularContext || task.resolved.ProjectReferences()[index].Circular, completed, analyzing, circularityStack) + if ctx.Err() != nil { + return nil + } + upstream := o.setupBuildTask(ctx, subReference, task, inCircularContext || task.resolved.ProjectReferences()[index].Circular, completed, analyzing, circularityStack) if upstream != nil { task.upStream = append(task.upStream, &upstreamTask{task: upstream, refIndex: index}) } @@ -249,41 +273,67 @@ 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.PathKey, *BuildTask]{} o.order = nil o.errors = nil - o.GenerateGraph(tasks) + o.GenerateGraph(ctx, tasks) } -func (o *Orchestrator) GenerateGraph(oldTasks *collections.SyncMap[tspath.PathKey, *BuildTask]) { +func (o *Orchestrator) GenerateGraph(ctx context.Context, oldTasks *collections.SyncMap[tspath.PathKey, *BuildTask]) { + o.graphGenerated = false + defer func() { + if oldTasks != nil { + oldTasks.Range(func(path tspath.PathKey, oldTask *BuildTask) bool { + if task, ok := o.tasks.Load(path); ok && task == oldTask { + return true + } + if oldTask.contentMapperProject != nil { + _ = oldTask.contentMapperProject.Close() + } + return true + }) + } + if ctx.Err() != nil { + // A partial graph must not be reused or built on a subsequent request. + o.tasks.Range(func(_ tspath.PathKey, task *BuildTask) bool { + if task.contentMapperProject != nil { + _ = task.contentMapperProject.Close() + } + return true + }) + o.tasks = &collections.SyncMap[tspath.PathKey, *BuildTask]{} + o.order = nil + o.scheduleOrder = nil + o.errors = nil + o.graphGenerated = false + } + }() + if ctx.Err() != nil { + return + } 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() + if ctx.Err() != nil { + return + } // Generate the graph completed := collections.Set[tspath.PathKey]{} analyzing := collections.Set[tspath.PathKey]{} circularityStack := []string{} for _, project := range projects { - o.setupBuildTask(project, nil, false, &completed, &analyzing, circularityStack) - } - o.scheduleOrder = o.computeScheduleOrder() - if oldTasks != nil { - oldTasks.Range(func(path tspath.PathKey, oldTask *BuildTask) bool { - if task, ok := o.tasks.Load(path); ok && task == oldTask { - return true - } - if oldTask.contentMapperProject != nil { - _ = oldTask.contentMapperProject.Close() - } - return true - }) + if ctx.Err() != nil { + return + } + o.setupBuildTask(ctx, project, nil, false, &completed, &analyzing, circularityStack) } - o.graphGenerated = true + o.scheduleOrder = o.computeScheduleOrder(ctx) + o.graphGenerated = ctx.Err() == nil } // tsc -b entrypoint @@ -293,17 +343,20 @@ func (o *Orchestrator) Start(ctx context.Context) tsc.CommandLineResult { // orchestrator.Build() entrypoint for api func (o *Orchestrator) Build(ctx context.Context, project string) *OrchestratorResult { - o.recheckAllProjects(project) + o.recheckAllProjects(ctx, project) return o.start(ctx, project, false /*onlyReferences*/) } // orchestrator.BuildReferences() entrypoint for api func (o *Orchestrator) BuildReferences(ctx context.Context, project string) *OrchestratorResult { - o.recheckAllProjects(project) + o.recheckAllProjects(ctx, project) return o.start(ctx, project, true /*onlyReferences*/) } func (o *Orchestrator) start(ctx context.Context, project string, onlyReferences bool) *OrchestratorResult { + if ctx.Err() != nil { + return &OrchestratorResult{Result: tsc.CommandLineResult{Status: tsc.ExitStatusCancelled}} + } o.contentMapperHost = tsc.NewContentMapperHost(ctx, o.opts.Sys, o.opts.Command.CompilerOptions) if o.contentMapperHost != nil && (!o.opts.Command.CompilerOptions.Watch.IsTrue() || o.opts.Testing == nil) { defer o.contentMapperHost.Close() @@ -312,9 +365,12 @@ func (o *Orchestrator) start(ctx context.Context, project string, onlyReferences o.watchStatusReporter(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode)) } if o.graphGenerated { - o.GenerateGraphReusingOldTasks() + o.GenerateGraphReusingOldTasks(ctx) } else { - o.GenerateGraph(nil) + o.GenerateGraph(ctx, nil) + } + if ctx.Err() != nil { + return &OrchestratorResult{Result: tsc.CommandLineResult{Status: tsc.ExitStatusCancelled}} } order, ok := o.getBuildOrderFor(project) if !ok { @@ -337,8 +393,8 @@ func (o *Orchestrator) start(ctx context.Context, project string, onlyReferences return result } -func (o *Orchestrator) recheckAllProjects(project string) { - if !o.graphGenerated { +func (o *Orchestrator) recheckAllProjects(ctx context.Context, project string) { + if !o.graphGenerated || ctx.Err() != nil { return } order, ok := o.getBuildOrderFor(project) @@ -346,6 +402,9 @@ func (o *Orchestrator) recheckAllProjects(project string) { return } o.rangeTasks(order, func(path tspath.PathKey, task *BuildTask) { + if ctx.Err() != nil { + return + } task.resetStatus() task.resetConfig(o, path) }) @@ -354,18 +413,24 @@ func (o *Orchestrator) recheckAllProjects(project string) { } // orchestrator.Clean() entrypoint for api -func (o *Orchestrator) Clean(project string) *OrchestratorResult { - return o.clean(project, false) +func (o *Orchestrator) Clean(ctx context.Context, project string) *OrchestratorResult { + return o.clean(ctx, project, false) } // orchestrator.CleanReferences() entrypoint for api -func (o *Orchestrator) CleanReferences(project string) *OrchestratorResult { - return o.clean(project, true) +func (o *Orchestrator) CleanReferences(ctx context.Context, project string) *OrchestratorResult { + return o.clean(ctx, project, true) } -func (o *Orchestrator) clean(project string, onlyReferences bool) *OrchestratorResult { +func (o *Orchestrator) clean(ctx context.Context, project string, onlyReferences bool) *OrchestratorResult { + if ctx.Err() != nil { + return &OrchestratorResult{Result: tsc.CommandLineResult{Status: tsc.ExitStatusCancelled}} + } if !o.graphGenerated { - o.GenerateGraph(nil) + o.GenerateGraph(ctx, nil) + } + if ctx.Err() != nil { + return &OrchestratorResult{Result: tsc.CommandLineResult{Status: tsc.ExitStatusCancelled}} } if len(o.errors) != 0 { result := &OrchestratorResult{ @@ -389,6 +454,9 @@ func (o *Orchestrator) clean(project string, onlyReferences bool) *OrchestratorR dry := o.opts.Command.BuildOptions.Dry.IsTrue() reportDiagnostic := o.createDiagnosticReporter(nil) for _, task := range order { + if ctx.Err() != nil { + break + } if task.resolved == nil { diagnostic := ast.NewCompilerDiagnostic(diagnostics.File_0_not_found, task.config) reportDiagnostic(diagnostic) @@ -402,9 +470,12 @@ func (o *Orchestrator) clean(project string, onlyReferences bool) *OrchestratorR projectOutputs := task.resolved.GetOutputFileNames() deleted := false for outputFile := range projectOutputs { - deleted = o.cleanProjectOutput(outputFile, inputs, dry, &result.FilesToDelete, reportDiagnostic) || deleted + if ctx.Err() != nil { + break + } + deleted = o.cleanProjectOutput(ctx, outputFile, inputs, dry, &result.FilesToDelete, reportDiagnostic) || deleted } - deleted = o.cleanProjectOutput(task.resolved.GetBuildInfoFileName(), inputs, dry, &result.FilesToDelete, reportDiagnostic) || deleted + deleted = o.cleanProjectOutput(ctx, task.resolved.GetBuildInfoFileName(), inputs, dry, &result.FilesToDelete, reportDiagnostic) || deleted if deleted { task.resetStatus() task.buildInfoEntryMu.Lock() @@ -413,7 +484,11 @@ func (o *Orchestrator) clean(project string, onlyReferences bool) *OrchestratorR } } - result.reportWithFilesToDelete(o, dry) + if ctx.Err() != nil { + result.Result.Status = tsc.ExitStatusCancelled + } else { + result.reportWithFilesToDelete(o, dry) + } return result } @@ -453,13 +528,17 @@ func (o *Orchestrator) getBuildOrderFor(project string) ([]*BuildTask, bool) { } func (o *Orchestrator) cleanProjectOutput( + ctx context.Context, outputFile tspath.RootedFilePath, inputs *collections.Set[tspath.PathKey], dry bool, filesToDelete *[]tspath.RootedFilePath, reportDiagnostic tsc.DiagnosticReporter, ) bool { - if outputFile == "" || inputs.Has(o.caseSensitivity.PathKey(outputFile.AsPath())) || !o.host.FS().FileExists(outputFile) { + if ctx.Err() != nil || outputFile == "" || inputs.Has(o.caseSensitivity.PathKey(outputFile.AsPath())) || !o.host.FS().FileExists(outputFile) { + return false + } + if ctx.Err() != nil { return false } *filesToDelete = append(*filesToDelete, outputFile) @@ -854,10 +933,16 @@ func (o *Orchestrator) DoCycle(ctx context.Context) { o.watchStatusReporter(ast.NewCompilerDiagnostic(diagnostics.File_change_detected_Starting_incremental_compilation)) if needsConfigUpdate.Load() { // Generate new tasks - o.GenerateGraphReusingOldTasks() + o.GenerateGraphReusingOldTasks(ctx) + } + if ctx.Err() != nil { + return } o.buildOrClean(ctx) + if ctx.Err() != nil { + return + } o.updateWatch() desiredDirs := o.computeDesiredWatches() if err := o.wm.ReconcileWatches(desiredDirs); err != nil { @@ -972,7 +1057,7 @@ func (o *Orchestrator) buildOrCleanProject(ctx context.Context, task *BuildTask, if !o.opts.Command.BuildOptions.Clean.IsTrue() { task.buildProject(ctx, o, path) } else { - task.cleanProject(o, path) + task.cleanProject(ctx, o, path) } if o.opts.Testing == nil { // The program is only needed by Testing.OnProgram at report time; drop it now so a task diff --git a/tsc/internal/execute/tsctests/tscbuild_test.go b/tsc/internal/execute/tsctests/tscbuild_test.go index 41c946adeeeb5..cf8309fde144a 100644 --- a/tsc/internal/execute/tsctests/tscbuild_test.go +++ b/tsc/internal/execute/tsctests/tscbuild_test.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "testing" "time" @@ -25,18 +26,35 @@ import ( type cancellingBuildFS struct { vfs.FS - cancel context.CancelFunc - once sync.Once + cancel context.CancelFunc + cancelPath tspath.RootedFilePath + once sync.Once + cancelled atomic.Bool + configReads atomic.Int64 + postCancelDirectoryReads atomic.Int64 } func (f *cancellingBuildFS) ReadFile(path tspath.RootedFilePath) (string, bool) { content, ok := f.FS.ReadFile(path) - if path == "/home/src/workspaces/project/leaf/a.ts" { - f.once.Do(f.cancel) + if path.BaseName() == "tsconfig.json" { + f.configReads.Add(1) + } + if path == f.cancelPath { + f.once.Do(func() { + f.cancel() + f.cancelled.Store(true) + }) } return content, ok } +func (f *cancellingBuildFS) DirectoryExists(path tspath.RootedDirectoryPath) bool { + if f.cancelled.Load() { + f.postCancelDirectoryReads.Add(1) + } + return f.FS.DirectoryExists(path) +} + type cancellingBuildSystem struct { *TestSys fs vfs.FS @@ -48,39 +66,63 @@ func (s *cancellingBuildSystem) FS() vfs.FS { func TestBuildCancellationAcrossReferencesAndRetry(t *testing.T) { t.Parallel() - sys := newTestSys(&tscInput{ - files: FileMap{ - "/home/src/workspaces/project/tsconfig.json": `{"files":[],"references":[{"path":"./middle"}]}`, - "/home/src/workspaces/project/middle/tsconfig.json": `{"compilerOptions":{"composite":true},"references":[{"path":"../leaf"}]}`, - "/home/src/workspaces/project/middle/b.ts": `import { a } from "../leaf/a"; export const b = a;`, - "/home/src/workspaces/project/leaf/tsconfig.json": `{"compilerOptions":{"composite":true}}`, - "/home/src/workspaces/project/leaf/a.ts": `export const a = 1;`, - }, - commandLineArgs: []string{"--build", "--extendedDiagnostics"}, - }, false) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - wrapped := &cancellingBuildSystem{ - TestSys: sys, - fs: &cancellingBuildFS{FS: sys.FS(), cancel: cancel}, - } - orchestrator := build.NewOrchestrator(build.Options{ - Sys: wrapped, - Command: tsoptions.ParseBuildCommandLine([]string{"--extendedDiagnostics"}, wrapped.FS(), sys.GetCurrentDirectory()), - Testing: sys, - }) - result := orchestrator.Build(ctx, "") - assert.Equal(t, result.Result.Status, tsc.ExitStatusCancelled) - assert.Equal(t, len(result.Errors), 0) - assert.Assert(t, !strings.Contains(sys.currentWrite.String(), "Total time:")) - assert.Assert(t, !wrapped.FS().FileExists("/home/src/workspaces/project/leaf/a.js")) - assert.Assert(t, !wrapped.FS().FileExists("/home/src/workspaces/project/middle/b.js")) + for _, test := range []struct { + cancelPath tspath.RootedFilePath + configReads int64 + }{ + {"", 0}, + {"/home/src/workspaces/project/tsconfig.json", 1}, + {"/home/src/workspaces/project/middle/tsconfig.json", 2}, + {"/home/src/workspaces/project/leaf/a.ts", 3}, + } { + for _, singleThreaded := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/singleThreaded=%t", test.cancelPath, singleThreaded), func(t *testing.T) { + t.Parallel() + sys := newTestSys(&tscInput{ + files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{"files":[],"references":[{"path":"./middle"}]}`, + "/home/src/workspaces/project/middle/tsconfig.json": `{"compilerOptions":{"composite":true},"references":[{"path":"../leaf"}]}`, + "/home/src/workspaces/project/middle/b.ts": `import { a } from "../leaf/a"; export const b = a;`, + "/home/src/workspaces/project/leaf/tsconfig.json": `{"compilerOptions":{"composite":true}}`, + "/home/src/workspaces/project/leaf/a.ts": `export const a = 1;`, + }, + commandLineArgs: []string{"--build", "--extendedDiagnostics"}, + }, false) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + fs := &cancellingBuildFS{FS: sys.FS(), cancel: cancel, cancelPath: test.cancelPath} + wrapped := &cancellingBuildSystem{TestSys: sys, fs: fs} + if test.cancelPath == "" { + cancel() + } + args := []string{"--extendedDiagnostics"} + if singleThreaded { + args = append(args, "--singleThreaded") + } + orchestrator := build.NewOrchestrator(build.Options{ + Sys: wrapped, + Command: tsoptions.ParseBuildCommandLine(args, wrapped.FS(), sys.GetCurrentDirectory()), + Testing: sys, + }) + result := orchestrator.Build(ctx, "") + assert.Equal(t, result.Result.Status, tsc.ExitStatusCancelled) + assert.Equal(t, len(result.Errors), 0) + assert.Equal(t, fs.configReads.Load(), test.configReads) + if test.configReads < 3 { + assert.Equal(t, len(orchestrator.Order()), 0) + } + assert.Assert(t, !strings.Contains(sys.currentWrite.String(), "Total time:")) + assert.Assert(t, !wrapped.FS().FileExists("/home/src/workspaces/project/leaf/a.js")) + assert.Assert(t, !wrapped.FS().FileExists("/home/src/workspaces/project/middle/b.js")) - result = orchestrator.Build(t.Context(), "") - assert.Equal(t, result.Result.Status, tsc.ExitStatusSuccess) - assert.Equal(t, len(result.Errors), 0) - assert.Assert(t, wrapped.FS().FileExists("/home/src/workspaces/project/leaf/a.js")) - assert.Assert(t, wrapped.FS().FileExists("/home/src/workspaces/project/middle/b.js")) + result = orchestrator.Build(t.Context(), "") + assert.Equal(t, result.Result.Status, tsc.ExitStatusSuccess) + assert.Equal(t, len(result.Errors), 0) + assert.Assert(t, wrapped.FS().FileExists("/home/src/workspaces/project/leaf/a.js")) + assert.Assert(t, wrapped.FS().FileExists("/home/src/workspaces/project/middle/b.js")) + }) + } + } } func TestBuildCommandLine(t *testing.T) { diff --git a/tsc/internal/execute/tsctests/watcher_race_test.go b/tsc/internal/execute/tsctests/watcher_race_test.go index 506b8a16350ff..c51984ac63a80 100644 --- a/tsc/internal/execute/tsctests/watcher_race_test.go +++ b/tsc/internal/execute/tsctests/watcher_race_test.go @@ -9,8 +9,10 @@ import ( "time" "github.com/microsoft/TypeScript/tsc/internal/execute" + "github.com/microsoft/TypeScript/tsc/internal/execute/build" "github.com/microsoft/TypeScript/tsc/internal/execute/tsc" "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" "gotest.tools/v3/assert" ) @@ -79,6 +81,51 @@ func TestWatchCycleStopsWithCancelledContext(t *testing.T) { } } +func TestBuildWatchSkipsReconciliationAfterCancellation(t *testing.T) { + t.Parallel() + for _, cancelConfig := range []bool{false, true} { + t.Run(fmt.Sprintf("cancelConfig=%t", cancelConfig), func(t *testing.T) { + t.Parallel() + const config = "/home/src/workspaces/project/tsconfig.json" + const source = "/home/src/workspaces/project/a.ts" + sys := newTestSys(&tscInput{ + files: FileMap{ + config: `{"compilerOptions":{"composite":true},"files":["a.ts"]}`, + source: `export const a = 1;`, + }, + }, false) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + fs := &cancellingBuildFS{FS: sys.FS(), cancel: cancel} + wrapped := &cancellingBuildSystem{TestSys: sys, fs: fs} + orchestrator := build.NewOrchestrator(build.Options{ + Sys: wrapped, + Command: tsoptions.ParseBuildCommandLine([]string{"--watch"}, wrapped.FS(), sys.GetCurrentDirectory()), + Testing: sys, + }) + result := orchestrator.Build(t.Context(), "") + assert.Equal(t, result.Result.Status, tsc.ExitStatusSuccess) + assert.Assert(t, result.Result.Watcher != nil) + changed := tspath.RootedFilePath(source) + if cancelConfig { + changed = config + sys.writeFileNoError(config, `{"compilerOptions":{"composite":true,"strict":true},"files":["a.ts"]}`) + } else { + sys.writeFileNoError(source, `export const a = 2;`) + } + fs.cancelPath = changed + sys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: changed.AsString()}}) + sys.clearOutput() + + orchestrator.DoCycle(ctx) + + assert.Equal(t, ctx.Err(), context.Canceled) + assert.Equal(t, fs.postCancelDirectoryReads.Load(), int64(0)) + assert.Assert(t, !strings.Contains(sys.currentWrite.String(), "Watching for file changes.")) + }) + } +} + // TestWatcherConcurrentDoCycle calls DoCycle from multiple goroutines // while modifying source files, exposing data races on Watcher fields // such as configModified, program, config, and the underlying