🔎 Search Terms
createWatchProgram close timer, timerToUpdateProgram close, watch program close recreates watchers, close does not clear timeout, process does not exit after close, watch API close leak
🕗 Version & Regression Information
- This is the behavior in every version I tried (6.0.3;
close() in release-5.9 is the same), and I reviewed the FAQ for entries about the watch API
⏯ Playground Link
Not applicable: this is the compiler watch API on a Node host, which the Playground does not run.
💻 Code
import * as ts from "typescript";
import * as fs from "fs";
fs.writeFileSync("main.ts", "const x = 10;");
const host = ts.createWatchCompilerHost(["main.ts"], {}, ts.sys, ts.createEmitAndSemanticDiagnosticsBuilderProgram);
const watch = ts.createWatchProgram(host);
// A change lands, then the program is closed before the 250 ms update timer fires
fs.writeFileSync("main.ts", "const x = 20;");
setTimeout(() => {
watch.close();
// Expected: the process exits here. Actual: the update timer fires, synchronizeProgram()
// recreates the file watchers on the closed program, and the process stays alive.
setTimeout(() => console.log(process.getActiveResourcesInfo()), 1000);
}, 100);
🙁 Actual behavior
close() (src/compiler/watchPublic.ts, release-6.0, L562-L598) closes the file, directory and missing-file watchers and clears timerToInvalidateFailedLookupResolutions, but not timerToUpdateProgram, the timer that scheduleProgramUpdate() arms on every watch event. If a watch event lands in the 250 ms before close(), the timer fires after it: updateProgramWithWatchStatus() runs synchronizeProgram() on the closed program and installs a new set of watchers. builderProgram is already undefined, so a second close() does not reach them, and in a Node host they keep the event loop alive.
Where it shows: @rollup/plugin-typescript creates a watch program for every one-shot build and closes it in buildEnd; a file change in the last 250 ms of the build leaves rollup -c printing its output and never exiting (192 FSEventWrap and 13 StatWatcher handles on process.getActiveResourcesInfo()). Measured on a generated Nx React library, rollup -c with one touch of the entry file 650 to 850 ms after spawn: 11 of 30 runs still alive 20 s later on TypeScript 6.0.3; 0 of 30 with close() clearing the timer (same build, typescript.js built from release-6.0 with the four-line change). Nx sees it as an e2e hang in about 4% of its CI runs (nrwl/nx#36794); worked around from the host side in rollup/plugins#2025 and nrwl/nx#37196.
🙂 Expected behavior
After close() no timer armed by the program is pending and no watcher is created; the process exits when the host has nothing else to do. close() should clear timerToUpdateProgram the way it clears timerToInvalidateFailedLookupResolutions.
Additional information about the issue
Scope: TypeScript 7 (the Go compiler on main) does not have this path. Its watch mode is a loop inside tsc --watch that ends with a context and closes its watches there (Watcher.start, RunLoop), and its JS API has no createWatchProgram; @rollup/plugin-typescript calls ts.createWatchProgram on the typescript package, so it reaches this on 6.0 and 5.9.
A fix with a watchAPI unit test is on sdjayna/TypeScript fix/watch-program-close-clears-update-timer; not opened as a PR, since release-6.0 asks for an approved issue first.
🔎 Search Terms
createWatchProgram close timer, timerToUpdateProgram close, watch program close recreates watchers, close does not clear timeout, process does not exit after close, watch API close leak
🕗 Version & Regression Information
close()inrelease-5.9is the same), and I reviewed the FAQ for entries about the watch API⏯ Playground Link
Not applicable: this is the compiler watch API on a Node host, which the Playground does not run.
💻 Code
🙁 Actual behavior
close()(src/compiler/watchPublic.ts,release-6.0, L562-L598) closes the file, directory and missing-file watchers and clearstimerToInvalidateFailedLookupResolutions, but nottimerToUpdateProgram, the timer thatscheduleProgramUpdate()arms on every watch event. If a watch event lands in the 250 ms beforeclose(), the timer fires after it:updateProgramWithWatchStatus()runssynchronizeProgram()on the closed program and installs a new set of watchers.builderProgramis alreadyundefined, so a secondclose()does not reach them, and in a Node host they keep the event loop alive.Where it shows:
@rollup/plugin-typescriptcreates a watch program for every one-shot build and closes it inbuildEnd; a file change in the last 250 ms of the build leavesrollup -cprinting its output and never exiting (192FSEventWrapand 13StatWatcherhandles onprocess.getActiveResourcesInfo()). Measured on a generated Nx React library,rollup -cwith onetouchof the entry file 650 to 850 ms after spawn: 11 of 30 runs still alive 20 s later on TypeScript 6.0.3; 0 of 30 withclose()clearing the timer (same build,typescript.jsbuilt fromrelease-6.0with the four-line change). Nx sees it as an e2e hang in about 4% of its CI runs (nrwl/nx#36794); worked around from the host side in rollup/plugins#2025 and nrwl/nx#37196.🙂 Expected behavior
After
close()no timer armed by the program is pending and no watcher is created; the process exits when the host has nothing else to do.close()should cleartimerToUpdateProgramthe way it clearstimerToInvalidateFailedLookupResolutions.Additional information about the issue
Scope: TypeScript 7 (the Go compiler on
main) does not have this path. Its watch mode is a loop insidetsc --watchthat ends with a context and closes its watches there (Watcher.start,RunLoop), and its JS API has nocreateWatchProgram;@rollup/plugin-typescriptcallsts.createWatchProgramon thetypescriptpackage, so it reaches this on 6.0 and 5.9.A fix with a
watchAPIunit test is on sdjayna/TypeScriptfix/watch-program-close-clears-update-timer; not opened as a PR, sincerelease-6.0asks for an approved issue first.