Skip to content

Commit 0dfd33e

Browse files
antfubotopencode
andcommitted
fix(assets,nuxt): stop dev-bridge file-watcher leak that OOMs/hangs Nuxt
The assets plugin starts a chokidar watcher in setup(), but it was only ever disposed by tests. A Nuxt/Vite host recreates the dev bridge — and a fresh DevframeNodeContext — on every config reload and once per build environment, so watchers accumulated until the dev process ran out of heap ("JS heap out of memory" on Nuxt 4); the destabilised/killed bridge then surfaced on the client as a WebSocket "socket hang up" (Nuxt 5). - assets: key live watchers by absolute directory (not by context) so a new setup on the same dir supersedes the previous watcher instead of stacking another — capping watchers at one per directory regardless of reload count. Regression test added. - assets: harden the watch — followSymlinks:false, ignore node_modules/ .git/.nuxt/.output/.turbo/.cache/dist, ignorePermissionErrors, and a bounded depth — so a symlink or misconfigured dir can't make the scan walk a huge tree. - nuxt: mount the dev RPC/WS bridge on the client build only ({ server: false }). The browser talks to Nuxt's client dev server, so the second bridge + watcher spun up in the Nitro/SSR build context was redundant and a source of instability. Co-authored-by: opencode <noreply@opencode.ai>
1 parent 8b06771 commit 0dfd33e

4 files changed

Lines changed: 71 additions & 12 deletions

File tree

packages/nuxt/src/module.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,20 @@ export default defineNuxtModule<ModuleOptions>({
139139
?? (nuxt.options.devServer as any)?.host
140140
?? options.devframe.cli?.host
141141

142+
// Client build only: the browser talks to Nuxt's client dev server,
143+
// so the RPC/WS bridge and its `__connection.json` middleware must
144+
// live there. Adding it to the server (Nitro/SSR) build too would
145+
// spin up a second, redundant bridge + file watcher inside the SSR
146+
// build context — wasted resources at best, and a source of dev-server
147+
// instability at worst.
142148
addVitePlugin(viteDevBridge(options.devframe, {
143149
base: options.baseURL ?? './',
144150
devMiddleware: {
145151
port: mw.port,
146152
host,
147153
flags: mw.flags,
148154
},
149-
}) as any)
155+
}) as any, { server: false })
150156
}
151157
},
152158
})

plugins/assets/src/node/index.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DevframeNodeContext } from 'devframe/types'
22
import { existsSync } from 'node:fs'
33
import fsp from 'node:fs/promises'
4+
import { resolve } from 'pathe'
45
import { UPLOAD_CHANNEL } from '../rpc/functions/upload'
56
import { alwaysFunctions, readFunctions, writeFunctions } from '../rpc/index'
67
import { configureAssets } from './context'
@@ -27,7 +28,14 @@ export interface SetupAssetsOptions {
2728
serveStatic: boolean
2829
}
2930

30-
const watchers = new WeakMap<DevframeNodeContext, () => Promise<void>>()
31+
// Keyed by absolute managed directory, not by context: a host (Nuxt / Vite)
32+
// recreates the dev bridge — and thus a fresh `DevframeNodeContext` — on every
33+
// config reload and once per build environment. Keying by directory caps live
34+
// watchers at one per directory no matter how many times setup re-runs, so
35+
// they can't accumulate and exhaust the heap. `ctxToDir` lets a specific
36+
// context dispose its own watcher (tests).
37+
const watchersByDir = new Map<string, () => Promise<void>>()
38+
const ctxToDir = new WeakMap<DevframeNodeContext, string>()
3139

3240
/**
3341
* Register the assets RPC surface on a devframe node context: ensures the
@@ -69,8 +77,14 @@ export async function setupAssets(ctx: DevframeNodeContext, options: SetupAssets
6977
ctx.rpc.register(fn)
7078
}
7179

72-
if (ctx.mode === 'dev')
73-
watchers.set(ctx, watchAssetsDir(ctx, options.dir))
80+
if (ctx.mode === 'dev') {
81+
const dir = resolve(options.dir)
82+
// Replace any watcher left over from a prior dev-bridge cycle on the
83+
// same directory before starting a fresh one bound to this context.
84+
await watchersByDir.get(dir)?.()
85+
watchersByDir.set(dir, watchAssetsDir(ctx, options.dir))
86+
ctxToDir.set(ctx, dir)
87+
}
7488
}
7589

7690
/**
@@ -79,10 +93,14 @@ export async function setupAssets(ctx: DevframeNodeContext, options: SetupAssets
7993
* leaked chokidar watcher doesn't keep the process alive.
8094
*/
8195
export async function disposeAssetsWatcher(ctx: DevframeNodeContext): Promise<void> {
82-
const dispose = watchers.get(ctx)
96+
const dir = ctxToDir.get(ctx)
97+
if (!dir)
98+
return
99+
ctxToDir.delete(ctx)
100+
const dispose = watchersByDir.get(dir)
83101
if (!dispose)
84102
return
85-
watchers.delete(ctx)
103+
watchersByDir.delete(dir)
86104
await dispose()
87105
}
88106

