mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
perf(hmr): poll client bundles only
This commit is contained in:
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user