From df91823800a86b3a7c4efcd613cc857c28f8476d Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 21 Jan 2026 05:34:45 +0100 Subject: [PATCH] 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 --- src/inner-loop-tracker.ts | 8 +++---- src/session.ts | 48 ++++++++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/inner-loop-tracker.ts b/src/inner-loop-tracker.ts index 3744e68b..99003f58 100644 --- a/src/inner-loop-tracker.ts +++ b/src/inner-loop-tracker.ts @@ -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; } diff --git a/src/session.ts b/src/session.ts index 8fd69402..ee20374a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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( + promise: Promise, + timeoutMs: number, + operation: string +): Promise { + let timeoutId: NodeJS.Timeout; + + const timeoutPromise = new Promise((_, 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 {