Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/server/management/system-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,15 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
const { req, url, config } = ctx;
if (url.pathname === "/api/system/memory" && req.method === "GET") {
const usage = process.memoryUsage();
let jscHeap: { heapSize: number; heapCapacity: number; objectCount: number } | null = null;
let jscHeap: { heapSize: number; heapCapacity: number; objectCount: number; extraMemorySize: number } | null = null;
try {
const { heapStats } = await import("bun:jsc");
const stats = heapStats();
jscHeap = {
heapSize: stats.heapSize,
heapCapacity: stats.heapCapacity,
objectCount: stats.objectCount,
extraMemorySize: typeof stats.extraMemorySize === "number" ? stats.extraMemorySize : 0,
};
} catch {
/* non-Bun tooling or unavailable introspection — omit the discriminator */
Expand Down
17 changes: 17 additions & 0 deletions src/server/memory-watchdog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
* paths, hostnames, or tokens.
*/

import { heapStats } from "bun:jsc";

export type MemorySampleBase = {
/** Epoch ms. */
at: number;
Expand All @@ -26,6 +28,10 @@ export type MemorySampleBase = {
external: number;
/** ArrayBuffer memory tracked by process.memoryUsage(). */
arrayBuffers: number;
/** JSC heapStats().heapSize, when introspection is available. */
jscHeapSize?: number;
/** JSC heapStats().extraMemorySize — JSC-visible native memory (Bun 1.4 external-memory reporting). */
jscExtraMemorySize?: number;
};

export type MemorySample = MemorySampleBase & {
Expand Down Expand Up @@ -78,13 +84,24 @@ export function getActiveMemoryWatchdog(): MemoryWatchdog | null {

function defaultSample(now: () => 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,
heapUsed: usage.heapUsed,
heapTotal: usage.heapTotal,
external: usage.external,
arrayBuffers: usage.arrayBuffers,
jscHeapSize,
jscExtraMemorySize,
};
return { ...base, ...observedMemoryCounter(base) };
}
Expand Down
30 changes: 29 additions & 1 deletion tests/memory-watchdog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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;
Expand All @@ -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
Expand Down
Loading