Skip to content

Non-watch builds leak TypeScript file watchers via updateProgramWithWatchStatus timer #1950

@koal44

Description

@koal44

Expected Behavior

A non-watch Rollup build with the @rollup/plugin-typescriptplugin should finish, close all TypeScript watchers, and let the process exit cleanly, even if TypeScript runs an internal updateProgramWithWatchStatus timer.

Actual Behavior

Occasionally, for whatever reason (typically on a cold start for the first entry file), TypeScript triggers its internal scheduleProgramUpdate, queuing a deferred update. In non-watch mode, the plugin closes and discards the builder program as soon as the initial TypeScript work is done, well before that deferred update runs.

When TypeScript eventually performs the update, it attaches new file and directory watchers to the internal watch program instance it created earlier, but the plugin has already discarded its reference to that instance and has no way to close the watchers TypeScript is now creating. These watchers persist for the remainder of the process and prevent Rollup from exiting. The ts-watch-leak diagnostic test exposes this by showing a non-zero watcher count after the build.

Additional Information

How to reproduce

You can reproduce the diagnostics directly from my fork:

git clone https://github.com/koal44/rollup-plugins.git
cd rollup-plugins
git checkout ts-watch-leak
pnpm install
cd plugins
pnpm --filter ./packages/typescript test -- test/test.js

On my machine this prints something like:

[diag:leak] updating timer from 250ms to 0ms
  ✔ [test:leak] passes the leak test when patched and delay is zero

[diag:leak] updating timer from 250ms to 100ms
  ✖ [test:leak] passes the leak test when patched and delay is 100

  Difference:

  - 178
  + 0

The failing assertion is t.is(getTsWatcherCount(), 0), so there are 178 TypeScript watchers still alive after the non-watch build and the forced timer delay.

What the diagnostics do

To make this reproducible, I added a small diagnostics module that is only enabled from the test.

First, it patches ts.sys.watchFile / ts.sys.watchDirectory to keep track of every watcher that TypeScript creates and closes:

// leakDiagnostics.ts
import type tsNs from 'typescript';

type Ts = typeof tsNs;

let enableTsTimerPatch = false;
let enableTsWatchersPatch = false;
let programUpdateDelayMs = 0;
export const config = {
  get enableTsTimerPatch() { return enableTsTimerPatch; },
  set enableTsTimerPatch(v: boolean) { enableTsTimerPatch = v; },

  get programUpdateDelayMs() { return programUpdateDelayMs; },
  set programUpdateDelayMs(ms) { programUpdateDelayMs = ms; },

  get enableTsWatchersPatch() { return enableTsWatchersPatch; },
  set enableTsWatchersPatch(v: boolean) { enableTsWatchersPatch = v; }
};

// Patch ts.sys.{watchFile,watchDirectory} to track created watchers
const tsWatchers = new Set<tsNs.FileWatcher>();
let isTsWatchPatched = false;
export function patchTsWatch(ts: Ts) {
  if (isTsWatchPatched) return;
  isTsWatchPatched = true;

  track('watchFile');
  track('watchDirectory');

  function track(name: 'watchFile' | 'watchDirectory') {
    const orig = ts.sys[name];

    ts.sys[name] = (...args) => {
      if (!orig) throw new Error(`ts.sys.${name} is not implemented`);
      const watcher = (orig as any).apply(ts.sys, args);
      const { close } = watcher;

      tsWatchers.add(watcher);

      watcher.close = () => {
        tsWatchers.delete(watcher);
        return close.apply(watcher);
      };

      return watcher;
    };
  }
}

export function getTsWatcherCount() {
  return tsWatchers.size;
}

Second, it intercepts the setTimeout that TypeScript uses for its internal scheduleProgramUpdate path and forces that update to happen on a controlled delay, while also nudging TypeScript to actually schedule one:

// leakDiagnostics.ts

