mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: add resource management types and utilities for memory optimization
- Add Disposable, BufferConfig, MemoryMetrics, CleanupRegistration types - Create src/config/buffer-limits.ts with consolidated buffer size constants - Create src/config/map-limits.ts with Map size limits to prevent unbounded growth - Implement BufferAccumulator utility with configurable trim and onTrim callback - Implement LRUMap with automatic eviction and O(1) operations - Implement CleanupManager for unified resource cleanup with isStopped guard - Add comprehensive tests for all new utilities This lays the foundation for memory leak prevention and performance improvements. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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<string, number>;
|
||||
/** 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 */
|
||||
|
||||
@@ -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;
|
||||
@@ -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<string, CleanupRegistration>();
|
||||
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<CleanupResourceType, number> {
|
||||
const counts: Record<CleanupResourceType, number> = {
|
||||
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<T extends { removeListener?: (event: string, listener: () => 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;
|
||||
@@ -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';
|
||||
@@ -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<K, V> {
|
||||
/** 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<string, number>({
|
||||
* 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<K, V> extends Map<K, V> {
|
||||
private readonly maxSize: number;
|
||||
private readonly onEvict?: (key: K, value: V) => void;
|
||||
|
||||
/**
|
||||
* Creates a new LRUMap.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
*/
|
||||
constructor(options: LRUMapOptions<K, V>) {
|
||||
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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>({ maxSize: 5 });
|
||||
expect(map.size).toBe(0);
|
||||
expect(map.freeSlots).toBe(5);
|
||||
});
|
||||
|
||||
it('should set and get values', () => {
|
||||
const map = new LRUMap<string, number>({ 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<string, number>({ 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<string, number>({ 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<string, number>({ 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<string, number>({ 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<string, number>({
|
||||
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<string, number>({ 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<string, number>({ 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<string, number>({ 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<string, number>({ 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<string, number>({ 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<string, number>({ 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<string, Entry>({ 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<string, Entry>({
|
||||
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<string, number>({ 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<string, number>({ 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<string, number>({ maxSize: 10 });
|
||||
expect(map.maxEntries).toBe(10);
|
||||
});
|
||||
|
||||
it('should report freeSlots correctly', () => {
|
||||
const map = new LRUMap<string, number>({ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user