Improve responsiveness of tsc build to interruption - #4592
Improve responsiveness of tsc build to interruption#4592Luke Sandberg (lukesandberg) wants to merge 14 commits into
tsc build to interruption#4592Conversation
|
@microsoft-github-policy-service agree [company="Vercel"] |
|
@microsoft-github-policy-service agree company="Vercel" |
There was a problem hiding this comment.
Pull request overview
This PR improves tsc / tsc -b cancellation responsiveness by threading context.Context through compilation/build/emit paths so the compiler’s cooperative cancellation can take effect, and updates tsgo CLI behavior to match Node’s conventional signal exit codes. It also adds targeted regression tests to ensure cancellations abort cleanly without panics or partial results being reported.
Changes:
- Thread
context.Contextthrough compile, emit, and build orchestrator paths and add early-return cancellation checks to avoid partial diagnostics/results. - Update
cmd/tsgosignal handling to re-raise the interrupting signal, producing Node-compatible exit behavior onSIGINT/SIGTERM. - Add cancellation-focused tests for pre-canceled runs, mid-check cancellation, emit-phase cancellation, and checker-pool behavior after cancellation.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/execute/watcher.go | Passes a context into emit path (currently context.Background() with TODO). |
| internal/execute/tsctests/watcher_race_test.go | Adjusts watch cancellation expectations (no watcher established; still success). |
| internal/execute/tsctests/tsccancel_test.go | New end-to-end cancellation tests across compile/build/emit phases. |
| internal/execute/tsctests/runner.go | Adds baseline string for new ExitStatusCanceled. |
| internal/execute/tsc/emit.go | Plumbs context into emit/diagnostics and aborts early on cancellation. |
| internal/execute/tsc/compile.go | Introduces ExitStatusCanceled. |
| internal/execute/tsc.go | Threads context through compilation and incremental compilation entry points. |
| internal/execute/build/orchestrator.go | Threads context into build orchestration; treats watch-mode cancellation as success. |
| internal/execute/build/buildtask.go | Uses context while waiting on upstream tasks and skips expensive work when canceled. |
| internal/compiler/program.go | Avoids re-running diagnostics / checker reuse paths once canceled. |
| internal/compiler/checkerpool.go | Skips canceled checkers and stops feeding files once context is canceled. |
| internal/compiler/checkerpool_test.go | New regression test ensuring global diagnostics don’t panic after mid-check cancellation. |
| cmd/tsgo/main.go | Replaces NotifyContext with explicit signal tracking and re-raises the canceling signal. |
## Follow-ups for the experimental TypeScript CLI checker Two independent improvements to the `experimental.useTypeScriptCli` build path. ### 1. Make `tsc` responsive to interruption When `next build` is interrupted (Ctrl-C / `SIGTERM`), the TypeScript 7 native compiler could keep running to completion instead of stopping — leaving a CPU-heavy process alive after the build was abandoned. The teardown already handled termination signals and killed the whole process group; the problem was the signal it sent. The native compiler ignores `SIGTERM`/`SIGINT`, so the graceful signal never stopped it. We now escalate to `SIGKILL` (Windows: `taskkill /T /F`), which reaps the compiler on interrupt (measured ~200ms vs. running to completion). The compiler's signal handling may be improved upstream — see [microsoft/typescript-go#4592](microsoft/typescript-go#4592), which threads an interruption `context` through `tsc build`. That work is still in progress; until it lands and ships, this escalation is what makes interruption reliable. ### 2. Skip the jest worker for the CLI checker The type-check runs in a jest worker to isolate the TypeScript compiler-API heap so it can be freed after checking. In CLI mode the compiler runs in a separate `tsc` process, so there is no heap to isolate and the worker adds nothing but an extra process and indirection. CLI mode now runs the setup/config path in-process and spawns `tsc` directly. The TypeScript-API checker is unchanged and still uses the worker. ### Testing - Unit tests for `runTypeScriptCli` (spawn options, group-SIGKILL teardown, signal handling, listener cleanup, captured-output decoding). - Existing `test/production/app-dir/typescript-cli` integration suite passes (TS 6, TS 7, opt-in-required, `ignoreBuildErrors`, `--debug-build-paths`). - Manually verified against a TypeScript 7 project large enough to distinguish a real kill from natural completion: the native compiler is reaped ~200ms after interrupt. <!-- NEXT_JS_LLM_PR -->
## Follow-ups for the experimental TypeScript CLI checker Two independent improvements to the `experimental.useTypeScriptCli` build path. ### 1. Make `tsc` responsive to interruption When `next build` is interrupted (Ctrl-C / `SIGTERM`), the TypeScript 7 native compiler could keep running to completion instead of stopping — leaving a CPU-heavy process alive after the build was abandoned. The teardown already handled termination signals and killed the whole process group; the problem was the signal it sent. The native compiler ignores `SIGTERM`/`SIGINT`, so the graceful signal never stopped it. We now escalate to `SIGKILL` (Windows: `taskkill /T /F`), which reaps the compiler on interrupt (measured ~200ms vs. running to completion). The compiler's signal handling may be improved upstream — see [microsoft/typescript-go#4592](microsoft/typescript-go#4592), which threads an interruption `context` through `tsc build`. That work is still in progress; until it lands and ships, this escalation is what makes interruption reliable. ### 2. Skip the jest worker for the CLI checker The type-check runs in a jest worker to isolate the TypeScript compiler-API heap so it can be freed after checking. In CLI mode the compiler runs in a separate `tsc` process, so there is no heap to isolate and the worker adds nothing but an extra process and indirection. CLI mode now runs the setup/config path in-process and spawns `tsc` directly. The TypeScript-API checker is unchanged and still uses the worker. ### Testing - Unit tests for `runTypeScriptCli` (spawn options, group-SIGKILL teardown, signal handling, listener cleanup, captured-output decoding). - Existing `test/production/app-dir/typescript-cli` integration suite passes (TS 6, TS 7, opt-in-required, `ignoreBuildErrors`, `--debug-build-paths`). - Manually verified against a TypeScript 7 project large enough to distinguish a real kill from natural completion: the native compiler is reaped ~200ms after interrupt. <!-- NEXT_JS_LLM_PR --> (cherry picked from commit 63375cd)
Is this actually how it worked before? I'd think that Ctrl+C was just plain unhandled in our old compiler and therefore got a default? |
i am not sure how it work in the js version, but there was a test saying that interruption reports as success in watch mode But if that is the case, should we just do the simple thing and remove the signal handlers? |
## Follow-ups for the experimental TypeScript CLI checker Two independent improvements to the `experimental.useTypeScriptCli` build path. ### 1. Make `tsc` responsive to interruption When `next build` is interrupted (Ctrl-C / `SIGTERM`), the TypeScript 7 native compiler could keep running to completion instead of stopping — leaving a CPU-heavy process alive after the build was abandoned. The teardown already handled termination signals and killed the whole process group; the problem was the signal it sent. The native compiler ignores `SIGTERM`/`SIGINT`, so the graceful signal never stopped it. We now escalate to `SIGKILL` (Windows: `taskkill /T /F`), which reaps the compiler on interrupt (measured ~200ms vs. running to completion). The compiler's signal handling may be improved upstream — see [microsoft/typescript-go#4592](microsoft/typescript-go#4592), which threads an interruption `context` through `tsc build`. That work is still in progress; until it lands and ships, this escalation is what makes interruption reliable. ### 2. Skip the jest worker for the CLI checker The type-check runs in a jest worker to isolate the TypeScript compiler-API heap so it can be freed after checking. In CLI mode the compiler runs in a separate `tsc` process, so there is no heap to isolate and the worker adds nothing but an extra process and indirection. CLI mode now runs the setup/config path in-process and spawns `tsc` directly. The TypeScript-API checker is unchanged and still uses the worker. ### Testing - Unit tests for `runTypeScriptCli` (spawn options, group-SIGKILL teardown, signal handling, listener cleanup, captured-output decoding). - Existing `test/production/app-dir/typescript-cli` integration suite passes (TS 6, TS 7, opt-in-required, `ignoreBuildErrors`, `--debug-build-paths`). - Manually verified against a TypeScript 7 project large enough to distinguish a real kill from natural completion: the native compiler is reaped ~200ms after interrupt. <!-- NEXT_JS_LLM_PR --> (cherry picked from commit 63375cd)
|
The further this goes, the more I wonder if we should simply stop handling signals except in the LS or something. Obviously we never set up any signal handlers in tsc, right? |
|
Yeah i more or less said that in the PR description (under The downside of that is partial outputs (but honestly who cares) the more interesting case is the LSP where you presumably want to be able to time out or cancel requests in response to editor actions. For that you do need to propagate That being said, i know little about the LSP protocol or the requirements thereof. |
835c9df to
b08f5fe
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
internal/execute/build/orchestrator.go:619
- The aggregate does not always reflect cancellation. If graph generation finds a project-reference cycle, this branch runs no tasks, so a context canceled while the graph was being built still returns
ProjectReferenceCycle_OutputsSkipped; a cancellation just after the last task can likewise be lost. SincerunMainonly re-raises the signal forExitStatusCanceled, that interrupt is swallowed. Override the aggregate status fromctx.Err()after either branch.
// A canceled task surfaces ExitStatusCanceled through its own report(), so the
// aggregated status reflects cancellation without a separate override here.
## Follow-ups for the experimental TypeScript CLI checker Two independent improvements to the `experimental.useTypeScriptCli` build path. ### 1. Make `tsc` responsive to interruption When `next build` is interrupted (Ctrl-C / `SIGTERM`), the TypeScript 7 native compiler could keep running to completion instead of stopping — leaving a CPU-heavy process alive after the build was abandoned. The teardown already handled termination signals and killed the whole process group; the problem was the signal it sent. The native compiler ignores `SIGTERM`/`SIGINT`, so the graceful signal never stopped it. We now escalate to `SIGKILL` (Windows: `taskkill /T /F`), which reaps the compiler on interrupt (measured ~200ms vs. running to completion). The compiler's signal handling may be improved upstream — see [microsoft/typescript-go#4592](microsoft/typescript-go#4592), which threads an interruption `context` through `tsc build`. That work is still in progress; until it lands and ships, this escalation is what makes interruption reliable. ### 2. Skip the jest worker for the CLI checker The type-check runs in a jest worker to isolate the TypeScript compiler-API heap so it can be freed after checking. In CLI mode the compiler runs in a separate `tsc` process, so there is no heap to isolate and the worker adds nothing but an extra process and indirection. CLI mode now runs the setup/config path in-process and spawns `tsc` directly. The TypeScript-API checker is unchanged and still uses the worker. ### Testing - Unit tests for `runTypeScriptCli` (spawn options, group-SIGKILL teardown, signal handling, listener cleanup, captured-output decoding). - Existing `test/production/app-dir/typescript-cli` integration suite passes (TS 6, TS 7, opt-in-required, `ignoreBuildErrors`, `--debug-build-paths`). - Manually verified against a TypeScript 7 project large enough to distinguish a real kill from natural completion: the native compiler is reaped ~200ms after interrupt. <!-- NEXT_JS_LLM_PR -->
The CLI wires SIGINT/SIGTERM to a context in cmd/tsgo/main.go, but the plain (non-watch, non-build) compile path never threaded it to the checker: performCompilation/performIncrementalCompilation didn't accept a context and EmitFilesAndReportErrors hardcoded context.Background(). As a result a large `--noEmit` compile ignored Ctrl-C entirely — and because signal.NotifyContext replaces the default handler, the process wouldn't even die, it ran to completion. Thread the context through the compile path so the checker's existing cooperative cancellation polling (isCanceled) takes effect, and add a distinct ExitStatusCanceled (6) returned when the compile is aborted so incomplete diagnostics are not reported as a complete result. Introducing cancellation into the compiler checker pool exposed a latent panic: the pool reuses a fixed set of checkers across the diagnostics pass, and a canceled checker panics on reuse (checkNotCanceled). Guard the reuse sites (the second GetGlobalDiagnostics pass and the per-file checker-group loop) with ctx.Err() so a canceled checker is not fed more work. These guards are only necessary because the diagnostics APIs return a bare []*ast.Diagnostic with no error channel, making an empty (canceled) result indistinguishable from a clean one; the comments record that root cause. Watcher and build-task emit call sites pass context.Background() to preserve current behavior (their cancellation is handled at the RunLoop / orchestrator level). Add TestTscNoEmitCancellation covering both the single-file short-circuit and the multi-file/single-checker reuse path that would otherwise panic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Orchestrator.Start received the signal context but ran the actual non-watch build via buildOrClean() without it — ctx only reached the watch loop. So a plain `tsc -b` never consulted cancellation: SIGINT was captured (default handler suppressed by signal.NotifyContext) yet the build ran to completion. Same class of bug as the one-shot compile. Thread ctx through Start -> buildOrClean -> rangeTask -> buildOrCleanProject -> buildProject -> compileAndEmit -> EmitAndReportStatistics so a long build responds to SIGINT at the checker's granularity. rangeTask stops scheduling further projects once canceled; buildOrClean reports ExitStatusCanceled even if cancellation lands before any project produces a status; compileAndEmit early-returns on a canceled result (its EmitResult is nil) without updating timestamps or marking the project up-to-date. The canceled status dominates the aggregate via max. Watch mode is unchanged: the initial build and each DoCycle run to completion with context.Background(), cancellation observed at the RunLoop boundary. Also switch the compiler checker-group reuse guard to also check checker.WasCanceled() (the actual reuse precondition) and add a build-mode case to TestTscNoEmitCancellation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Trim the comments added on this branch for succinctness: keep the "no error channel" rationale once in forEachCheckerGroupDo and cross-reference it from the other guards, drop restated/tutorial prose. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rangeTask worker loop returned early on cancellation without running the task's f, so its done/reportDone channels never closed and any in-flight task waiting on them (waitOnUpstream / the report chain) could deadlock. Instead always run f for every fetched task and observe cancellation inside buildProject: skip the compile but still complete the task lifecycle. Make waitOnUpstream and updateDownstream ctx-aware so waiters escape on cancel and partial state is never propagated downstream. Drop the buildOrClean status override; a canceled task surfaces ExitStatusCanceled through its own report(). Honor cancellation during a watch-mode initial build too, but report success on that path: Ctrl-C is the expected way to exit a watch, so a non-zero code would make normal shutdown look like a failure. One-shot tsc -b still returns ExitStatusCanceled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses review feedback on the cancellation work: - EmitFilesAndReportErrors: re-check ctx after Emit. Emit returns nil when canceled partway through (e.g. the internal no-emit-on-error recheck), which previously fell through to Status=Success with a nil EmitResult and crashed in EmitAndReportStatistics. Guard EmitResult==nil there too. - checkerPool.GetGlobalDiagnostics: skip WasCanceled checkers. A checker canceled mid-check panics in checkNotCanceled when asked for global diagnostics; this fired from emitBuildInfo -> ensureHasErrorsForState, a path the earlier call-site guards missed. Fixing it in the pool covers the class, so the now-redundant guard in GetDiagnosticsOfAnyProgram is removed. - Drop the speculative ctx guard in collectCheckerDiagnosticsFromFiles: it only ran on the project-system pool path, which tsc never exercises. - cmd/tsgo: capture the interrupting signal (signal.Notify, not NotifyContext, whose context can't reveal the signal) and re-raise it on cancellation so the process exits with the conventional code -- 130 for SIGINT, 143 for SIGTERM -- matching the JS tsc, instead of the internal ExitStatusCanceled (6). - Tests: add TestTscMidCheckCancellation, which cancels *during* checking (the pre-canceled test cannot) and pins both the pool skip and the forEachCheckerGroupDo guard against checkNotCanceled panics. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up hardening after finding the call-site guards were incomplete: - HandleNoEmitOnError: bail when already canceled. During incremental emit it is re-invoked per affected file and re-runs GetDiagnosticsOfAnyProgram; on a canceled checker that panics in checkNotCanceled (getSemanticDiagnostics). This vector was not covered by the earlier guards. - Restore the ctx guard in GetDiagnosticsOfAnyProgram after getSemanticDiagnostics. It is independently needed for the --declaration path (no noEmitOnError, so HandleNoEmitOnError returns early): without it the subsequent GetDeclarationDiagnostics serializes types on a canceled checker and panics. - main.go: harden the signal re-raise. Capture the signal on a dedicated channel (no shared variable read across the goroutine) and, after re-raising, return 128+signum instead of blocking forever, so a failed Kill can't hang the process. - Tighten the checkerpool cancellation comment to say why it breaks. Tests (all with verified teeth -- each fix was reverted to confirm the test fails): - compiler: TestGetGlobalDiagnosticsAfterCancellation directly pins the pool's WasCanceled skip. - tsctests: TestTscMidCheckCancellation gains a declaration-emit case; new TestTscCancellationSweep cancels at every successive poll across the compile and into emit (incremental+noEmitOnError and declaration configs), asserting no panic and a consistent terminal status. This sweep is what surfaced the emit re-entry panic above. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old comment claimed buildProject "reports ExitStatusCanceled and nothing else" when this guard fires. That is wrong at the completion boundary: if the compile finishes and cancellation trips exactly on this ctx.Err() poll, buildProject reports Success (the work completed) -- the guard just skips the now-pointless downstream propagation. Rewrite it to state what the guard actually protects: updateDownstream seeds dependents' in-memory rebuild state (never outputs), and must be skipped once canceled so a partially-emitted program's HasChangedDtsFile can't corrupt downstream up-to-date decisions. Note it is currently a no-op in one-shot -b (empty downStream) and becomes load-bearing once DoCycle threads a cancelable context into watch rebuilds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge the pre-canceled and mid-check tests into a single table-driven TestTscCancellationAborts: both assert the same thing (aborts with ExitStatusCanceled, no diagnostics leaked, no checkNotCanceled panic), differing only in when the signal fires. A per-case midCheck flag selects the cancellation strategy via a shared runWithCancellation helper, which also folds in the timeout guard. As a side benefit the mid-check cases now also assert no diagnostics are reported, which the old mid-check test didn't. TestTscCancellationSweep stays separate -- it exercises a distinct property (no panic / no partial-success across every cancellation point) with clean source. Teeth re-verified after the refactor: reverting each guard still fails the corresponding case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
emit_test.go arrived on main while this branch added a ctx parameter to EmitFilesAndReportErrors, so the merged tree failed to compile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b08f5fe to
b6e1c50
Compare
|
Jake Bailey (@jakebailey) should I abandon this approach? Sorry i was OOO for a bit, but it would be nice to fix this issue somehow. Simply removing the signal handlers changes semantics of So we need some solution...
|
|
I think we might just be better off not doing the signal handling at all. Or, doing it to some extent such that the API can still do cancellations, but just not feed it into the root of tsc? I'm not 100% certain what my thoughts are yet. But I want to fix this for a patch release for sure. |
|
Yeah, so I think we should just drop the signals. I'm preparing a PR; we're trying to close up shop on this repo so I'm going to close this one just to get things moving. Thanks for looking into this, however! |
|
Made #4911. |
Improve responsiveness of
tscandtsc buildto signal based interruption.Overview
When integrating typescript 7 into next.js we observed that
tsc, launched as a subprocess, wouldn't exit onSIGTERM/SIGINTuntil the build finished. The CLI wired the signal to a context, but the compile and build paths never threaded it to the checker, so its cooperative cancellation never took effect.This PR:
tsc -bpaths (performCompilation/performIncrementalCompilation→EmitFilesAndReportErrors→ the build orchestrator), so the checker's existing cancellation polling takes effect.SIGINT, 143 forSIGTERM), matching the JStsc. (Watch mode instead reports success —Ctrl-Cis the normal way to stop a watch.)This PR was authored by a mix of me and Claude Opus.
Known follow-up
Watch-mode rebuild cycles still run on
context.Background(), so a long rebuild is only interruptible between cycles, not mid-cycle (marked with a TODO).Alternatives
Just don't install the signal handlers, or only install them in
watchmode where they can interrupt the watch loop?Fixes microsoft/TypeScript#63856