export function patchProgramUpdateTimer(
  host: { setTimeout?: tsNs.System['setTimeout']; rootFiles?: string[] },
  program: { updateRootFileNames?(rootFiles?: string[]): void }
) {
  const realSetTimeout = host.setTimeout?.bind(host);
  if (realSetTimeout) {
    host.setTimeout = function patchedSetTimeout(cb, ms, ...args) {
      if (
        cb.name === 'updateProgramWithWatchStatus' &&
        ms === 250 /* && args[0] === "timerToUpdateProgram"*/
      ) {
        console.log(`[diag:leak] updating timer from ${ms}ms to ${config.programUpdateDelayMs}ms`);
        ms = config.programUpdateDelayMs;
      }
      return realSetTimeout(cb, ms, ...args);
    };
  }

  // force typescript to schedule a program update so that the bug is reproducible
  program.updateRootFileNames?.(host.rootFiles?.slice());
}

In buildStart, these diagnostics are only enabled when the config flags are set (the default in normal use is that both patches are disabled):

// inside buildStart, before creating the watch program

if (config.enableTsWatchersPatch) {
  patchTsWatch(ts);
}

const host = createWatchHost(ts, this, { /* ... */ });
program = ts.createWatchProgram(host);

if (config.enableTsTimerPatch) {
  patchProgramUpdateTimer(host, program);
}

Finally, the test itself turns on the diagnostics and asserts that no watchers are left after the non-watch build and the delayed update:

const { config, getTsWatcherCount } = require('..');

async function leakTest(t, timerDelay) {
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  config.enableTsTimerPatch = true;
  config.enableTsWatchersPatch = true;
  config.programUpdateDelayMs = timerDelay;

  const bundle = await rollup({
    input: 'fixtures/basic/main.ts',
    plugins: [typescript({ tsconfig: 'fixtures/basic/tsconfig.json' })],
    onwarn,
  });

  await getCode(bundle, outputOptions);

  // Wait long enough for the deferred TypeScript update to run
  await delay(timerDelay + 200);

  t.is(getTsWatcherCount(), 0);
}

test.serial('[test:leak] passes the leak test when patched and delay is zero', async (t) => {
  await leakTest(t, 0);
});

test.serial('[test:leak] passes the leak test when patched and delay is 100', async (t) => {
  await leakTest(t, 100);
});

On the unmodified plugin, the second test fails because there are still live watchers after the deferred update.

So is this critical?

Nope. This only affects non-watch mode, and as far as I can tell, only the first cold-start entry file ever triggers a scheduleProgramUpdate. There isn’t an escalating leak; you just end up with a second batch of watchers that stays alive until the process tries to exit, at which point Rollup hangs.

It does expose an architectural mismatch: Rollup assumes it can run the plugin synchronously and immediately close out its internal state, while TypeScript assumes that any watch program it creates will live long enough for deferred timers to run. Those two philosophies step on each other here.

Solution ideas:

  1. Do nothing.
    Users can exercise their fingers and hit Ctrl-C. Mild inconvenience.

  2. Split watch vs. non-watch architectures.
    Mmm, I tried. I whittled it down to six failing tests, but it drifted too far from the plugin’s current structure, and too much legacy seems to depend on createWatchProgram. I wouldn’t recommend this path.

  3. Hack the timer (as in the test harness).
    Setting the internal 250 ms timer to zero works for diagnostics, but it’s obviously not a production fix.

  4. Find a way to wait for TypeScript to settle.
    There’s no public API to cancel the deferred update, but if TypeScript exposes any signal that no more updates are pending, the plugin could theoretically wait for that before closing the watch program.

  5. Figure out why the deferred update fires at all.
    I didn’t chase this deeply. It seems connected to cold-start behavior and some internal dirtiness detection on the first file load. If the condition were known, a cleaner guard might be possible.

Note:

There's an implied assumption that TypeScript does in fact enqueue a scheduleProgramUpdate during non-watch builds. This matches what I observed in my runs and the diagnostics make the bug reproducible, but the environmental conditions under which this occurs aren't fully characterized.

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