From fc905c53885fdbd6642323e026e8c1a23a51d407 Mon Sep 17 00:00:00 2001 From: Quint Daenen Date: Wed, 18 Feb 2026 11:04:38 +0100 Subject: [PATCH 1/2] fix: prevent `change` listener leak in Vite plugin (#108) Each call to `configureServer` (e.g. on Vite dev-server restart) added a new `change` listener without removing the previous one, eventually triggering Node's `MaxListenersExceededWarning`. Store a cleanup function in the plugin closure and call it at the start of each `configureServer` invocation so only one listener is active at a time. Add tests covering registration, cleanup, file filtering, and the `disableWatch` opt-out. --- flake.lock | 48 +++++++++++++++++++ flake.nix | 53 +++++++++++++++++++++ src/plugins/vite.ts | 30 +++++++----- tests/vite-plugin.test.ts | 99 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 13 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 tests/vite-plugin.test.ts diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a1f02d1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1771177547, + "narHash": "sha256-trTtk3WTOHz7hSw89xIIvahkgoFJYQ0G43IlqprFoMA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ac055f38c798b0d87695240c7b761b82fc7e5bc2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1771384185, + "narHash": "sha256-KvmjUeA7uODwzbcQoN/B8DCZIbhT/Q/uErF1BBMcYnw=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "23dd7fa91602a68bd04847ac41bc10af1e6e2fd2", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a9ab948 --- /dev/null +++ b/flake.nix @@ -0,0 +1,53 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + nixpkgs, + rust-overlay, + ... + }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems f; + in + { + devShells = forAllSystems ( + system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + }; + rustToolchain = pkgs.rust-bin.stable."1.89.0".default.override { + extensions = [ + "rustfmt" + "clippy" + ]; + targets = [ "wasm32-unknown-unknown" ]; + }; + in + { + default = pkgs.mkShell { + packages = [ + rustToolchain + pkgs.wasm-pack + pkgs.nodejs_24 + pkgs.pnpm + ]; + }; + } + ); + }; +} diff --git a/src/plugins/vite.ts b/src/plugins/vite.ts index c6f8823..f87606b 100644 --- a/src/plugins/vite.ts +++ b/src/plugins/vite.ts @@ -33,7 +33,7 @@ */ import { resolve } from 'node:path'; -import type { Plugin, ViteDevServer } from 'vite'; +import type { Plugin } from 'vite'; import { type GenerateOptions, type GenerateOutputOptions, @@ -89,6 +89,8 @@ export interface Options extends Omit { * @ignore */ export function icpBindgen(options: Options): Plugin { + let cleanupWatcher: (() => void) | undefined; + return { name: VITE_PLUGIN_NAME, async buildStart() { @@ -96,24 +98,26 @@ export function icpBindgen(options: Options): Plugin { }, configureServer(server) { if (!options.disableWatch) { - watchDidFileChanges(server, options); + // Remove previous listener to prevent accumulation on server restart. + cleanupWatcher?.(); + + const didFilePath = resolve(options.didFile); + server.watcher.add(didFilePath); + + const onChange = async (changedPath: string) => { + if (resolve(changedPath) === resolve(didFilePath)) { + await run(options); + } + }; + + server.watcher.on('change', onChange); + cleanupWatcher = () => server.watcher.off('change', onChange); } }, sharedDuringBuild: true, }; } -function watchDidFileChanges(server: ViteDevServer, options: Options) { - const didFilePath = resolve(options.didFile); - - server.watcher.add(didFilePath); - server.watcher.on('change', async (changedPath) => { - if (resolve(changedPath) === resolve(didFilePath)) { - await run(options); - } - }); -} - async function run(options: Options) { console.log(cyan(`[${VITE_PLUGIN_NAME}] Generating bindings from`), green(options.didFile)); diff --git a/tests/vite-plugin.test.ts b/tests/vite-plugin.test.ts new file mode 100644 index 0000000..e2c8e78 --- /dev/null +++ b/tests/vite-plugin.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for the `icpBindgen` Vite plugin's `configureServer` hook, which + * watches a `.did` file for changes and re-runs code generation. + * + * The `generate` function is mocked so these tests focus purely on watcher + * lifecycle behavior: listener registration, cleanup on server restart, file + * filtering, and the `disableWatch` opt-out. + */ + +import { EventEmitter } from 'node:events'; +import { resolve } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { icpBindgen } from '../src/plugins/vite.ts'; + +vi.mock('../src/core/generate/index.ts', () => ({ + generate: vi.fn().mockResolvedValue(undefined), +})); + +function createMockServer() { + const watcher = new EventEmitter(); + return { + watcher: Object.assign(watcher, { + add: vi.fn(), + off: watcher.removeListener.bind(watcher), + }), + }; +} + +const pluginOptions = { + didFile: './test.did', + outDir: './out', +}; + +// Our mock server only implements the subset of ViteDevServer used by the +// plugin (watcher.add/on/off), so a single cast is needed here. +function configureServer( + plugin: ReturnType, + server: ReturnType, +) { + // biome-ignore lint/suspicious/noExplicitAny: partial mock of ViteDevServer + (plugin.configureServer as any)(server); +} + +describe('icpBindgen vite plugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should register a change listener on configureServer', () => { + const plugin = icpBindgen(pluginOptions); + const server = createMockServer(); + + configureServer(plugin, server); + + expect(server.watcher.listenerCount('change')).toBe(1); + }); + + it('should not register a listener when disableWatch is true', () => { + const plugin = icpBindgen({ ...pluginOptions, disableWatch: true }); + const server = createMockServer(); + + configureServer(plugin, server); + + expect(server.watcher.listenerCount('change')).toBe(0); + }); + + it('should clean up previous listener when configureServer is called again', () => { + const plugin = icpBindgen(pluginOptions); + const server = createMockServer(); + + // Simulate multiple configureServer calls (e.g. Vite server restarts + // reusing the same watcher). + for (let i = 0; i < 10; i++) { + configureServer(plugin, server); + } + + expect(server.watcher.listenerCount('change')).toBe(1); + }); + + it('should trigger generate only for the watched did file', async () => { + const { generate } = await import('../src/core/generate/index.ts'); + + const plugin = icpBindgen(pluginOptions); + const server = createMockServer(); + + configureServer(plugin, server); + + // Emit a change for an unrelated file — should not trigger generate. + server.watcher.emit('change', '/some/other/file.ts'); + await vi.waitFor(() => {}); + expect(generate).not.toHaveBeenCalled(); + + // Emit a change for the watched did file — should trigger generate. + server.watcher.emit('change', resolve(pluginOptions.didFile)); + await vi.waitFor(() => { + expect(generate).toHaveBeenCalledOnce(); + }); + }); +}); From 328e4b032feaefa6c338b208d5143ccfea648784 Mon Sep 17 00:00:00 2001 From: Quint Daenen Date: Wed, 18 Feb 2026 14:01:07 +0100 Subject: [PATCH 2/2] chore: remove Nix flake dev environment --- flake.lock | 48 ------------------------------------------------ flake.nix | 53 ----------------------------------------------------- 2 files changed, 101 deletions(-) delete mode 100644 flake.lock delete mode 100644 flake.nix diff --git a/flake.lock b/flake.lock deleted file mode 100644 index a1f02d1..0000000 --- a/flake.lock +++ /dev/null @@ -1,48 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1771177547, - "narHash": "sha256-trTtk3WTOHz7hSw89xIIvahkgoFJYQ0G43IlqprFoMA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "ac055f38c798b0d87695240c7b761b82fc7e5bc2", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - } - }, - "rust-overlay": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1771384185, - "narHash": "sha256-KvmjUeA7uODwzbcQoN/B8DCZIbhT/Q/uErF1BBMcYnw=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "23dd7fa91602a68bd04847ac41bc10af1e6e2fd2", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index a9ab948..0000000 --- a/flake.nix +++ /dev/null @@ -1,53 +0,0 @@ -{ - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - - outputs = - { - nixpkgs, - rust-overlay, - ... - }: - let - systems = [ - "x86_64-linux" - "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" - ]; - forAllSystems = f: nixpkgs.lib.genAttrs systems f; - in - { - devShells = forAllSystems ( - system: - let - pkgs = import nixpkgs { - inherit system; - overlays = [ rust-overlay.overlays.default ]; - }; - rustToolchain = pkgs.rust-bin.stable."1.89.0".default.override { - extensions = [ - "rustfmt" - "clippy" - ]; - targets = [ "wasm32-unknown-unknown" ]; - }; - in - { - default = pkgs.mkShell { - packages = [ - rustToolchain - pkgs.wasm-pack - pkgs.nodejs_24 - pkgs.pnpm - ]; - }; - } - ); - }; -}