Skip to content

Commit d4a09d9

Browse files
committed
Revert "fix(assets,nuxt): stop dev-bridge file-watcher leak that OOMs/hangs Nuxt"
This reverts commit 0dfd33e.
1 parent 0dfd33e commit d4a09d9

4 files changed

Lines changed: 12 additions & 71 deletions

File tree

packages/nuxt/src/module.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -139,20 +139,14 @@ 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.
148142
addVitePlugin(viteDevBridge(options.devframe, {
149143
base: options.baseURL ?? './',
150144
devMiddleware: {
151145
port: mw.port,
152146
host,
153147
flags: mw.flags,
154148
},
155-
}) as any, { server: false })
149+
}) as any)
156150
}
157151
},
158152
})

plugins/assets/src/node/index.ts

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
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'
54
import { UPLOAD_CHANNEL } from '../rpc/functions/upload'
65
import { alwaysFunctions, readFunctions, writeFunctions } from '../rpc/index'
76
import { configureAssets } from './context'
@@ -28,14 +27,7 @@ export interface SetupAssetsOptions {
2827
serveStatic: boolean
2928
}
3029

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>()
30+
const watchers = new WeakMap<DevframeNodeContext, () => Promise<void>>()
3931

4032
/**
4133
* Register the assets RPC surface on a devframe node context: ensures the
@@ -77,14 +69,8 @@ export async function setupAssets(ctx: DevframeNodeContext, options: SetupAssets
7769
ctx.rpc.register(fn)
7870
}
7971

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-
}
72+
if (ctx.mode === 'dev')
73+
watchers.set(ctx, watchAssetsDir(ctx, options.dir))
8874
}
8975

9076
/**
@@ -93,14 +79,10 @@ export async function setupAssets(ctx: DevframeNodeContext, options: SetupAssets
9379
* leaked chokidar watcher doesn't keep the process alive.
9480
*/
9581
export async function disposeAssetsWatcher(ctx: DevframeNodeContext): Promise<void> {
96-
const dir = ctxToDir.get(ctx)
97-
if (!dir)
98-
return
99-
ctxToDir.delete(ctx)
100-
const dispose = watchersByDir.get(dir)
82+
const dispose = watchers.get(ctx)
10183
if (!dispose)
10284
return
103-
watchersByDir.delete(dir)
85+
watchers.delete(ctx)
10486
await dispose()
10587
}
10688

plugins/assets/src/node/watcher.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,11 @@ 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-
116
/**
127
* Watch the managed directory and broadcast {@link CHANGED_EVENT} (debounced)
138
* whenever a file is added, removed, or changed, so connected UIs refresh
149
* their listing live. Returns a disposer; call it when the devframe shuts
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.
10+
* down (tests in particular — a leaked watcher keeps the process alive).
1811
*/
1912
export function watchAssetsDir(ctx: DevframeNodeContext, dir: string): () => Promise<void> {
2013
const notify = debounce(async () => {
@@ -23,14 +16,11 @@ export function watchAssetsDir(ctx: DevframeNodeContext, dir: string): () => Pro
2316

2417
const watcher = watch(dir, {
2518
ignoreInitial: true,
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,
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,
3424
})
3525
watcher
3626
.on('add', () => void notify())

plugins/assets/test/assets.test.ts

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -181,29 +181,4 @@ 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-
})
209184
})

0 commit comments

Comments
 (0)