Skip to content

createWatchProgram().close() does not cancel the pending program update timer #64450

Description

🔎 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions