diff options
Diffstat (limited to 'packages/host-bin/src/mem-telemetry.ts')
| -rw-r--r-- | packages/host-bin/src/mem-telemetry.ts | 160 |
1 files changed, 160 insertions, 0 deletions
diff --git a/packages/host-bin/src/mem-telemetry.ts b/packages/host-bin/src/mem-telemetry.ts new file mode 100644 index 0000000..576347b --- /dev/null +++ b/packages/host-bin/src/mem-telemetry.ts @@ -0,0 +1,160 @@ +/** + * Periodic memory telemetry — leak-localization edge effect. + * + * Owns the timers (setInterval) that periodically log process.memoryUsage() + * so RSS growth can be correlated with active conversations/turns and the + * leaking subsystem pinpointed. This is the composition-root edge effect + * (AGENTS.md: timers are edge effects owned by host-bin, NOT the kernel). + * + * All effects are injected (sampler, GC, clock, logger, active-conversation + * count) so the timer logic is fully testable — tests pass fakes and assert + * the emitted log calls, never waiting on a real wall clock. No ambient + * state (P3): the timers are owned explicitly and returned as a `stop()` + * handle that the composition root clears on shutdown. + * + * The per-turn before/after sampling lives in session-orchestrator (it wraps + * the stream boundary); this module owns the PERIODIC baseline + GC logging. + */ + +import type { Logger } from "@dispatch/kernel"; +import { + type MemorySample, + memoryDelta, + memorySampleAttributes, +} from "@dispatch/session-orchestrator"; + +/** Default periodic sample interval: every 15s. */ +export const DEFAULT_MEMORY_SAMPLE_INTERVAL_MS = 15_000; + +/** Default GC interval: every 5 min (longer than the sample interval). */ +export const DEFAULT_GC_INTERVAL_MS = 5 * 60_000; + +/** + * Deps injected into {@link startMemoryTelemetry}. Every effect is explicit so + * the timer logic is reproducible from its inputs (no ambient state, P3) and + * testable without a real clock or process. + */ +export interface MemoryTelemetryDeps { + /** Logger (auto-scoped to host-bin by the composition root). */ + readonly logger: Logger; + /** Edge effect: capture a {@link MemorySample} now (process.memoryUsage()). */ + readonly sampleMemory: () => MemorySample; + /** + * Edge effect: run a full GC cycle. In production this is `() => + * Bun.gc(true)`; tests pass a no-op or a counting fake. Used on the longer + * GC interval to distinguish live retained objects from GC fragmentation. + */ + readonly gc: () => void; + /** + * The number of conversations currently driving a turn (from the + * session-orchestrator's activeConversations set). Tags each periodic + * sample so growth can be attributed to the streaming/turn path vs an idle + * baseline. + */ + readonly getActiveConversationCount: () => number; + /** Periodic sample interval (ms). Defaults to 15s. */ + readonly sampleIntervalMs?: number; + /** GC interval (ms). Defaults to 5 min. */ + readonly gcIntervalMs?: number; + /** + * Injected timer scheduler (defaults to global setInterval). Tests pass a + * fake to drive ticks deterministically without real wall-clock waits. The + * handle type is opaque (the same type {@link clearInterval} accepts). + */ + readonly setInterval?: (fn: () => void, ms: number) => MemoryTimerHandle; + /** Injected timer clearer (defaults to global clearInterval). */ + readonly clearInterval?: (handle: MemoryTimerHandle | undefined) => void; +} + +/** Opaque timer handle shared by {@link MemoryTelemetryDeps.setInterval} / clearInterval. */ +export type MemoryTimerHandle = ReturnType<typeof globalThis.setInterval>; + +/** Handle returned by {@link startMemoryTelemetry} to stop the timers. */ +export interface MemoryTelemetryHandle { + /** Stop both timers. Idempotent. Called by the composition root on shutdown. */ + readonly stop: () => void; +} + +/** + * Start periodic memory telemetry. Logs process.memoryUsage() every + * `sampleIntervalMs` (default 15s) tagged with the active-conversation count, + * and every `gcIntervalMs` (default 5 min) runs `gc()` and logs RSS + * before/after to distinguish live retained objects from GC fragmentation. + * + * Returns a `stop()` handle that clears both timers. The composition root + * (host-bin main) owns this handle and calls `stop()` on shutdown so the + * timers never leak across a restart. + * + * Pure decision logic: {@link buildPeriodicAttributes} / + * {@link buildGcAttributes} are exported separately for unit testing. + */ +export function startMemoryTelemetry(deps: MemoryTelemetryDeps): MemoryTelemetryHandle { + const sampleIntervalMs = deps.sampleIntervalMs ?? DEFAULT_MEMORY_SAMPLE_INTERVAL_MS; + const gcIntervalMs = deps.gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS; + const setIntervalFn = deps.setInterval ?? globalThis.setInterval; + const clearIntervalFn = deps.clearInterval ?? globalThis.clearInterval; + + let gcHandle: MemoryTimerHandle | undefined; + + // Periodic sample: log rss/heap/external/arrayBuffers + active-conversation + // count every 15s. Correlates RSS growth with active turns so the leak can + // be attributed to the streaming path vs an idle baseline. + const sampleHandle: MemoryTimerHandle | undefined = setIntervalFn(() => { + const sample = deps.sampleMemory(); + const activeConversations = deps.getActiveConversationCount(); + deps.logger.info("memory:periodic", buildPeriodicAttributes(sample, activeConversations)); + }, sampleIntervalMs); + + // GC log: on a longer interval, force a full GC and log RSS before/after. + // A small/no drop after gc means the memory is LIVE (retained objects — the + // leak); a large drop means it was GC fragmentation (reclaimable). This + // distinguishes the two failure modes the crash investigation flagged. + gcHandle = setIntervalFn(() => { + const before = deps.sampleMemory(); + deps.gc(); + const after = deps.sampleMemory(); + deps.logger.info("memory:gc", buildGcAttributes(before, after)); + }, gcIntervalMs); + + let stopped = false; + return { + stop() { + if (stopped) return; // idempotent — safe to call on every shutdown path + stopped = true; + clearIntervalFn(sampleHandle); + clearIntervalFn(gcHandle); + }, + }; +} + +/** + * Pure: build the logger attributes for a periodic sample. Exported for unit + * testing (no I/O, no clock). The `activeConversations` count tags the sample + * so growth can be attributed to the streaming/turn path vs idle baseline. + */ +export function buildPeriodicAttributes( + sample: MemorySample, + activeConversations: number, +): ReturnType<typeof memorySampleAttributes> & { activeConversations: number } { + return { ...memorySampleAttributes(sample), activeConversations }; +} + +/** + * Pure: build the logger attributes for a GC sample. Exported for unit testing. + * Carries the absolute after-sample plus the `reclaimed` delta + * (`before - after`): a POSITIVE `reclaimedRssMB` means GC freed memory + * (fragmentation, reclaimable); near-zero/negative means the memory is LIVE + * (retained objects — the leak). This distinguishes the two failure modes the + * crash investigation flagged. + */ +export function buildGcAttributes( + before: MemorySample, + after: MemorySample, +): ReturnType<typeof memorySampleAttributes> { + // reclaimed = before - after (how much GC freed). memoryDelta(a, b) = b - a, + // so pass (after, before) to get before - after. + return { + ...memorySampleAttributes(after), + ...memorySampleAttributes(memoryDelta(after, before), "reclaimed"), + }; +} |
