Skip to content

fix(typescript): clear the pending program update when a build closes the watch program - #2025

Open
sdjayna wants to merge 3 commits into
rollup:masterfrom
sdjayna:fix/typescript-close-clears-update-timer
Open

sdjayna wants to merge 3 commits into
rollup:masterfrom
sdjayna:fix/typescript-close-clears-update-timer

Conversation

@sdjayna

@sdjayna sdjayna commented Sep 25, 2026 •

Copy link
Copy Markdown

Rollup Plugin Name: typescript

This PR contains:

  • bugfix
  • feature
  • refactor
  • documentation
  • other

Are tests included?

  • yes (bugfixes and features will not be merged without tests)
  • no

Breaking Changes?

  • yes (breaking changes will not be merged unless absolutely necessary)
  • no

List any relevant issue numbers:

nrwl/nx#36794, nrwl/nx#37196

Description

A one-shot rollup -c with this plugin sometimes prints created dist in 380ms and never exits. The plugin runs a TypeScript watch program for every build and, when Rollup is not in watch mode, closes it in buildEnd. TypeScript schedules program updates through host.setTimeout(updateProgramWithWatchStatus, 250, "timerToUpdateProgram"), and its close() closes the file watchers and clears the resolution timer but not that one (6.0.3; release-5.9 has the same close()). So a change to any watched path in the 250 ms before buildEnd (a source file, a tsconfig, a package.json, node_modules/@types, a directory of a failed lookup) leaves the timer armed; it fires after the close, updateProgramWithWatchStatus() runs synchronizeProgram() on the closed program and installs a new set of file, directory and missing-file watchers. builderProgram is already undefined, so nothing closes them again, and since Rollup's CLI does not call process.exit the process stays up. This is the rollup -c child that Nx's e2e suite finds alive minutes after its output (nrwl/nx#36794); nrwl/nx#37196 works around it on the Nx side by wrapping the typescript option.

The change is in createWatchProgram in src/watchProgram.ts: the host's setTimeout and clearTimeout are wrapped to keep the set of pending timers, and the returned program's close() cancels whatever is still pending after TypeScript's own close. Hosts without timers (TypeScript then updates synchronously) are left alone. Watch mode is unchanged: the program is only closed from closeWatcher, and a timer is only cancelled by a close.

Measured with dist/cjs/index.js from this branch dropped into a generated Nx React library (rollup -c rollup.config.cjs, TypeScript 6.0.3, Rollup 4.63.5, Node 26.7.0, macOS), one touch of the entry source file at a fixed offset from spawn; the process lives about 1,050 ms and buildEnd falls in the 650 to 850 ms band:

plugin touch offset runs still alive 20 s after created …
12.3.0 650 to 850 ms 30 11
12.3.0 900 to 1,600 ms 20 0
12.3.0 none 3 0
this branch 650 to 850 ms 30 0
this branch 900 to 1,050 ms 8 0
this branch none 3 0

Every hung process holds 192 FSEventWrap and 13 StatWatcher handles (process.getActiveResourcesInfo() on SIGUSR2); no run with this branch holds any.

Test: clears a pending program update when the watch program is closed uses fakeTypescript with a createWatchProgram that arms a timer through the host the way TypeScript does and records whether it fires after the build; it fails on master (the timer fires) and passes here. pnpm test in packages/typescript: 91 pass, 2 skipped (pre-existing). tsc --noEmit, eslint and prettier --check clean on the two changed files.

The root cause is TypeScript's; clearing timerToUpdateProgram in close() there would make the wrapper redundant, but the plugin has to run against every TypeScript its users have installed. Reported upstream as microsoft/TypeScript#64450.

… the watch program

TypeScript's `createWatchProgram().close()` stops the file watchers but does
not clear `timerToUpdateProgram`. The plugin closes the program in `buildEnd`
when Rollup is not in watch mode, so a change to any watched path in the last
250ms of the build leaves the timer armed; it fires after the close, rebuilds
the program with a full set of file watchers, and nothing closes those again.
`rollup -c` then prints its output and never exits.

Track the timers the program arms through the host and cancel the pending
ones when the program is closed. Watch mode is unchanged: the program is not
closed there until the watcher stops.
@sdjayna
sdjayna requested a review from shellscape as a code owner September 25, 2026 19:48
@greptile-apps

greptile-apps Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Fixes timer cleanup in the TypeScript watch program.

The PR appears safe to merge; no outstanding finding or new actionable issue remains.

Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[TypeScript schedules update] --> B[Host tracks timer]
  B --> C[One-shot build closes program]
  C --> D[Cancel pending timer]
  D --> E[No update runs after close]
Loading

Reviews (2) · Last reviewed commit: "fix(typescript): forward timer arguments..."

Comment thread packages/typescript/test/test.js
leosvelperez pushed a commit to nrwl/nx that referenced this pull request Sep 29, 2026
…es its watch program (#37196)

**Fixes the `react-package.test.ts` flake: a rollup build finishes,
prints `created …`, and its process never exits, so the e2e times out at
300 s. It fails about one `master` run in 25 and has been failing PRs
that have nothing to do with rollup.** **Cause: TypeScript leaves a
program-update timer armed when `@rollup/plugin-typescript` closes its
watch program at the end of the build. This change clears it; the hang
went from 11 of 53 runs to 0 of 41.**

## Current Behavior

### What fails, and why it matters

`e2e-react` fails with `Command timed out after 300s: build childlib…`
while the output already ends in `created ../../dist/libs/childlib… in
1.4s`. The build is done; the process is still there. Every such run is
a red CI for a change that did nothing wrong, and a re-run.

| Failed because of it | When |
|---|---|
| `master` | 3 of the last 30 runs (24 Sep 13:39 and 13:44, 25 Sep
14:58); #36794 measured it at about 4% of `master` runs since August |
| #37113 (daemon lockfile check) | main-linux, 25 Sep ([run
36133475462](https://github.com/nrwl/nx/actions/runs/36133475462)) |
| #37085 (parallel cache restore) | main-linux, 18 Sep, then green on
re-run |

#36794 found from a CI dump that the `rollup -c` child is what stays
alive after its last output, and added a handle dump to find out why.
This PR is the why.

### Why the process does not exit

1. `withNx` adds
[`@rollup/plugin-typescript`](https://github.com/nrwl/nx/blob/0d8fca1b245ce2700b240008140d3eaef49eebb1/packages/rollup/src/plugins/with-nx/with-nx.ts#L262).
The plugin runs the TypeScript compiler as a **watch program** even for
a one-shot build, and closes it in
[`buildEnd`](https://github.com/rollup/plugins/blob/639f45638234c1c3fabfb13615c78bebaef89ef2/packages/typescript/src/index.ts#L196-L200).
2. A watch program reacts to file changes by arming a 250 ms timer
([`timerToUpdateProgram`](https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/compiler/watchPublic.ts#L881-L891)).
TypeScript's
[`close()`](https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/compiler/watchPublic.ts#L562-L598)
stops the file watchers but does not cancel that timer.
3. So if any watched file changes in the last 250 ms of the build, the
timer fires after the program is closed, rebuilds it, and creates a
fresh set of file watchers that nothing will ever close. Every hung
process we caught holds 192 `FSEventWrap` and 13 `StatWatcher` handles;
a healthy one holds none.
4. Rollup's CLI does not call `process.exit`, so those watchers keep it
alive.
[`nx:run-commands`](https://github.com/nrwl/nx/blob/0d8fca1b245ce2700b240008140d3eaef49eebb1/packages/nx/src/executors/run-commands/running-tasks.ts#L457)
waits for the child to close, the
[e2e](https://github.com/nrwl/nx/blob/0d8fca1b245ce2700b240008140d3eaef49eebb1/e2e/utils/command-utils.ts#L477)
waits for nx, and the test times out.

On CI a build phase runs 5 to 12 s on a shared runner with the daemon
recomputing the graph beside it, so the window for a stray file event is
far wider than 250 ms. Which event lands in it on CI does not matter to
the fix.

## Expected Behavior

The process exits when the build is done.

**The change:**
`packages/rollup/src/plugins/with-nx/watch-program-timers.ts` (+51
lines) gives the plugin a TypeScript whose watch programs remember the
timers they arm and cancel them on `close()`. `withNx` passes it as the
plugin's `typescript` option (+2 lines). Watch mode is untouched: the
program is never closed there.

**Proof:** `rollup -c` on a generated React library, with one `touch` of
a source file at a fixed offset. The process normally exits at about
1,050 ms, and `buildEnd` falls in the 650 to 850 ms band.

| | runs | still alive 20 s after `created …` |
|---|---:|---:|
| `@nx/rollup` 23.2.1, touch at 650 to 850 ms | 30 | **11** |
| `@nx/rollup` 23.2.1, touch later or none | 23 | 0 |
| this branch, touch at 650 to 850 ms | 30 | **0** |
| this branch, touch later or none | 11 | 0 |

Tests: 4 new specs pass (timer cleared on close, timer still fires while
open, host without timers untouched, module otherwise unchanged); `nx
run rollup:test` 188 pass; lint and format clean.

**Where the bug really lives:** in TypeScript's `close()`, and the
plugin could also clear the timers itself (rollup/plugins#2025). This PR
fixes every `withNx` build now, on every installed TypeScript, whichever
of those lands.

## Steps to Reproduce

1. `npx create-nx-workspace proj --preset=apps --pm=npm`, `npm i -D
@nx/react`, `nx g @nx/react:library libs/childlib --bundler=rollup
--unitTestRunner=jest`, then `nx show projects` once to cache the graph.
2. In `libs/childlib`: `NX_TASK_TARGET_PROJECT=@proj/childlib
NX_TASK_TARGET_TARGET=build NX_WORKSPACE_ROOT=$PWD/../.. npx rollup -c
rollup.config.cjs & sleep 0.7; touch src/lib/childlib.tsx; wait` (try
offsets between 0.65 and 0.85 s; about one run in three hangs).
3. `rollup` prints `created dist in …` and does not exit. With this
change it exits at every offset.

## Related Issue(s)

#36794 (diagnostics for this timeout). Runs it failed: #37113
([36133475462](https://github.com/nrwl/nx/actions/runs/36133475462)),
#37085 on 18 Sep.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant