diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 8af24a2693..228cef0816 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -590,7 +590,7 @@ export type ServiceMemoryData = { arrayBuffers: number; observedBytes?: number; observedMetric?: MemoryMetric; - jscHeap: { heapSize: number } | null; + jscHeap: { heapSize: number; extraMemorySize?: number } | null; streamMode: string; eagerRelay: { useEagerRelay: boolean; reason: string } | null; watchdog: { warnThresholdBytes: number; lastWarnAt: number | null; observedBytes?: number; observedMetric?: MemoryMetric } | null; @@ -659,7 +659,12 @@ export async function fetchServiceMemory( observedMetric: body.observedMetric === "rss" || body.observedMetric === "external" || body.observedMetric === "arrayBuffers" ? body.observedMetric : undefined, - jscHeap: body.jscHeap && typeof body.jscHeap.heapSize === "number" ? { heapSize: body.jscHeap.heapSize } : null, + jscHeap: body.jscHeap && typeof body.jscHeap.heapSize === "number" + ? { + heapSize: body.jscHeap.heapSize, + extraMemorySize: typeof body.jscHeap.extraMemorySize === "number" ? body.jscHeap.extraMemorySize : undefined, + } + : null, streamMode: typeof body.streamMode === "string" ? body.streamMode : "auto", eagerRelay: body.eagerRelay && typeof body.eagerRelay.reason === "string" ? { useEagerRelay: body.eagerRelay.useEagerRelay === true, reason: body.eagerRelay.reason } @@ -751,7 +756,7 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] const observed = observedMemory(d); const observedBytes = d.observedBytes ?? d.watchdog?.observedBytes ?? observed.bytes; const observedMetric = d.observedMetric ?? d.watchdog?.observedMetric ?? observed.metric; - lines.push(` rss=${mb(d.rss)}, external=${mb(d.external)}, arrayBuffers=${mb(d.arrayBuffers)}, heapUsed=${mb(d.heapUsed)}${d.jscHeap ? `, jscHeap=${mb(d.jscHeap.heapSize)}` : ""}`); + lines.push(` rss=${mb(d.rss)}, external=${mb(d.external)}, arrayBuffers=${mb(d.arrayBuffers)}, heapUsed=${mb(d.heapUsed)}${d.jscHeap ? `, jscHeap=${mb(d.jscHeap.heapSize)}` : ""}${d.jscHeap?.extraMemorySize !== undefined ? `, jscExtra=${mb(d.jscHeap.extraMemorySize)}` : ""}`); lines.push(` observed=${mb(observedBytes)} (${observedMetric})`); lines.push(` streamMode=${d.streamMode}${d.eagerRelay ? ` (eager relay: ${d.eagerRelay.useEagerRelay ? "on" : "off"}, ${d.eagerRelay.reason})` : ""}`); if (d.watchdog) { diff --git a/src/server/management/system-routes.ts b/src/server/management/system-routes.ts index 867a3f94a0..1aaa26189c 100644 --- a/src/server/management/system-routes.ts +++ b/src/server/management/system-routes.ts @@ -51,7 +51,7 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise number): MemorySample { const usage = process.memoryUsage(); + let jscHeapSize: number | undefined; + let jscExtraMemorySize: number | undefined; + try { + const stats = heapStats(); + jscHeapSize = stats.heapSize; + jscExtraMemorySize = typeof stats.extraMemorySize === "number" ? stats.extraMemorySize : undefined; + } catch { + /* introspection failure must never break sampling */ + } const base = { at: now(), rss: usage.rss, @@ -85,6 +100,8 @@ function defaultSample(now: () => number): MemorySample { heapTotal: usage.heapTotal, external: usage.external, arrayBuffers: usage.arrayBuffers, + jscHeapSize, + jscExtraMemorySize, }; return { ...base, ...observedMemoryCounter(base) }; } diff --git a/tests/memory-watchdog.test.ts b/tests/memory-watchdog.test.ts index 2e75b4cc51..78506a939b 100644 --- a/tests/memory-watchdog.test.ts +++ b/tests/memory-watchdog.test.ts @@ -166,6 +166,33 @@ describe("startMemoryWatchdog", () => { first.stop(); // already superseded — must not null out `second` expect(getActiveMemoryWatchdog()).toBe(second); }); + + test("injected samples with JSC counters round-trip through snapshot()", async () => { + let t = 0; + const wd = startMemoryWatchdog({ + intervalMs: 1, + now: () => ++t, + sample: () => ({ ...sampleAt(t, 10), jscHeapSize: 111, jscExtraMemorySize: 222 }), + warn: () => {}, + }); + await new Promise(resolve => setTimeout(resolve, 10)); + const snap = wd.snapshot(); + expect(snap.samples.length).toBeGreaterThan(0); + expect(snap.samples[0]!.jscHeapSize).toBe(111); + expect(snap.samples[0]!.jscExtraMemorySize).toBe(222); + }); + + test("default sampler records JSC heap counters under Bun (1.4 extraMemorySize visibility)", async () => { + const wd = startMemoryWatchdog({ intervalMs: 1, warn: () => {} }); + await new Promise(resolve => setTimeout(resolve, 20)); + const snap = wd.snapshot(); + expect(snap.samples.length).toBeGreaterThan(0); + const s = snap.samples[snap.samples.length - 1]!; + // bun test always runs under Bun, so bun:jsc introspection is available. + expect(typeof s.jscHeapSize).toBe("number"); + expect(s.jscHeapSize!).toBeGreaterThan(0); + expect(typeof s.jscExtraMemorySize).toBe("number"); + }); }); describe("GET /api/system/memory", () => { @@ -186,7 +213,7 @@ describe("GET /api/system/memory", () => { const body = await res!.json() as { pid: number; bunVersion: string; platform: string; rss: number; heapUsed: number; external: number; arrayBuffers: number; observedBytes: number; observedMetric: string; - jscHeap: { heapSize: number } | null; + jscHeap: { heapSize: number; extraMemorySize: number } | null; responseState: { count: number; residentCount: number; spillStubCount: number; tombstoneCount: number; totalBytes: number; spillPayloadBytes: number; largestBytes: number; oldestAgeMs: number; @@ -210,6 +237,7 @@ describe("GET /api/system/memory", () => { expect(body.observedBytes).toBeGreaterThan(0); expect(["rss", "external", "arrayBuffers"]).toContain(body.observedMetric); expect(body.jscHeap?.heapSize).toBeGreaterThan(0); + expect(typeof body.jscHeap?.extraMemorySize).toBe("number"); // responseState is a scalar-only continuation-store attribution block: every field is a // finite number (no paths, tokens, or account identifiers), so it is safe on this surface. // The exact count is pinned on purpose: a new field must be reviewed for privacy safety