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:
arkon
2026-01-28 04:35:20 +01:00
co-authored by Claude Opus 4.5
parent 0a928144da
commit 83e5b0b78e
10 changed files with 1802 additions and 0 deletions
+193
View File
@@ -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');
});
});
});
+301
View File
@@ -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();
});
});
});
+245
View File
@@ -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);
});
});
});