fix: improve memory safety and regex pattern handling

- Add withTimeout utility for async operation protection
- Move Promise callback cleanup earlier in stop() to prevent orphaned refs
- Fix regex lastIndex resets in inner-loop-tracker (reset BEFORE test)
- Add DEFAULT_ASYNC_TIMEOUT_MS and INTERACTIVE_START_TIMEOUT_MS constants

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-21 05:34:45 +01:00
co-authored by Claude Opus 4.5
parent d6e9530e5a
commit df91823800
2 changed files with 46 additions and 10 deletions
+4 -4
View File
@@ -461,21 +461,21 @@ export class InnerLoopTracker extends EventEmitter {
}
// Todo checkboxes: "- [ ] Task" or "- [x] Task"
// Reset lastIndex BEFORE test to ensure consistent matching with /g flag patterns
TODO_CHECKBOX_PATTERN.lastIndex = 0;
if (TODO_CHECKBOX_PATTERN.test(data)) {
// Reset lastIndex since we're reusing the global regex
TODO_CHECKBOX_PATTERN.lastIndex = 0;
return true;
}
// Todo indicator icons: "Todo: ☐", "Todo: ◐", etc.
TODO_INDICATOR_PATTERN.lastIndex = 0;
if (TODO_INDICATOR_PATTERN.test(data)) {
TODO_INDICATOR_PATTERN.lastIndex = 0;
return true;
}
// Claude Code native todo format: "☐ Task", "☒ Task"
TODO_NATIVE_PATTERN.lastIndex = 0;
if (TODO_NATIVE_PATTERN.test(data)) {
TODO_NATIVE_PATTERN.lastIndex = 0;
return true;
}
+42 -6
View File
@@ -26,6 +26,7 @@ import { ScreenManager } from './screen-manager.js';
export type { BackgroundTask } from './task-tracker.js';
export type { InnerLoopState, InnerTodoItem } from './types.js';
export { withTimeout };
// ============================================================================
// Buffer Size Constants
@@ -52,6 +53,12 @@ const MAX_LINE_BUFFER_SIZE = 64 * 1024;
/** Line buffer flush interval (100ms) - forces processing of partial lines */
const LINE_BUFFER_FLUSH_INTERVAL = 100;
/** Default timeout for async operations in milliseconds (5 minutes) */
const DEFAULT_ASYNC_TIMEOUT_MS = 5 * 60 * 1000;
/** Timeout for interactive session startup in milliseconds (30 seconds) */
const INTERACTIVE_START_TIMEOUT_MS = 30 * 1000;
// Filter out terminal focus escape sequences (focus in/out reports)
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
@@ -131,6 +138,33 @@ class BufferAccumulator {
}
}
/**
* Wraps a promise with a timeout to prevent indefinite hangs.
* If the promise doesn't resolve within the timeout, rejects with TimeoutError.
*
* @param promise - The promise to wrap
* @param timeoutMs - Timeout in milliseconds
* @param operation - Description of the operation for error messages
* @returns Promise that resolves/rejects with the original result or timeout error
*/
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
operation: string
): Promise<T> {
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${operation} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutId);
});
}
/**
* Represents a JSON message from Claude CLI's stream-json output format.
* Messages are newline-delimited JSON objects parsed from PTY output.
@@ -1253,6 +1287,14 @@ export class Session extends EventEmitter {
this._lineBufferFlushTimer = null;
}
// Immediately cleanup Promise callbacks to prevent orphaned references
// during the rest of stop() processing (e.g., if screen kill times out)
if (this.rejectPromise) {
this.rejectPromise(new Error('Session stopped'));
}
this.resolvePromise = null;
this.rejectPromise = null;
if (this.ptyProcess) {
const pid = this.ptyProcess.pid;
@@ -1306,12 +1348,6 @@ export class Session extends EventEmitter {
console.log('[Session] Keeping screen session alive:', this._screenSession.screenName);
this._screenSession = null; // Detach but don't kill
}
if (this.rejectPromise) {
this.rejectPromise(new Error('Session stopped'));
this.resolvePromise = null;
this.rejectPromise = null;
}
}
assignTask(taskId: string): void {