perf(hmr): poll client bundles only

This commit is contained in:
imccyu
2026-08-26 15:09:48 +08:00
parent 8f88a6f207
commit 1429737a52
5 changed files with 25 additions and 58 deletions
+15 -25
View File
@@ -1,7 +1,7 @@
/** /**
* HMR plugin, node half: the host end of the dev reload chain. One interval * HMR plugin, node half: the host end of the dev reload chain. One interval
* stat-polls every graph row's client bundle and optional source map (polling * stat-polls every graph row's client bundle (polling by design: network mounts
* by design: network mounts deliver no inotify events), reports changes through * deliver no inotify events), reports changes through
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel * `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
* broadcasting graph/rebuilt frames to the browser half (src/client/). * broadcasting graph/rebuilt frames to the browser half (src/client/).
* The web bundle mounts this row unconditionally: without a rebuild * The web bundle mounts this row unconditionally: without a rebuild
@@ -42,30 +42,22 @@ function sseData(frame: PluginsEventFrame): string {
return `data: ${JSON.stringify(frame)}\n\n` return `data: ${JSON.stringify(frame)}\n\n`
} }
type WatchedArtifactStat = Omit<ClientArtifactBaseline, 'path'> type WatchedBundleStat = Omit<ClientArtifactBaseline, 'path'>
type WatchedBundle = { type WatchedBundle = {
-readonly [K in keyof ClientArtifactBaseline]: ClientArtifactBaseline[K] -readonly [K in keyof ClientArtifactBaseline]: ClientArtifactBaseline[K]
} & { dirty: boolean } } & { dirty: boolean }
/** Snapshot the bundle plus its optional development source map. */ /** Snapshot the executable bundle metadata that drives reloads. */
function artifactStat(path: string): WatchedArtifactStat { function bundleStat(path: string): WatchedBundleStat {
const bundle = statSync(path) const bundle = statSync(path)
try { return { mtimeMs: bundle.mtimeMs, size: bundle.size }
const map = statSync(`${path}.map`)
return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: map.mtimeMs, mapSize: map.size }
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return { mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
}
} }
/** Whether neither served artifact changed since the last successful re-hash. */ /** Whether the executable bundle is unchanged since the last successful re-hash. */
function sameArtifactStat(left: WatchedArtifactStat, right: WatchedArtifactStat): boolean { function sameBundleStat(left: WatchedBundleStat, right: WatchedBundleStat): boolean {
return left.mtimeMs === right.mtimeMs return left.mtimeMs === right.mtimeMs
&& left.size === right.size && left.size === right.size
&& left.mapMtimeMs === right.mapMtimeMs
&& left.mapSize === right.mapSize
} }
/** /**
@@ -80,7 +72,7 @@ export function apply(ctx: Context, config: Config): void {
// --- bundle watch: one HMR-owned stat poll ------------------------------ // --- bundle watch: one HMR-owned stat poll ------------------------------
const watched = new Map<string, WatchedBundle>() const watched = new Map<string, WatchedBundle>()
const rehash = (id: string, watch: WatchedBundle, current: WatchedArtifactStat): void => { const rehash = (id: string, watch: WatchedBundle, current: WatchedBundleStat): void => {
try { try {
// rebuilt() replaces the opaque startup rev on its first call; later // rebuilt() replaces the opaque startup rev on its first call; later
// calls stay silent when the content hash is unchanged. // calls stay silent when the content hash is unchanged.
@@ -95,17 +87,15 @@ export function apply(ctx: Context, config: Config): void {
} }
watch.mtimeMs = current.mtimeMs watch.mtimeMs = current.mtimeMs
watch.size = current.size watch.size = current.size
watch.mapMtimeMs = current.mapMtimeMs
watch.mapSize = current.mapSize
watch.dirty = false watch.dirty = false
} }
const watchRow = (id: string, baseline: ClientArtifactBaseline): void => { const watchRow = (id: string, baseline: ClientArtifactBaseline): void => {
const watch: WatchedBundle = { ...baseline, dirty: false } const watch: WatchedBundle = { ...baseline, dirty: false }
watched.set(id, watch) watched.set(id, watch)
let current: WatchedArtifactStat let current: WatchedBundleStat
try { try {
current = artifactStat(baseline.path) current = bundleStat(baseline.path)
} catch (error) { } catch (error) {
watch.dirty = true watch.dirty = true
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error) if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
@@ -113,20 +103,20 @@ export function apply(ctx: Context, config: Config): void {
} }
// The module host captured its baseline before reading the bytes in the // The module host captured its baseline before reading the bytes in the
// startup batch. Only a mismatch crosses into the content-hash path. // startup batch. Only a mismatch crosses into the content-hash path.
if (!sameArtifactStat(current, watch)) rehash(id, watch, current) if (!sameBundleStat(current, watch)) rehash(id, watch, current)
} }
const pollWatches = (): void => { const pollWatches = (): void => {
for (const [id, watch] of watched) { for (const [id, watch] of watched) {
let current: WatchedArtifactStat let current: WatchedBundleStat
try { try {
current = artifactStat(watch.path) current = bundleStat(watch.path)
} catch (error) { } catch (error) {
watch.dirty = true watch.dirty = true
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error) if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
continue continue
} }
if (!watch.dirty && sameArtifactStat(current, watch)) continue if (!watch.dirty && sameBundleStat(current, watch)) continue
// Stat-before-hash preserves a detectable older baseline for writes that // Stat-before-hash preserves a detectable older baseline for writes that
// land during hashing. Repeated stat changes heal a torn read. // land during hashing. Repeated stat changes heal a torn read.
rehash(id, watch, current) rehash(id, watch, current)
@@ -31,19 +31,7 @@ interface FakeHostOptions {
function artifactBaseline(path: string): ClientArtifactBaseline { function artifactBaseline(path: string): ClientArtifactBaseline {
const bundle = statSync(path) const bundle = statSync(path)
try { return { path, mtimeMs: bundle.mtimeMs, size: bundle.size }
const sourceMap = statSync(`${path}.map`)
return {
path,
mtimeMs: bundle.mtimeMs,
size: bundle.size,
mapMtimeMs: sourceMap.mtimeMs,
mapSize: sourceMap.size,
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return { path, mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
}
} }
function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost { function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
@@ -111,7 +99,7 @@ async function mount(clientModuleHost: FakeHost, webServer: WebServer) {
} }
describe('hmr node half', () => { describe('hmr node half', () => {
it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => { it('watches graph bundles, ignores map-only changes, and unwatches on dispose', async () => {
const bundle = join(dir, 'a.js') const bundle = join(dir, 'a.js')
writeFileSync(bundle, 'v1') writeFileSync(bundle, 'v1')
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]])) const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
@@ -130,13 +118,17 @@ describe('hmr node half', () => {
clientModuleHost.rebuiltCalls.length = 0 clientModuleHost.rebuiltCalls.length = 0
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2)) await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(`${bundle}.map`, '{"version":3}') writeFileSync(`${bundle}.map`, '{"version":3}')
await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
expect(clientModuleHost.rebuiltCalls).toEqual([])
writeFileSync(bundle, 'v3-even-longer')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 }) await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
await fiber.dispose() await fiber.dispose()
expect(routes).toHaveLength(0) expect(routes).toHaveLength(0)
// Watcher gone: further file changes report nothing. // Watcher gone: further file changes report nothing.
clientModuleHost.rebuiltCalls.length = 0 clientModuleHost.rebuiltCalls.length = 0
writeFileSync(bundle, 'v3-even-longer') writeFileSync(bundle, 'v4-after-dispose')
await new Promise(resolve => setTimeout(resolve, POLL_MS * 4)) await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
expect(clientModuleHost.rebuiltCalls).toHaveLength(0) expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
}) })
+2 -15
View File
@@ -23,7 +23,7 @@
*/ */
import { createHash, randomBytes } from 'node:crypto' import { createHash, randomBytes } from 'node:crypto'
import { readFileSync, statSync, type Stats } from 'node:fs' import { readFileSync, statSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
@@ -78,10 +78,6 @@ export interface ClientArtifactBaseline {
readonly mtimeMs: number readonly mtimeMs: number
/** Bundle size in bytes. */ /** Bundle size in bytes. */
readonly size: number readonly size: number
/** Source-map modification time, or null when no map was observable. */
readonly mapMtimeMs: number | null
/** Source-map size in bytes, or null when no map was observable. */
readonly mapSize: number | null
} }
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */ /** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
@@ -756,22 +752,13 @@ export class ClientModuleRegistry extends Service {
return meta return meta
} }
/** Capture the bundle and optional-map stats before reading their bytes. */ /** Capture the bundle stats before reading its bytes. */
private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline { private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline {
const bundle = statSync(clientPath) const bundle = statSync(clientPath)
let sourceMap: Stats | undefined
try {
sourceMap = statSync(`${clientPath}.map`)
} catch {
// Optional map metadata only seeds HMR; the following map read reports
// malformed or inaccessible bytes and a later stat change self-heals.
}
return { return {
path: clientPath, path: clientPath,
mtimeMs: bundle.mtimeMs, mtimeMs: bundle.mtimeMs,
size: bundle.size, size: bundle.size,
mapMtimeMs: sourceMap?.mtimeMs ?? null,
mapSize: sourceMap?.size ?? null,
} }
} }
@@ -378,8 +378,6 @@ describe('client bundle activation', () => {
path: firstPath, path: firstPath,
mtimeMs: firstStat.mtimeMs, mtimeMs: firstStat.mtimeMs,
size: firstStat.size, size: firstStat.size,
mapMtimeMs: null,
mapSize: null,
}) })
expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined() expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
}) })
@@ -3434,7 +3434,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'ClientArtifactBaseline', name: 'ClientArtifactBaseline',
declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n readonly mapMtimeMs: number | null;\n readonly mapSize: number | null;\n}', declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n}',
}, },
{ {
name: 'CodeBindingErrorClass', name: 'CodeBindingErrorClass',