diff --git a/src/config/buffer-limits.ts b/src/config/buffer-limits.ts new file mode 100644 index 00000000..90b14eba --- /dev/null +++ b/src/config/buffer-limits.ts @@ -0,0 +1,110 @@ +/** + * @fileoverview Centralized buffer size limits for memory management. + * + * These constants define the maximum sizes for various buffers throughout + * Claudeman. Consolidating them here ensures consistent limits and makes + * it easy to tune memory usage. + * + * Memory Budget Rationale (for 20 concurrent sessions): + * - Terminal buffer: 2MB max × 20 = 40MB worst case + * - Text output: 1MB max × 20 = 20MB worst case + * - Messages: ~1KB each × 1000 × 20 = 20MB worst case + * - Total buffer overhead: ~80MB (acceptable for long-running server) + * + * @module config/buffer-limits + */ + +// ============================================================================ +// Terminal Buffer Limits +// ============================================================================ + +/** + * Maximum terminal buffer size in characters. + * Contains raw terminal output with ANSI escape sequences. + * Reduced from 5MB to 2MB for better render performance. + */ +export const MAX_TERMINAL_BUFFER_SIZE = 2 * 1024 * 1024; // 2MB + +/** + * Size to trim terminal buffer to when max is exceeded. + * Keeps the most recent portion to preserve context. + */ +export const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB + +// ============================================================================ +// Text Output Buffer Limits +// ============================================================================ + +/** + * Maximum text output buffer size in characters. + * Contains ANSI-stripped text for search and analysis. + */ +export const MAX_TEXT_OUTPUT_SIZE = 1 * 1024 * 1024; // 1MB + +/** + * Size to trim text output buffer to when max is exceeded. + */ +export const TRIM_TEXT_TO = 768 * 1024; // 768KB + +// ============================================================================ +// Message Buffer Limits +// ============================================================================ + +/** + * Maximum number of Claude JSON messages to keep in memory per session. + * Older messages are discarded when limit is exceeded. + */ +export const MAX_MESSAGES = 1000; + +/** + * Number of messages to keep when trimming (80% of max). + */ +export const TRIM_MESSAGES_TO = 800; + +// ============================================================================ +// Line Buffer Limits +// ============================================================================ + +/** + * Maximum line buffer size in characters. + * Prevents unbounded growth for extremely long lines without newlines. + */ +export const MAX_LINE_BUFFER_SIZE = 64 * 1024; // 64KB + +// ============================================================================ +// Respawn Controller Buffer Limits +// ============================================================================ + +/** + * Maximum respawn controller buffer size. + * Smaller than session buffer since it's only used for idle detection. + */ +export const MAX_RESPAWN_BUFFER_SIZE = 1 * 1024 * 1024; // 1MB + +/** + * Size to trim respawn buffer to when max is exceeded. + */ +export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB + +// ============================================================================ +// Run Summary Limits +// ============================================================================ + +/** + * Maximum number of events to keep in run summary. + */ +export const MAX_RUN_SUMMARY_EVENTS = 1000; + +/** + * Number of events to keep when trimming run summary. + */ +export const TRIM_RUN_SUMMARY_TO = 800; + +// ============================================================================ +// Spawn Message Limits +// ============================================================================ + +/** + * Maximum messages per spawn communication channel. + */ +export const MAX_MESSAGES_PER_CHANNEL = 100; diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts new file mode 100644 index 00000000..4c35be0f --- /dev/null +++ b/src/config/map-limits.ts @@ -0,0 +1,137 @@ +/** + * @fileoverview Centralized Map size limits for memory management. + * + * These constants define maximum sizes for Maps that track ephemeral data. + * Without limits, long-running sessions can accumulate unbounded entries + * leading to memory leaks. + * + * Memory Budget Rationale: + * - Assuming average entry size of ~1KB + * - MAX_TRACKED_AGENTS=500 × 1KB = ~500KB for agent tracking + * - Activity/results per agent × agents = bounded by these limits + * - Total Map overhead: <50MB even under heavy load + * + * @module config/map-limits + */ + +// ============================================================================ +// Agent Tracking Limits +// ============================================================================ + +/** + * Maximum number of agents to track across all sessions. + * Oldest agents are evicted when limit is exceeded (LRU policy). + */ +export const MAX_TRACKED_AGENTS = 500; + +/** + * Maximum activity entries to keep per agent. + * Includes tool calls, status updates, progress reports. + */ +export const MAX_SUBAGENT_ACTIVITY_PER_AGENT = 100; + +/** + * Maximum tool results to keep per agent. + * Prevents memory growth from long-running agents with many tool calls. + */ +export const MAX_TOOL_RESULTS_PER_AGENT = 200; + +// ============================================================================ +// Hook Event Limits +// ============================================================================ + +/** + * Maximum pending hook events to queue. + * Prevents unbounded growth if hook processing is slow. + */ +export const MAX_PENDING_HOOKS = 50; + +// ============================================================================ +// Session Tracking Limits +// ============================================================================ + +/** + * Maximum concurrent sessions allowed. + * Each session consumes significant resources (PTY, buffers, watchers). + */ +export const MAX_CONCURRENT_SESSIONS = 50; + +/** + * Maximum session history entries to keep (for analytics). + */ +export const MAX_SESSION_HISTORY = 100; + +// ============================================================================ +// SSE Client Limits +// ============================================================================ + +/** + * Maximum SSE clients per session. + * Prevents resource exhaustion from many browser tabs. + */ +export const MAX_SSE_CLIENTS_PER_SESSION = 10; + +/** + * Maximum total SSE clients across all sessions. + */ +export const MAX_TOTAL_SSE_CLIENTS = 100; + +// ============================================================================ +// File Watcher Limits +// ============================================================================ + +/** + * Maximum file watchers (FSWatcher) to allow. + * Linux default max_user_watches is 8192-65536. + * We warn at 80% capacity and evict idle watchers. + */ +export const MAX_FILE_WATCHERS = 500; + +/** + * Warning threshold as percentage of max watchers. + */ +export const FILE_WATCHER_WARNING_THRESHOLD = 0.8; + +// ============================================================================ +// Task Tracking Limits +// ============================================================================ + +/** + * Maximum tasks to keep in the task queue. + */ +export const MAX_QUEUED_TASKS = 100; + +/** + * Maximum completed tasks to keep for history. + */ +export const MAX_COMPLETED_TASKS_HISTORY = 50; + +// ============================================================================ +// Todo Item Limits (Ralph Tracker) +// ============================================================================ + +/** + * Maximum todo items to track per session. + */ +export const MAX_TODOS_PER_SESSION = 500; + +/** + * TTL for completed todo items before cleanup (1 hour). + */ +export const COMPLETED_TODO_TTL_MS = 60 * 60 * 1000; + +// ============================================================================ +// Pending Tool Calls Limits +// ============================================================================ + +/** + * Maximum pending tool calls to track per subagent. + * Entries should be cleaned up on tool_result, but this prevents leaks. + */ +export const MAX_PENDING_TOOL_CALLS = 100; + +/** + * TTL for orphaned pending tool calls (5 minutes). + * If no tool_result received, entry is cleaned up. + */ +export const PENDING_TOOL_CALL_TTL_MS = 5 * 60 * 1000; diff --git a/src/types.ts b/src/types.ts index 8bc297d6..6cfc949a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,77 @@ * - Inner loop tracking (Ralph Wiggum detection) */ +// ========== Resource Management Types ========== + +/** + * Interface for objects that hold resources requiring explicit cleanup. + * Implementing classes should release timers, watchers, and other resources in dispose(). + */ +export interface Disposable { + /** Release all held resources. Safe to call multiple times. */ + dispose(): void; + /** Whether this object has been disposed */ + readonly isDisposed: boolean; +} + +/** + * Configuration for buffer accumulator instances. + * Used for terminal buffers, text output, and other size-limited string storage. + */ +export interface BufferConfig { + /** Maximum buffer size in bytes before trimming */ + maxSize: number; + /** Size to trim to when maxSize is exceeded */ + trimSize: number; + /** Optional callback invoked when buffer is trimmed */ + onTrim?: (trimmedBytes: number) => void; +} + +/** + * Memory metrics for monitoring and debugging. + * Extends Node.js process.memoryUsage() with application-specific tracking. + */ +export interface MemoryMetrics { + /** Heap memory used by V8 (bytes) */ + heapUsed: number; + /** Total heap size allocated by V8 (bytes) */ + heapTotal: number; + /** Memory used by C++ objects bound to JavaScript (bytes) */ + external: number; + /** Memory used by ArrayBuffers and SharedArrayBuffers (bytes) */ + arrayBuffers: number; + /** Sizes of tracked Maps by name */ + mapSizes: Record; + /** Number of active timers (setTimeout/setInterval) */ + timerCount: number; + /** Number of active file system watchers */ + watcherCount: number; + /** Timestamp when metrics were collected */ + timestamp: number; +} + +/** + * Resource types that can be registered for cleanup. + */ +export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream'; + +/** + * Registration entry for a cleanup resource. + * Used by CleanupManager to track and dispose resources. + */ +export interface CleanupRegistration { + /** Unique identifier for this registration */ + id: string; + /** Type of resource */ + type: CleanupResourceType; + /** Human-readable description for debugging */ + description: string; + /** Cleanup function to call on dispose */ + cleanup: () => void; + /** Timestamp when registered */ + registeredAt: number; +} + // ========== Core Status Types ========== /** Status of a Claude session */ diff --git a/src/utils/buffer-accumulator.ts b/src/utils/buffer-accumulator.ts new file mode 100644 index 00000000..e238ab22 --- /dev/null +++ b/src/utils/buffer-accumulator.ts @@ -0,0 +1,190 @@ +/** + * @fileoverview High-performance buffer accumulator for string data. + * + * This utility reduces GC pressure by avoiding repeated string concatenation (`+=`). + * Instead, chunks are pushed to an array and joined only when needed. + * Automatically trims when size limits are exceeded. + * + * @module utils/buffer-accumulator + */ + +import type { BufferConfig } from '../types.js'; + +/** + * High-performance buffer accumulator using array-based collection. + * + * Features: + * - Efficient append via array push (avoids O(n) string concat) + * - Lazy join on read (consolidates only when accessed) + * - Automatic trimming when max size exceeded + * - Optional callback on trim for logging/metrics + * + * @example + * ```typescript + * const buffer = new BufferAccumulator({ + * maxSize: 2 * 1024 * 1024, // 2MB + * trimSize: 1.5 * 1024 * 1024, // Trim to 1.5MB + * onTrim: (bytes) => console.log(`Trimmed ${bytes} bytes`) + * }); + * + * buffer.append('chunk1'); + * buffer.append('chunk2'); + * console.log(buffer.value); // Joins and returns full content + * ``` + */ +export class BufferAccumulator { + private chunks: string[] = []; + private totalLength: number = 0; + private readonly maxSize: number; + private readonly trimSize: number; + private readonly onTrim?: (trimmedBytes: number) => void; + + /** + * Creates a new BufferAccumulator. + * + * @param config - Buffer configuration with max/trim sizes + */ + constructor(config: BufferConfig); + /** + * Creates a new BufferAccumulator with simple size parameters. + * + * @param maxSize - Maximum buffer size before trimming + * @param trimSize - Size to trim to when max is exceeded + */ + constructor(maxSize: number, trimSize: number); + constructor(configOrMaxSize: BufferConfig | number, trimSize?: number) { + if (typeof configOrMaxSize === 'number') { + this.maxSize = configOrMaxSize; + this.trimSize = trimSize!; + this.onTrim = undefined; + } else { + this.maxSize = configOrMaxSize.maxSize; + this.trimSize = configOrMaxSize.trimSize; + this.onTrim = configOrMaxSize.onTrim; + } + } + + /** + * Append data to the buffer. + * Automatically trims if maxSize is exceeded. + * + * @param data - String data to append + */ + append(data: string): void { + if (!data) return; + this.chunks.push(data); + this.totalLength += data.length; + + // Trim if exceeded max size + if (this.totalLength > this.maxSize) { + this.trim(); + } + } + + /** + * Get the full buffer content. + * Consolidates chunks on access for efficient subsequent reads. + */ + get value(): string { + if (this.chunks.length === 0) return ''; + if (this.chunks.length === 1) return this.chunks[0]; + + // Consolidate chunks on access + const result = this.chunks.join(''); + this.chunks = [result]; + return result; + } + + /** + * Get current buffer length without joining chunks. + */ + get length(): number { + return this.totalLength; + } + + /** + * Check if buffer is empty. + */ + get isEmpty(): boolean { + return this.totalLength === 0; + } + + /** + * Clear the buffer completely. + */ + clear(): void { + this.chunks = []; + this.totalLength = 0; + } + + /** + * Set buffer to a specific value, replacing all content. + * + * @param value - New buffer content + */ + set(value: string): void { + this.chunks = value ? [value] : []; + this.totalLength = value?.length || 0; + } + + /** + * Get the last N characters from the buffer. + * Useful for checking recent content without reading the entire buffer. + * + * @param n - Number of characters to get from the end + * @returns The last N characters (or entire buffer if smaller) + */ + tail(n: number): string { + if (n >= this.totalLength) { + return this.value; + } + const full = this.value; + return full.slice(-n); + } + + /** + * Check if buffer ends with a specific string. + * + * @param suffix - String to check for + * @returns True if buffer ends with the suffix + */ + endsWith(suffix: string): boolean { + if (!suffix) return true; // All strings end with empty string + if (suffix.length > this.totalLength) return false; + return this.tail(suffix.length) === suffix; + } + + /** + * Search for a pattern in the buffer (from the end). + * + * @param pattern - String or RegExp to search for + * @param fromEnd - Number of characters from end to search within (default: entire buffer) + * @returns True if pattern is found + */ + contains(pattern: string | RegExp, fromEnd?: number): boolean { + const searchIn = fromEnd ? this.tail(fromEnd) : this.value; + if (typeof pattern === 'string') { + return searchIn.includes(pattern); + } + return pattern.test(searchIn); + } + + /** + * Trim buffer to keep only the most recent data. + * Called automatically when maxSize is exceeded. + */ + private trim(): void { + const full = this.chunks.join(''); + const trimmedBytes = full.length - this.trimSize; + const trimmed = full.slice(-this.trimSize); + this.chunks = [trimmed]; + this.totalLength = trimmed.length; + + // Notify callback if configured + if (this.onTrim && trimmedBytes > 0) { + this.onTrim(trimmedBytes); + } + } +} + +export default BufferAccumulator; diff --git a/src/utils/cleanup-manager.ts b/src/utils/cleanup-manager.ts new file mode 100644 index 00000000..21b6e64f --- /dev/null +++ b/src/utils/cleanup-manager.ts @@ -0,0 +1,330 @@ +/** + * @fileoverview Centralized resource cleanup manager. + * + * Tracks timers, intervals, watchers, and other resources that need explicit + * cleanup. Provides a unified dispose() method that safely cleans up all + * registered resources, even on partial failures. + * + * @module utils/cleanup-manager + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { Disposable, CleanupRegistration, CleanupResourceType } from '../types.js'; + +/** + * Options for setTimeout/setInterval with automatic cleanup. + */ +export interface TimerOptions { + /** Human-readable description for debugging */ + description?: string; +} + +/** + * Centralized manager for tracking and disposing resources. + * + * Implements the Disposable interface for hierarchical cleanup. + * All registered resources are cleaned up when dispose() is called. + * + * Features: + * - Tracks timers, intervals, watchers, and custom cleanup functions + * - isStopped guard to prevent callbacks firing after disposal + * - Safe disposal that continues even if individual cleanups fail + * - Debug logging for resource tracking + * + * @example + * ```typescript + * class MyService implements Disposable { + * private cleanup = new CleanupManager(); + * + * start() { + * // Timer automatically cleared on dispose + * this.cleanup.setTimeout(() => { + * if (this.cleanup.isStopped) return; // Guard + * this.doWork(); + * }, 5000, { description: 'work timer' }); + * } + * + * dispose() { + * this.cleanup.dispose(); + * } + * + * get isDisposed() { return this.cleanup.isDisposed; } + * } + * ``` + */ +export class CleanupManager implements Disposable { + private registrations = new Map(); + private _isDisposed = false; + private readonly debugMode: boolean; + + /** + * Creates a new CleanupManager. + * + * @param debug - Enable debug logging for resource tracking + */ + constructor(debug = false) { + this.debugMode = debug; + } + + /** + * Whether this manager has been disposed. + * Check this before executing callbacks to prevent zombie operations. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * Alias for isDisposed - check before executing async callbacks. + */ + get isStopped(): boolean { + return this._isDisposed; + } + + /** + * Number of currently registered resources. + */ + get resourceCount(): number { + return this.registrations.size; + } + + /** + * Get counts by resource type for metrics/debugging. + */ + get resourceCounts(): Record { + const counts: Record = { + timer: 0, + interval: 0, + watcher: 0, + listener: 0, + stream: 0, + }; + + for (const reg of this.registrations.values()) { + counts[reg.type]++; + } + + return counts; + } + + /** + * Schedule a timeout that will be automatically cleared on dispose. + * + * @param callback - Function to call when timeout fires + * @param delay - Delay in milliseconds + * @param options - Optional configuration + * @returns Timer ID for manual clearing if needed + */ + setTimeout( + callback: () => void, + delay: number, + options?: TimerOptions + ): string { + const id = uuidv4(); + const timeoutId = setTimeout(() => { + // Remove registration when timer fires naturally + this.registrations.delete(id); + // Don't execute if already stopped + if (this._isDisposed) return; + callback(); + }, delay); + + this.register({ + id, + type: 'timer', + description: options?.description || `setTimeout(${delay}ms)`, + cleanup: () => clearTimeout(timeoutId), + registeredAt: Date.now(), + }); + + return id; + } + + /** + * Schedule an interval that will be automatically cleared on dispose. + * + * @param callback - Function to call on each interval + * @param delay - Interval in milliseconds + * @param options - Optional configuration + * @returns Interval ID for manual clearing if needed + */ + setInterval( + callback: () => void, + delay: number, + options?: TimerOptions + ): string { + const id = uuidv4(); + const intervalId = setInterval(() => { + // Don't execute if stopped + if (this._isDisposed) return; + callback(); + }, delay); + + this.register({ + id, + type: 'interval', + description: options?.description || `setInterval(${delay}ms)`, + cleanup: () => clearInterval(intervalId), + registeredAt: Date.now(), + }); + + return id; + } + + /** + * Register a custom cleanup function. + * + * @param type - Type of resource for categorization + * @param cleanup - Function to call on dispose + * @param description - Human-readable description + * @returns Registration ID for manual removal if needed + */ + registerCleanup( + type: CleanupResourceType, + cleanup: () => void, + description: string + ): string { + const id = uuidv4(); + this.register({ + id, + type, + description, + cleanup, + registeredAt: Date.now(), + }); + return id; + } + + /** + * Register a file system watcher for cleanup. + * + * @param watcher - Object with close() method (FSWatcher, chokidar, etc.) + * @param description - Human-readable description + * @returns Registration ID + */ + registerWatcher( + watcher: { close: () => void }, + description: string + ): string { + return this.registerCleanup('watcher', () => watcher.close(), description); + } + + /** + * Register an event listener for cleanup. + * + * @param emitter - Object with removeListener/off method + * @param event - Event name + * @param listener - Listener function + * @param description - Human-readable description + * @returns Registration ID + */ + registerListener void) => void; off?: (event: string, listener: () => void) => void }>( + emitter: T, + event: string, + listener: () => void, + description: string + ): string { + return this.registerCleanup('listener', () => { + if (emitter.removeListener) { + emitter.removeListener(event, listener); + } else if (emitter.off) { + emitter.off(event, listener); + } + }, description); + } + + /** + * Register a stream for cleanup. + * + * @param stream - Object with destroy() or close() method + * @param description - Human-readable description + * @returns Registration ID + */ + registerStream( + stream: { destroy?: () => void; close?: () => void }, + description: string + ): string { + return this.registerCleanup('stream', () => { + if (stream.destroy) { + stream.destroy(); + } else if (stream.close) { + stream.close(); + } + }, description); + } + + /** + * Manually remove a registered resource. + * + * @param id - Registration ID returned from register methods + * @returns True if resource was found and removed + */ + unregister(id: string): boolean { + const reg = this.registrations.get(id); + if (!reg) return false; + + try { + reg.cleanup(); + } catch (err) { + this.debug(`Error cleaning up ${reg.type} "${reg.description}": ${err}`); + } + + this.registrations.delete(id); + return true; + } + + /** + * Dispose all registered resources. + * Safe to call multiple times (idempotent). + * Continues cleanup even if individual resources fail. + */ + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + + const errors: Array<{ description: string; error: unknown }> = []; + + for (const reg of this.registrations.values()) { + try { + reg.cleanup(); + this.debug(`Cleaned up ${reg.type}: ${reg.description}`); + } catch (err) { + errors.push({ description: reg.description, error: err }); + this.debug(`Error cleaning up ${reg.type} "${reg.description}": ${err}`); + } + } + + this.registrations.clear(); + + if (errors.length > 0) { + console.error(`[CleanupManager] ${errors.length} errors during disposal:`, + errors.map(e => e.description).join(', ')); + } + } + + /** + * Get all current registrations for debugging. + */ + getRegistrations(): CleanupRegistration[] { + return Array.from(this.registrations.values()); + } + + /** + * Internal: Register a cleanup entry. + */ + private register(reg: CleanupRegistration): void { + this.registrations.set(reg.id, reg); + this.debug(`Registered ${reg.type}: ${reg.description}`); + } + + /** + * Internal: Debug logging. + */ + private debug(message: string): void { + if (this.debugMode) { + console.log(`[CleanupManager] ${message}`); + } + } +} + +export default CleanupManager; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..6d94d2a6 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,11 @@ +/** + * @fileoverview Utility module exports. + * + * This module re-exports all utility classes and functions for easy import. + * + * @module utils + */ + +export { BufferAccumulator } from './buffer-accumulator.js'; +export { LRUMap, type LRUMapOptions } from './lru-map.js'; +export { CleanupManager, type TimerOptions } from './cleanup-manager.js'; diff --git a/src/utils/lru-map.ts b/src/utils/lru-map.ts new file mode 100644 index 00000000..44981dab --- /dev/null +++ b/src/utils/lru-map.ts @@ -0,0 +1,214 @@ +/** + * @fileoverview LRU (Least Recently Used) Map implementation. + * + * Extends the built-in Map with automatic eviction when a maximum size is + * exceeded. Uses Map's insertion-order iteration for O(1) LRU eviction. + * + * @module utils/lru-map + */ + +/** + * Configuration options for LRUMap. + */ +export interface LRUMapOptions { + /** Maximum number of entries before eviction */ + maxSize: number; + /** Optional callback when an entry is evicted */ + onEvict?: (key: K, value: V) => void; +} + +/** + * A Map with automatic LRU (Least Recently Used) eviction. + * + * When the map exceeds maxSize, the oldest entries are evicted. + * Access via get() refreshes an entry's position (moves to most recent). + * + * Uses JavaScript Map's insertion-order guarantee for efficient LRU behavior. + * All operations are O(1) amortized. + * + * @example + * ```typescript + * const cache = new LRUMap({ + * maxSize: 100, + * onEvict: (key, value) => console.log(`Evicted ${key}`) + * }); + * + * cache.set('a', 1); + * cache.set('b', 2); + * cache.get('a'); // Refreshes 'a', making 'b' the oldest + * // When full, 'b' would be evicted first + * ``` + */ +export class LRUMap extends Map { + private readonly maxSize: number; + private readonly onEvict?: (key: K, value: V) => void; + + /** + * Creates a new LRUMap. + * + * @param options - Configuration options + */ + constructor(options: LRUMapOptions) { + super(); + this.maxSize = options.maxSize; + this.onEvict = options.onEvict; + } + + /** + * Set a key-value pair. + * If key exists, updates value and refreshes position. + * If adding new entry would exceed maxSize, evicts oldest entries. + * + * @param key - Key to set + * @param value - Value to associate + * @returns this (for chaining) + */ + override set(key: K, value: V): this { + // If key exists, delete first to refresh position + if (super.has(key)) { + super.delete(key); + } + + // Add the entry (will be at end = most recent) + super.set(key, value); + + // Evict oldest entries if over capacity + while (super.size > this.maxSize) { + const oldestKey = super.keys().next().value; + if (oldestKey !== undefined) { + const oldestValue = super.get(oldestKey)!; + super.delete(oldestKey); + this.onEvict?.(oldestKey, oldestValue); + } + } + + return this; + } + + /** + * Get a value and refresh its position (mark as most recently used). + * + * @param key - Key to look up + * @returns Value if found, undefined otherwise + */ + override get(key: K): V | undefined { + if (!super.has(key)) { + return undefined; + } + + // Delete and re-insert to move to end (most recent) + const value = super.get(key)!; + super.delete(key); + super.set(key, value); + return value; + } + + /** + * Check if a key exists WITHOUT refreshing its position. + * Use this when you want to check existence without affecting LRU order. + * + * @param key - Key to check + * @returns True if key exists + */ + override has(key: K): boolean { + return super.has(key); + } + + /** + * Peek at a value WITHOUT refreshing its position. + * Use this when you want to read without affecting LRU order. + * + * @param key - Key to peek + * @returns Value if found, undefined otherwise + */ + peek(key: K): V | undefined { + return super.get(key); + } + + /** + * Get the oldest entry (next to be evicted) without removing it. + * + * @returns [key, value] of oldest entry, or undefined if empty + */ + oldest(): [K, V] | undefined { + const first = super.entries().next(); + if (first.done) return undefined; + return first.value; + } + + /** + * Get the newest entry (most recently accessed). + * + * @returns [key, value] of newest entry, or undefined if empty + */ + newest(): [K, V] | undefined { + let last: [K, V] | undefined; + for (const entry of super.entries()) { + last = entry; + } + return last; + } + + /** + * Evict entries older than a specific timestamp. + * Assumes values have a timestamp property or are numbers representing time. + * + * @param maxAge - Maximum age in milliseconds + * @param getTimestamp - Function to extract timestamp from value + * @returns Number of entries evicted + */ + expireOlderThan(maxAge: number, getTimestamp: (value: V) => number): number { + const now = Date.now(); + const cutoff = now - maxAge; + let evicted = 0; + + // Iterate from oldest to newest + for (const [key, value] of super.entries()) { + if (getTimestamp(value) < cutoff) { + super.delete(key); + this.onEvict?.(key, value); + evicted++; + } else { + // Since entries are ordered, once we find a non-expired one, + // all subsequent entries are also non-expired + break; + } + } + + return evicted; + } + + /** + * Get all keys in order from oldest to newest. + * + * @returns Array of keys + */ + keysInOrder(): K[] { + return Array.from(super.keys()); + } + + /** + * Get all values in order from oldest to newest. + * + * @returns Array of values + */ + valuesInOrder(): V[] { + return Array.from(super.values()); + } + + /** + * Get the maximum size limit. + */ + get maxEntries(): number { + return this.maxSize; + } + + /** + * Get the number of free slots before eviction would occur. + */ + get freeSlots(): number { + return Math.max(0, this.maxSize - super.size); + } +} + +export default LRUMap; diff --git a/test/utils/buffer-accumulator.test.ts b/test/utils/buffer-accumulator.test.ts new file mode 100644 index 00000000..32230c5a --- /dev/null +++ b/test/utils/buffer-accumulator.test.ts @@ -0,0 +1,193 @@ +/** + * Tests for BufferAccumulator utility. + * + * Port: N/A (unit tests, no server) + */ + +import { BufferAccumulator } from '../../src/utils/buffer-accumulator.js'; + +describe('BufferAccumulator', () => { + describe('basic operations', () => { + it('should start empty', () => { + const buffer = new BufferAccumulator(1000, 800); + expect(buffer.value).toBe(''); + expect(buffer.length).toBe(0); + expect(buffer.isEmpty).toBe(true); + }); + + it('should append data', () => { + const buffer = new BufferAccumulator(1000, 800); + buffer.append('hello'); + buffer.append(' '); + buffer.append('world'); + + expect(buffer.value).toBe('hello world'); + expect(buffer.length).toBe(11); + expect(buffer.isEmpty).toBe(false); + }); + + it('should ignore empty appends', () => { + const buffer = new BufferAccumulator(1000, 800); + buffer.append('hello'); + buffer.append(''); + buffer.append(null as unknown as string); + buffer.append(undefined as unknown as string); + + expect(buffer.value).toBe('hello'); + expect(buffer.length).toBe(5); + }); + + it('should clear the buffer', () => { + const buffer = new BufferAccumulator(1000, 800); + buffer.append('hello world'); + buffer.clear(); + + expect(buffer.value).toBe(''); + expect(buffer.length).toBe(0); + expect(buffer.isEmpty).toBe(true); + }); + + it('should set buffer to specific value', () => { + const buffer = new BufferAccumulator(1000, 800); + buffer.append('hello'); + buffer.set('goodbye'); + + expect(buffer.value).toBe('goodbye'); + expect(buffer.length).toBe(7); + }); + + it('should set to empty value', () => { + const buffer = new BufferAccumulator(1000, 800); + buffer.append('hello'); + buffer.set(''); + + expect(buffer.value).toBe(''); + expect(buffer.length).toBe(0); + }); + }); + + describe('automatic trimming', () => { + it('should trim when maxSize is exceeded', () => { + const buffer = new BufferAccumulator(100, 50); + + // Fill buffer beyond max + buffer.append('a'.repeat(60)); + buffer.append('b'.repeat(60)); // Total 120, exceeds 100 + + // Should have trimmed to 50 + expect(buffer.length).toBe(50); + // Should keep most recent data (all 'b's) + expect(buffer.value).toBe('b'.repeat(50)); + }); + + it('should call onTrim callback when trimming', () => { + let trimmedBytes = 0; + const buffer = new BufferAccumulator({ + maxSize: 100, + trimSize: 50, + onTrim: (bytes) => { trimmedBytes = bytes; }, + }); + + // Fill buffer beyond max + buffer.append('a'.repeat(60)); + buffer.append('b'.repeat(60)); // Total 120, trims to 50 + + expect(trimmedBytes).toBe(70); // 120 - 50 = 70 + }); + + it('should not call onTrim when no trimming needed', () => { + let trimCalled = false; + const buffer = new BufferAccumulator({ + maxSize: 100, + trimSize: 50, + onTrim: () => { trimCalled = true; }, + }); + + buffer.append('a'.repeat(50)); // Under max + + expect(trimCalled).toBe(false); + }); + }); + + describe('tail and search operations', () => { + it('should get tail of buffer', () => { + const buffer = new BufferAccumulator(1000, 800); + buffer.append('hello world'); + + expect(buffer.tail(5)).toBe('world'); + expect(buffer.tail(6)).toBe(' world'); + expect(buffer.tail(100)).toBe('hello world'); // Returns all if n > length + }); + + it('should check endsWith', () => { + const buffer = new BufferAccumulator(1000, 800); + buffer.append('hello world'); + + expect(buffer.endsWith('world')).toBe(true); + expect(buffer.endsWith('hello')).toBe(false); + expect(buffer.endsWith('')).toBe(true); + expect(buffer.endsWith('a very long string')).toBe(false); + }); + + it('should search with contains (string)', () => { + const buffer = new BufferAccumulator(1000, 800); + buffer.append('hello world, how are you?'); + + expect(buffer.contains('world')).toBe(true); + expect(buffer.contains('foo')).toBe(false); + expect(buffer.contains('you', 10)).toBe(true); + expect(buffer.contains('hello', 10)).toBe(false); + }); + + it('should search with contains (regex)', () => { + const buffer = new BufferAccumulator(1000, 800); + buffer.append('hello world 123'); + + expect(buffer.contains(/\d+/)).toBe(true); + expect(buffer.contains(/foo/)).toBe(false); + expect(buffer.contains(/world/)).toBe(true); + }); + }); + + describe('constructor overloads', () => { + it('should accept simple number parameters', () => { + const buffer = new BufferAccumulator(100, 50); + buffer.append('a'.repeat(120)); + expect(buffer.length).toBe(50); + }); + + it('should accept BufferConfig object', () => { + const buffer = new BufferAccumulator({ + maxSize: 100, + trimSize: 50, + }); + buffer.append('a'.repeat(120)); + expect(buffer.length).toBe(50); + }); + }); + + describe('chunk consolidation', () => { + it('should consolidate chunks on value access', () => { + const buffer = new BufferAccumulator(1000, 800); + buffer.append('a'); + buffer.append('b'); + buffer.append('c'); + + // First access consolidates + const value1 = buffer.value; + expect(value1).toBe('abc'); + + // Second access should return same value efficiently + const value2 = buffer.value; + expect(value2).toBe('abc'); + }); + + it('should handle single chunk efficiently', () => { + const buffer = new BufferAccumulator(1000, 800); + buffer.append('hello world'); + + // Single chunk doesn't need joining + expect(buffer.value).toBe('hello world'); + }); + }); +}); diff --git a/test/utils/cleanup-manager.test.ts b/test/utils/cleanup-manager.test.ts new file mode 100644 index 00000000..24a77534 --- /dev/null +++ b/test/utils/cleanup-manager.test.ts @@ -0,0 +1,301 @@ +/** + * Tests for CleanupManager utility. + * + * Port: N/A (unit tests, no server) + */ + +import { CleanupManager } from '../../src/utils/cleanup-manager.js'; + +describe('CleanupManager', () => { + describe('basic state', () => { + it('should start not disposed', () => { + const cleanup = new CleanupManager(); + expect(cleanup.isDisposed).toBe(false); + expect(cleanup.isStopped).toBe(false); + expect(cleanup.resourceCount).toBe(0); + }); + + it('should track resource counts by type', () => { + const cleanup = new CleanupManager(); + + cleanup.setTimeout(() => {}, 10000); + cleanup.setInterval(() => {}, 10000); + cleanup.registerCleanup('watcher', () => {}, 'test watcher'); + + const counts = cleanup.resourceCounts; + expect(counts.timer).toBe(1); + expect(counts.interval).toBe(1); + expect(counts.watcher).toBe(1); + expect(counts.listener).toBe(0); + expect(counts.stream).toBe(0); + + cleanup.dispose(); + }); + }); + + describe('setTimeout', () => { + it('should execute callback after delay', async () => { + const cleanup = new CleanupManager(); + let called = false; + + cleanup.setTimeout(() => { called = true; }, 50); + + await new Promise(r => setTimeout(r, 100)); + + expect(called).toBe(true); + cleanup.dispose(); + }); + + it('should not execute callback if disposed before delay', async () => { + const cleanup = new CleanupManager(); + let called = false; + + cleanup.setTimeout(() => { called = true; }, 100); + cleanup.dispose(); + + await new Promise(r => setTimeout(r, 150)); + + expect(called).toBe(false); + }); + + it('should remove registration when timer fires naturally', async () => { + const cleanup = new CleanupManager(); + + cleanup.setTimeout(() => {}, 50); + expect(cleanup.resourceCount).toBe(1); + + await new Promise(r => setTimeout(r, 100)); + + expect(cleanup.resourceCount).toBe(0); + cleanup.dispose(); + }); + }); + + describe('setInterval', () => { + it('should execute callback repeatedly', async () => { + const cleanup = new CleanupManager(); + let count = 0; + + cleanup.setInterval(() => { count++; }, 30); + + await new Promise(r => setTimeout(r, 100)); + + expect(count).toBeGreaterThanOrEqual(2); + cleanup.dispose(); + }); + + it('should stop executing after dispose', async () => { + const cleanup = new CleanupManager(); + let count = 0; + + cleanup.setInterval(() => { count++; }, 20); + + await new Promise(r => setTimeout(r, 50)); + const countAtDispose = count; + cleanup.dispose(); + + await new Promise(r => setTimeout(r, 100)); + + expect(count).toBe(countAtDispose); + }); + }); + + describe('registerCleanup', () => { + it('should call cleanup function on dispose', () => { + const cleanup = new CleanupManager(); + let cleaned = false; + + cleanup.registerCleanup('watcher', () => { cleaned = true; }, 'test'); + cleanup.dispose(); + + expect(cleaned).toBe(true); + }); + + it('should continue cleanup even if one fails', () => { + const cleanup = new CleanupManager(); + let secondCleaned = false; + + cleanup.registerCleanup('watcher', () => { throw new Error('fail'); }, 'first'); + cleanup.registerCleanup('watcher', () => { secondCleaned = true; }, 'second'); + + // Should not throw + cleanup.dispose(); + + expect(secondCleaned).toBe(true); + }); + }); + + describe('registerWatcher', () => { + it('should call close() on dispose', () => { + const cleanup = new CleanupManager(); + let closed = false; + const watcher = { close: () => { closed = true; } }; + + cleanup.registerWatcher(watcher, 'test watcher'); + cleanup.dispose(); + + expect(closed).toBe(true); + }); + }); + + describe('registerListener', () => { + it('should call removeListener on dispose', () => { + const cleanup = new CleanupManager(); + const listener = () => {}; + let removed = false; + const emitter = { + removeListener: (event: string, fn: () => void) => { + if (event === 'data' && fn === listener) removed = true; + }, + }; + + cleanup.registerListener(emitter, 'data', listener, 'test listener'); + cleanup.dispose(); + + expect(removed).toBe(true); + }); + + it('should call off() if removeListener not available', () => { + const cleanup = new CleanupManager(); + const listener = () => {}; + let removed = false; + const emitter = { + off: (event: string, fn: () => void) => { + if (event === 'data' && fn === listener) removed = true; + }, + }; + + cleanup.registerListener(emitter, 'data', listener, 'test listener'); + cleanup.dispose(); + + expect(removed).toBe(true); + }); + }); + + describe('registerStream', () => { + it('should call destroy() on dispose', () => { + const cleanup = new CleanupManager(); + let destroyed = false; + const stream = { destroy: () => { destroyed = true; } }; + + cleanup.registerStream(stream, 'test stream'); + cleanup.dispose(); + + expect(destroyed).toBe(true); + }); + + it('should call close() if destroy not available', () => { + const cleanup = new CleanupManager(); + let closed = false; + const stream = { close: () => { closed = true; } }; + + cleanup.registerStream(stream, 'test stream'); + cleanup.dispose(); + + expect(closed).toBe(true); + }); + }); + + describe('unregister', () => { + it('should manually remove and cleanup resource', () => { + const cleanup = new CleanupManager(); + let cleaned = false; + + const id = cleanup.registerCleanup('watcher', () => { cleaned = true; }, 'test'); + expect(cleanup.resourceCount).toBe(1); + + const result = cleanup.unregister(id); + + expect(result).toBe(true); + expect(cleaned).toBe(true); + expect(cleanup.resourceCount).toBe(0); + + cleanup.dispose(); + }); + + it('should return false for unknown id', () => { + const cleanup = new CleanupManager(); + + const result = cleanup.unregister('unknown-id'); + + expect(result).toBe(false); + cleanup.dispose(); + }); + }); + + describe('dispose', () => { + it('should be idempotent (safe to call multiple times)', () => { + const cleanup = new CleanupManager(); + let cleanupCount = 0; + + cleanup.registerCleanup('watcher', () => { cleanupCount++; }, 'test'); + + cleanup.dispose(); + cleanup.dispose(); + cleanup.dispose(); + + expect(cleanupCount).toBe(1); + }); + + it('should mark as disposed', () => { + const cleanup = new CleanupManager(); + + cleanup.dispose(); + + expect(cleanup.isDisposed).toBe(true); + expect(cleanup.isStopped).toBe(true); + }); + + it('should clear all registrations', () => { + const cleanup = new CleanupManager(); + + cleanup.registerCleanup('watcher', () => {}, 'test1'); + cleanup.registerCleanup('watcher', () => {}, 'test2'); + expect(cleanup.resourceCount).toBe(2); + + cleanup.dispose(); + + expect(cleanup.resourceCount).toBe(0); + }); + }); + + describe('isStopped guard pattern', () => { + it('should allow checking isStopped in callbacks', async () => { + const cleanup = new CleanupManager(); + let executedAfterStop = false; + + cleanup.setTimeout(() => { + if (cleanup.isStopped) { + executedAfterStop = false; + } else { + executedAfterStop = true; + } + }, 50); + + // Dispose before timer fires + cleanup.dispose(); + + await new Promise(r => setTimeout(r, 100)); + + // The callback shouldn't have set this to true + expect(executedAfterStop).toBe(false); + }); + }); + + describe('getRegistrations', () => { + it('should return all current registrations', () => { + const cleanup = new CleanupManager(); + + cleanup.setTimeout(() => {}, 10000, { description: 'timer1' }); + cleanup.registerCleanup('watcher', () => {}, 'watcher1'); + + const regs = cleanup.getRegistrations(); + + expect(regs.length).toBe(2); + expect(regs.some(r => r.type === 'timer')).toBe(true); + expect(regs.some(r => r.type === 'watcher')).toBe(true); + + cleanup.dispose(); + }); + }); +}); diff --git a/test/utils/lru-map.test.ts b/test/utils/lru-map.test.ts new file mode 100644 index 00000000..3d8c6565 --- /dev/null +++ b/test/utils/lru-map.test.ts @@ -0,0 +1,245 @@ +/** + * Tests for LRUMap utility. + * + * Port: N/A (unit tests, no server) + */ + +import { LRUMap } from '../../src/utils/lru-map.js'; + +describe('LRUMap', () => { + describe('basic operations', () => { + it('should start empty', () => { + const map = new LRUMap({ maxSize: 5 }); + expect(map.size).toBe(0); + expect(map.freeSlots).toBe(5); + }); + + it('should set and get values', () => { + const map = new LRUMap({ maxSize: 5 }); + map.set('a', 1); + map.set('b', 2); + + expect(map.get('a')).toBe(1); + expect(map.get('b')).toBe(2); + expect(map.get('c')).toBeUndefined(); + }); + + it('should update existing values', () => { + const map = new LRUMap({ maxSize: 5 }); + map.set('a', 1); + map.set('a', 10); + + expect(map.get('a')).toBe(10); + expect(map.size).toBe(1); + }); + + it('should delete values', () => { + const map = new LRUMap({ maxSize: 5 }); + map.set('a', 1); + map.delete('a'); + + expect(map.get('a')).toBeUndefined(); + expect(map.size).toBe(0); + }); + + it('should check has correctly', () => { + const map = new LRUMap({ maxSize: 5 }); + map.set('a', 1); + + expect(map.has('a')).toBe(true); + expect(map.has('b')).toBe(false); + }); + }); + + describe('LRU eviction', () => { + it('should evict oldest entry when maxSize exceeded', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + map.set('d', 4); // Should evict 'a' + + expect(map.size).toBe(3); + expect(map.has('a')).toBe(false); + expect(map.get('b')).toBe(2); + expect(map.get('c')).toBe(3); + expect(map.get('d')).toBe(4); + }); + + it('should call onEvict callback when evicting', () => { + const evicted: Array<[string, number]> = []; + const map = new LRUMap({ + maxSize: 2, + onEvict: (key, value) => evicted.push([key, value]), + }); + + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); // Evicts 'a' + map.set('d', 4); // Evicts 'b' + + expect(evicted).toEqual([['a', 1], ['b', 2]]); + }); + + it('should refresh position on get()', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + + // Access 'a' to refresh it + map.get('a'); + + // Add 'd' which should evict 'b' (now oldest) + map.set('d', 4); + + expect(map.has('a')).toBe(true); // 'a' was refreshed + expect(map.has('b')).toBe(false); // 'b' was evicted + expect(map.has('c')).toBe(true); + expect(map.has('d')).toBe(true); + }); + + it('should refresh position on set() for existing key', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + + // Update 'a' to refresh it + map.set('a', 10); + + // Add 'd' which should evict 'b' (now oldest) + map.set('d', 4); + + expect(map.has('a')).toBe(true); + expect(map.get('a')).toBe(10); + expect(map.has('b')).toBe(false); + }); + }); + + describe('peek and oldest/newest', () => { + it('should peek without refreshing position', () => { + const map = new LRUMap({ maxSize: 3 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + + // Peek 'a' (should NOT refresh) + expect(map.peek('a')).toBe(1); + + // Add 'd' which should evict 'a' (still oldest) + map.set('d', 4); + + expect(map.has('a')).toBe(false); + }); + + it('should get oldest entry', () => { + const map = new LRUMap({ maxSize: 5 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + + expect(map.oldest()).toEqual(['a', 1]); + }); + + it('should get newest entry', () => { + const map = new LRUMap({ maxSize: 5 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + + expect(map.newest()).toEqual(['c', 3]); + }); + + it('should return undefined for oldest/newest on empty map', () => { + const map = new LRUMap({ maxSize: 5 }); + + expect(map.oldest()).toBeUndefined(); + expect(map.newest()).toBeUndefined(); + }); + }); + + describe('expireOlderThan', () => { + it('should expire entries older than maxAge', () => { + interface Entry { value: number; timestamp: number } + const map = new LRUMap({ maxSize: 10 }); + + const now = Date.now(); + map.set('old1', { value: 1, timestamp: now - 10000 }); // 10s old + map.set('old2', { value: 2, timestamp: now - 8000 }); // 8s old + map.set('new1', { value: 3, timestamp: now - 2000 }); // 2s old + map.set('new2', { value: 4, timestamp: now - 1000 }); // 1s old + + const evicted = map.expireOlderThan(5000, (v) => v.timestamp); + + expect(evicted).toBe(2); + expect(map.size).toBe(2); + expect(map.has('old1')).toBe(false); + expect(map.has('old2')).toBe(false); + expect(map.has('new1')).toBe(true); + expect(map.has('new2')).toBe(true); + }); + + it('should call onEvict for expired entries', () => { + interface Entry { value: number; timestamp: number } + const evicted: string[] = []; + const map = new LRUMap({ + maxSize: 10, + onEvict: (key) => evicted.push(key), + }); + + const now = Date.now(); + map.set('old', { value: 1, timestamp: now - 10000 }); + map.set('new', { value: 2, timestamp: now - 1000 }); + + map.expireOlderThan(5000, (v) => v.timestamp); + + expect(evicted).toEqual(['old']); + }); + }); + + describe('iteration helpers', () => { + it('should return keys in order', () => { + const map = new LRUMap({ maxSize: 5 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + + expect(map.keysInOrder()).toEqual(['a', 'b', 'c']); + }); + + it('should return values in order', () => { + const map = new LRUMap({ maxSize: 5 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + + expect(map.valuesInOrder()).toEqual([1, 2, 3]); + }); + }); + + describe('properties', () => { + it('should report maxEntries correctly', () => { + const map = new LRUMap({ maxSize: 10 }); + expect(map.maxEntries).toBe(10); + }); + + it('should report freeSlots correctly', () => { + const map = new LRUMap({ maxSize: 5 }); + expect(map.freeSlots).toBe(5); + + map.set('a', 1); + map.set('b', 2); + expect(map.freeSlots).toBe(3); + + map.set('c', 3); + map.set('d', 4); + map.set('e', 5); + expect(map.freeSlots).toBe(0); + + // Adding more doesn't go negative + map.set('f', 6); + expect(map.freeSlots).toBe(0); + }); + }); +});