/** * 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; /** 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 & { 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 { // 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"), }; }