plugins/assets/src/node/watcher.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@ import { watch } from 'chokidar'
33
import { debounce } from 'perfect-debounce'
44
import { CHANGED_EVENT } from '../constants'
55

6+
// Never descend into these — following a symlink or a stray copy of one of
7+
// these into the managed directory would otherwise make chokidar walk a huge
8+
// tree (a common cause of dev-server OOM).
9+
const IGNORED_RE = /(?:^|[/\\])(?:node_modules|\.git|\.nuxt|\.output|\.turbo|\.cache|dist)(?:[/\\]|$)/
10+
611
/**
712
* Watch the managed directory and broadcast {@link CHANGED_EVENT} (debounced)
813
* whenever a file is added, removed, or changed, so connected UIs refresh
914
* their listing live. Returns a disposer; call it when the devframe shuts
10-
* down (tests in particular — a leaked watcher keeps the process alive).
15+
* down — a leaked watcher keeps the process alive and, when a host (Nuxt /
16+
* Vite) recreates the dev bridge across reloads, accumulates until the
17+
* process runs out of heap.
1118
*/
1219
export function watchAssetsDir(ctx: DevframeNodeContext, dir: string): () => Promise<void> {
1320
const notify = debounce(async () => {
@@ -16,11 +23,14 @@ export function watchAssetsDir(ctx: DevframeNodeContext, dir: string): () => Pro
1623

1724
const watcher = watch(dir, {
1825
ignoreInitial: true,
19-
// Directories can nest arbitrarily deep (icon sets, generated builds
20-
// dropped into the managed dir, …) — chokidar's default depth is
21-
// unlimited, but pin a generous cap so a runaway symlink loop can't
22-
// spin the watcher forever.
23-
depth: 32,
26+
// Don't traverse symlinks — a link pointing at `node_modules` (or a
27+
// parent dir) would otherwise blow up memory / inotify watches.
28+
followSymlinks: false,
29+
ignorePermissionErrors: true,
30+
ignored: (path: string) => IGNORED_RE.test(path),
31+
// A managed asset dir is shallow in practice; cap depth so a misconfigured
32+
// `dir` (e.g. a project root) can't make the initial scan runaway.
33+
depth: 16,
2434
})
2535
watcher
2636
.on('add', () => void notify())

plugins/assets/test/assets.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,29 @@ describe('assets plugin', () => {
181181
await waitFor(() => changed)
182182
expect(await call(client, 'devframes:plugin:assets:list')).toHaveLength(1)
183183
})
184+
185+
it('replaces the watcher across dev-bridge restarts on the same directory', async () => {
186+
// A host (Nuxt / Vite) recreates the dev bridge — and a fresh context —
187+
// on every reload. Starting a second cycle on the same directory must
188+
// supersede the first watcher, not stack another one (which would leak
189+
// until the process runs out of heap). The proof: the newest cycle's
190+
// watcher is the live one, so its client still receives change events.
191+
const first = await startAssetsServer(dir)
192+
server = await startAssetsServer(dir) // second cycle; afterEach closes this one
193+
try {
194+
const clientB = bootClient(server.port)
195+
let changed = false
196+
clientB.onEvent('devframes:plugin:assets:changed', () => {
197+
changed = true
198+
})
199+
await call(clientB, 'devframes:plugin:assets:list')
200+
201+
await fsp.writeFile(join(dir, 'after-restart.txt'), 'x', 'utf-8')
202+
await waitFor(() => changed)
203+
expect(changed).toBe(true)
204+
}
205+
finally {
206+
await first.close()
207+
}
208+
})
184209
})

0 commit comments

Comments
 (0)