| ... | @@ -29,6 +29,8 @@ import { | ... | @@ -29,6 +29,8 @@ import { |
| 29 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; | 29 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; |
| 30 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; | 30 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; |
| 31 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; | 31 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; |
| | 32 | const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; |
| | 33 | const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; |
| 32 | const DEFAULT_OPEN_COLS = 120; | 34 | const DEFAULT_OPEN_COLS = 120; |
| 33 | const DEFAULT_OPEN_ROWS = 30; | 35 | const DEFAULT_OPEN_ROWS = 30; |
| 34 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | 36 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); |
| ... | @@ -279,6 +281,8 @@ interface TerminalManagerOptions { | ... | @@ -279,6 +281,8 @@ interface TerminalManagerOptions { |
| 279 | shellResolver?: () => string; | 281 | shellResolver?: () => string; |
| 280 | subprocessChecker?: TerminalSubprocessChecker; | 282 | subprocessChecker?: TerminalSubprocessChecker; |
| 281 | subprocessPollIntervalMs?: number; | 283 | subprocessPollIntervalMs?: number; |
| | 284 | processKillGraceMs?: number; |
| | 285 | maxRetainedInactiveSessions?: number; |
| 282 | } | 286 | } |
| 283 | | 287 | |
| 284 | export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> { | 288 | export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> { |
| ... | @@ -294,8 +298,11 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -294,8 +298,11 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 294 | private readonly persistDebounceMs: number; | 298 | private readonly persistDebounceMs: number; |
| 295 | private readonly subprocessChecker: TerminalSubprocessChecker; | 299 | private readonly subprocessChecker: TerminalSubprocessChecker; |
| 296 | private readonly subprocessPollIntervalMs: number; | 300 | private readonly subprocessPollIntervalMs: number; |
| | 301 | private readonly processKillGraceMs: number; |
| | 302 | private readonly maxRetainedInactiveSessions: number; |
| 297 | private subprocessPollTimer: ReturnType<typeof setInterval> | null = null; | 303 | private subprocessPollTimer: ReturnType<typeof setInterval> | null = null; |
| 298 | private subprocessPollInFlight = false; | 304 | private subprocessPollInFlight = false; |
| | 305 | private readonly killEscalationTimers = new Map<PtyProcess, ReturnType<typeof setTimeout>>(); |
| 299 | private readonly logger = createLogger("terminal"); | 306 | private readonly logger = createLogger("terminal"); |
| 300 | | 307 | |
| 301 | constructor(options: TerminalManagerOptions) { | 308 | constructor(options: TerminalManagerOptions) { |
| ... | @@ -308,6 +315,9 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -308,6 +315,9 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 308 | this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; | 315 | this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; |
| 309 | this.subprocessPollIntervalMs = | 316 | this.subprocessPollIntervalMs = |
| 310 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; | 317 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; |
| | 318 | this.processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; |
| | 319 | this.maxRetainedInactiveSessions = |
| | 320 | options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; |
| 311 | fs.mkdirSync(this.logsDir, { recursive: true }); | 321 | fs.mkdirSync(this.logsDir, { recursive: true }); |
| 312 | } | 322 | } |
| 313 | | 323 | |
| ... | @@ -342,6 +352,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -342,6 +352,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 342 | runtimeEnv: normalizedRuntimeEnv(input.env), | 352 | runtimeEnv: normalizedRuntimeEnv(input.env), |
| 343 | }; | 353 | }; |
| 344 | this.sessions.set(sessionKey, session); | 354 | this.sessions.set(sessionKey, session); |
| | 355 | this.evictInactiveSessionsIfNeeded(); |
| 345 | this.startSession(session, { ...input, cols, rows }, "started"); | 356 | this.startSession(session, { ...input, cols, rows }, "started"); |
| 346 | return this.snapshot(session); | 357 | return this.snapshot(session); |
| 347 | } | 358 | } |
| ... | @@ -453,6 +464,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -453,6 +464,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 453 | runtimeEnv: normalizedRuntimeEnv(input.env), | 464 | runtimeEnv: normalizedRuntimeEnv(input.env), |
| 454 | }; | 465 | }; |
| 455 | this.sessions.set(sessionKey, session); | 466 | this.sessions.set(sessionKey, session); |
| | 467 | this.evictInactiveSessionsIfNeeded(); |
| 456 | } else { | 468 | } else { |
| 457 | this.stopProcess(session); | 469 | this.stopProcess(session); |
| 458 | session.cwd = input.cwd; | 470 | session.cwd = input.cwd; |
| ... | @@ -506,6 +518,10 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -506,6 +518,10 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 506 | clearTimeout(timer); | 518 | clearTimeout(timer); |
| 507 | } | 519 | } |
| 508 | this.persistTimers.clear(); | 520 | this.persistTimers.clear(); |
| | 521 | for (const timer of this.killEscalationTimers.values()) { |
| | 522 | clearTimeout(timer); |
| | 523 | } |
| | 524 | this.killEscalationTimers.clear(); |
| 509 | this.pendingPersistHistory.clear(); | 525 | this.pendingPersistHistory.clear(); |
| 510 | this.threadLocks.clear(); | 526 | this.threadLocks.clear(); |
| 511 | this.persistQueues.clear(); | 527 | this.persistQueues.clear(); |
| ... | @@ -584,17 +600,14 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -584,17 +600,14 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 584 | }); | 600 | }); |
| 585 | } catch (error) { | 601 | } catch (error) { |
| 586 | if (ptyProcess) { | 602 | if (ptyProcess) { |
| 587 | try { | 603 | this.killProcessWithEscalation(ptyProcess, session.threadId, session.terminalId); |
| 588 | ptyProcess.kill(); | | |
| 589 | } catch { | | |
| 590 | // Ignore kill errors during failed startup cleanup. | | |
| 591 | } | | |
| 592 | } | 604 | } |
| 593 | session.status = "error"; | 605 | session.status = "error"; |
| 594 | session.pid = null; | 606 | session.pid = null; |
| 595 | session.process = null; | 607 | session.process = null; |
| 596 | session.hasRunningSubprocess = false; | 608 | session.hasRunningSubprocess = false; |
| 597 | session.updatedAt = new Date().toISOString(); | 609 | session.updatedAt = new Date().toISOString(); |
| | 610 | this.evictInactiveSessionsIfNeeded(); |
| 598 | this.updateSubprocessPollingState(); | 611 | this.updateSubprocessPollingState(); |
| 599 | const message = error instanceof Error ? error.message : "Terminal start failed"; | 612 | const message = error instanceof Error ? error.message : "Terminal start failed"; |
| 600 | this.emitEvent({ | 613 | this.emitEvent({ |
| ... | @@ -627,6 +640,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -627,6 +640,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 627 | } | 640 | } |
| 628 | | 641 | |
| 629 | private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void { | 642 | private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void { |
| | 643 | this.clearKillEscalationTimer(session.process); |
| 630 | this.cleanupProcessHandles(session); | 644 | this.cleanupProcessHandles(session); |
| 631 | session.process = null; | 645 | session.process = null; |
| 632 | session.pid = null; | 646 | session.pid = null; |
| ... | @@ -643,6 +657,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -643,6 +657,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 643 | exitCode: session.exitCode, | 657 | exitCode: session.exitCode, |
| 644 | exitSignal: session.exitSignal, | 658 | exitSignal: session.exitSignal, |
| 645 | }); | 659 | }); |
| | 660 | this.evictInactiveSessionsIfNeeded(); |
| 646 | this.updateSubprocessPollingState(); | 661 | this.updateSubprocessPollingState(); |
| 647 | } | 662 | } |
| 648 | | 663 | |
| ... | @@ -655,24 +670,80 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> | ... | @@ -655,24 +670,80 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 655 | session.hasRunningSubprocess = false; | 670 | session.hasRunningSubprocess = false; |
| 656 | session.status = "exited"; | 671 | session.status = "exited"; |
| 657 | session.updatedAt = new Date().toISOString(); | 672 | session.updatedAt = new Date().toISOString(); |
| | 673 | this.killProcessWithEscalation(process, session.threadId, session.terminalId); |
| | 674 | this.evictInactiveSessionsIfNeeded(); |
| | 675 | this.updateSubprocessPollingState(); |
| | 676 | } |
| | 677 | |
| | 678 | private cleanupProcessHandles(session: TerminalSessionState): void { |
| | 679 | session.unsubscribeData?.(); |
| | 680 | session.unsubscribeData = null; |
| | 681 | session.unsubscribeExit?.(); |
| | 682 | session.unsubscribeExit = null; |
| | 683 | } |
| | 684 | |
| | 685 | private clearKillEscalationTimer(process: PtyProcess | null): void { |
| | 686 | if (!process) return; |
| | 687 | const timer = this.killEscalationTimers.get(process); |
| | 688 | if (!timer) return; |
| | 689 | clearTimeout(timer); |
| | 690 | this.killEscalationTimers.delete(process); |
| | 691 | } |
| | 692 | |
| | 693 | private killProcessWithEscalation(process: PtyProcess, threadId: string, terminalId: string): void { |
| | 694 | this.clearKillEscalationTimer(process); |
| 658 | try { | 695 | try { |
| 659 | process.kill(); | 696 | process.kill("SIGTERM"); |
| 660 | } catch (error) { | 697 | } catch (error) { |
| 661 | const message = error instanceof Error ? error.message : String(error); | 698 | const message = error instanceof Error ? error.message : String(error); |
| 662 | this.logger.warn("failed to kill terminal process", { | 699 | this.logger.warn("failed to kill terminal process", { |
| 663 | threadId: session.threadId, | 700 | threadId, |
| 664 | terminalId: session.terminalId, | 701 | terminalId, |
| | 702 | signal: "SIGTERM", |
| 665 | error: message, | 703 | error: message, |
| 666 | }); | 704 | }); |
| | 705 | return; |
| 667 | } | 706 | } |
| 668 | this.updateSubprocessPollingState(); | 707 | |
| | 708 | const timer = setTimeout(() => { |
| | 709 | this.killEscalationTimers.delete(process); |
| | 710 | try { |
| | 711 | process.kill("SIGKILL"); |
| | 712 | } catch (error) { |
| | 713 | const message = error instanceof Error ? error.message : String(error); |
| | 714 | this.logger.warn("failed to force-kill terminal process", { |
| | 715 | threadId, |
| | 716 | terminalId, |
| | 717 | signal: "SIGKILL", |
| | 718 | error: message, |
| | 719 | }); |
| | 720 | } |
| | 721 | }, this.processKillGraceMs); |
| | 722 | timer.unref?.(); |
| | 723 | this.killEscalationTimers.set(process, timer); |
| 669 | } | 724 | } |
| 670 | | 725 | |
| 671 | private cleanupProcessHandles(session: TerminalSessionState): void { | 726 | private evictInactiveSessionsIfNeeded(): void { |
| 672 | session.unsubscribeData?.(); | 727 | const inactiveSessions = [...this.sessions.values()].filter((session) => session.status !== "running"); |
| 673 | session.unsubscribeData = null; | 728 | if (inactiveSessions.length <= this.maxRetainedInactiveSessions) { |
| 674 | session.unsubscribeExit?.(); | 729 | return; |
| 675 | session.unsubscribeExit = null; | 730 | } |
| | 731 | |
| | 732 | inactiveSessions.sort( |
| | 733 | (left, right) => |
| | 734 | left.updatedAt.localeCompare(right.updatedAt) || |
| | 735 | left.threadId.localeCompare(right.threadId) || |
| | 736 | left.terminalId.localeCompare(right.terminalId), |
| | 737 | ); |
| | 738 | const toEvict = inactiveSessions.length - this.maxRetainedInactiveSessions; |
| | 739 | for (const session of inactiveSessions.slice(0, toEvict)) { |
| | 740 | const key = toSessionKey(session.threadId, session.terminalId); |
| | 741 | this.sessions.delete(key); |
| | 742 | this.clearPersistTimer(session.threadId, session.terminalId); |
| | 743 | this.pendingPersistHistory.delete(key); |
| | 744 | this.persistQueues.delete(key); |
| | 745 | this.clearKillEscalationTimer(session.process); |
| | 746 | } |
| 676 | } | 747 | } |
| 677 | | 748 | |
| 678 | private queuePersist(threadId: string, terminalId: string, history: string): void { | 749 | private queuePersist(threadId: string, terminalId: string, history: string): void { |