import { PendingRequest, AutomationBridgeResponseMessage } from './types.js'; import { randomUUID, createHash } from 'node:crypto'; import { PROGRESS_EXTENSION_MS, MAX_PROGRESS_EXTENSIONS, PROGRESS_STALE_THRESHOLD, ABSOLUTE_MAX_TIMEOUT_MS } from '../constants.js'; const READ_ONLY_ACTION_PREFIXES = ['list', 'get_', 'exists', 'search', 'find']; // Note: The two-step event pattern was disabled because C++ handlers send a single response, // not request+event. All actions now use simple request-response. The PendingRequest interface // retains waitForEvent/eventTimeout fields for potential future use. export class RequestTracker { private pendingRequests = new Map(); private coalescedRequests = new Map>(); private lastRequestSentAt?: Date; constructor( private maxPendingRequests: number ) { } /** * Get the maximum number of pending requests allowed. * @returns The configured maximum pending requests limit */ public getMaxPendingRequests(): number { return this.maxPendingRequests; } /** * Get the timestamp of when the last request was sent. * @returns The Date of last request or undefined if no requests sent yet */ public getLastRequestSentAt(): Date | undefined { return this.lastRequestSentAt; } /** * Update the last request sent timestamp. * Called when a new request is dispatched. */ public updateLastRequestSentAt(): void { this.lastRequestSentAt = new Date(); } /** * Create a new pending request with timeout handling. * @param action - The action name being requested * @param payload - The request payload * @param timeoutMs - Timeout in milliseconds before the request fails * @returns Object containing the requestId and a promise that resolves with the response * @throws Error if max pending requests limit is reached */ public createRequest( action: string, payload: Record, timeoutMs: number ): { requestId: string; promise: Promise } { if (this.pendingRequests.size >= this.maxPendingRequests) { throw new Error(`Max pending requests limit reached (${this.maxPendingRequests})`); } const requestId = randomUUID(); const promise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { if (this.pendingRequests.has(requestId)) { this.cleanupRequest(requestId); reject(new Error(`Request ${requestId} timed out after ${timeoutMs}ms`)); } }, timeoutMs); // Set up absolute timeout cap to prevent indefinite extension const absoluteTimeout = setTimeout(() => { if (this.pendingRequests.has(requestId)) { this.cleanupRequest(requestId); const totalMs = ABSOLUTE_MAX_TIMEOUT_MS; reject(new Error(`Request ${requestId} exceeded absolute max timeout (${totalMs}ms)`)); } }, ABSOLUTE_MAX_TIMEOUT_MS); this.pendingRequests.set(requestId, { resolve, reject, timeout, action, payload, requestedAt: new Date(), // Note: waitForEvent and eventTimeoutMs are preserved for potential future use // but currently all actions use simple request-response pattern waitForEvent: false, eventTimeoutMs: timeoutMs, // Progress tracking initialization extensionCount: 0, lastProgressPercent: undefined, staleCount: 0, absoluteTimeout, totalExtensionMs: 0 }); }); return { requestId, promise }; } /** * Extend the timeout for a pending request based on progress update. * Implements safeguards against deadlock from false "alive" signals: * 1. Max extensions limit (MAX_PROGRESS_EXTENSIONS) * 2. Stale detection (percent unchanged for PROGRESS_STALE_THRESHOLD updates) * 3. Absolute max timeout cap (ABSOLUTE_MAX_TIMEOUT_MS) * * @param requestId - The request ID to extend * @param percent - Current progress percent (0-100) * @param message - Optional progress message * @returns True if timeout was extended, false if rejected (deadlock prevention) */ public extendTimeout(requestId: string, percent?: number, _message?: string): boolean { const pending = this.pendingRequests.get(requestId); if (!pending) { return false; } // Check 1: Max extensions limit if (pending.extensionCount !== undefined && pending.extensionCount >= MAX_PROGRESS_EXTENSIONS) { pending.reject(new Error( `Request ${requestId} exceeded max progress extensions (${MAX_PROGRESS_EXTENSIONS}) - possible deadlock detected` )); this.cleanupRequest(requestId); return false; } // Check 2: Stale detection - same percent for too many updates if (percent !== undefined && pending.lastProgressPercent === percent) { pending.staleCount = (pending.staleCount || 0) + 1; if (pending.staleCount >= PROGRESS_STALE_THRESHOLD) { pending.reject(new Error( `Request ${requestId} stalled - progress unchanged at ${percent}% for ${PROGRESS_STALE_THRESHOLD} updates` )); this.cleanupRequest(requestId); return false; } } else { // Reset stale count on progress change pending.staleCount = 0; } // Clear existing timeout and set new one clearTimeout(pending.timeout); const newTimeout = setTimeout(() => { if (this.pendingRequests.has(requestId)) { this.cleanupRequest(requestId); pending.reject(new Error(`Request ${requestId} timed out after extension`)); } }, PROGRESS_EXTENSION_MS); pending.timeout = newTimeout; pending.extensionCount = (pending.extensionCount || 0) + 1; pending.lastProgressPercent = percent; pending.totalExtensionMs = (pending.totalExtensionMs || 0) + PROGRESS_EXTENSION_MS; return true; } private clearRequestTimers(pending: PendingRequest): void { clearTimeout(pending.timeout); if (pending.eventTimeout) clearTimeout(pending.eventTimeout); if (pending.absoluteTimeout) clearTimeout(pending.absoluteTimeout); } /** * Clean up request timers and remove from map. */ private cleanupRequest(requestId: string): PendingRequest | undefined { const pending = this.pendingRequests.get(requestId); if (pending) { this.clearRequestTimers(pending); this.pendingRequests.delete(requestId); } return pending; } public getPendingRequest(requestId: string): PendingRequest | undefined { return this.pendingRequests.get(requestId); } public resolveRequest(requestId: string, response: AutomationBridgeResponseMessage): void { const pending = this.cleanupRequest(requestId); if (pending) { pending.resolve(response); } } public rejectRequest(requestId: string, error: Error): void { const pending = this.cleanupRequest(requestId); if (pending) { pending.reject(error); } } public rejectAll(error: Error): void { for (const [, pending] of this.pendingRequests) { this.clearRequestTimers(pending); pending.reject(error); } this.pendingRequests.clear(); } public getPendingCount(): number { return this.pendingRequests.size; } public getPendingDetails(): Array<{ requestId: string; action: string; ageMs: number }> { const now = Date.now(); return Array.from(this.pendingRequests.entries()).map(([id, pending]) => ({ requestId: id, action: pending.action, ageMs: Math.max(0, now - pending.requestedAt.getTime()) })); } public getCoalescedRequest(key: string): Promise | undefined { return this.coalescedRequests.get(key); } public setCoalescedRequest(key: string, promise: Promise): void { this.coalescedRequests.set(key, promise); // Remove from map when settled promise.finally(() => { if (this.coalescedRequests.get(key) === promise) { this.coalescedRequests.delete(key); } }).catch(() => undefined); } public createCoalesceKey(action: string, payload: Record): string { // Only coalesce read-only operations if (!READ_ONLY_ACTION_PREFIXES.some(a => action.startsWith(a))) return ''; // Create a stable hash of the payload const stablePayload = JSON.stringify(stabilizeJsonValue(payload)); return `${action}:${createHash('md5').update(stablePayload).digest('hex')}`; } } function stabilizeJsonValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map(item => stabilizeJsonValue(item)); } if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value as Record) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, child]) => [key, stabilizeJsonValue(child)]) ); } return value; }