| ... | ... | @@ -29,6 +29,8 @@ import { |
| 29 | 29 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; |
| 30 | 30 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; |
| 31 | 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 | 34 | const DEFAULT_OPEN_COLS = 120; |
| 33 | 35 | const DEFAULT_OPEN_ROWS = 30; |
| 34 | 36 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); |
| ... | ... | @@ -279,6 +281,8 @@ interface TerminalManagerOptions { |
| 279 | 281 | shellResolver?: () => string; |
| 280 | 282 | subprocessChecker?: TerminalSubprocessChecker; |
| 281 | 283 | subprocessPollIntervalMs?: number; |
| 284 | processKillGraceMs?: number; |
| 285 | maxRetainedInactiveSessions?: number; |
| 282 | 286 | } |
| 283 | 287 | |
| 284 | 288 | export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> { |
| ... | ... | @@ -294,8 +298,11 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 294 | 298 | private readonly persistDebounceMs: number; |
| 295 | 299 | private readonly subprocessChecker: TerminalSubprocessChecker; |
| 296 | 300 | private readonly subprocessPollIntervalMs: number; |
| 301 | private readonly processKillGraceMs: number; |
| 302 | private readonly maxRetainedInactiveSessions: number; |
| 297 | 303 | private subprocessPollTimer: ReturnType<typeof setInterval> | null = null; |
| 298 | 304 | private subprocessPollInFlight = false; |
| 305 | private readonly killEscalationTimers = new Map<PtyProcess, ReturnType<typeof setTimeout>>(); |
| 299 | 306 | private readonly logger = createLogger("terminal"); |
| 300 | 307 | |
| 301 | 308 | constructor(options: TerminalManagerOptions) { |
| ... | ... | @@ -308,6 +315,9 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 308 | 315 | this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; |
| 309 | 316 | this.subprocessPollIntervalMs = |
| 310 | 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 | 321 | fs.mkdirSync(this.logsDir, { recursive: true }); |
| 312 | 322 | } |
| 313 | 323 | |
| ... | ... | @@ -342,6 +352,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 342 | 352 | runtimeEnv: normalizedRuntimeEnv(input.env), |
| 343 | 353 | }; |
| 344 | 354 | this.sessions.set(sessionKey, session); |
| 355 | this.evictInactiveSessionsIfNeeded(); |
| 345 | 356 | this.startSession(session, { ...input, cols, rows }, "started"); |
| 346 | 357 | return this.snapshot(session); |
| 347 | 358 | } |
| ... | ... | @@ -453,6 +464,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 453 | 464 | runtimeEnv: normalizedRuntimeEnv(input.env), |
| 454 | 465 | }; |
| 455 | 466 | this.sessions.set(sessionKey, session); |
| 467 | this.evictInactiveSessionsIfNeeded(); |
| 456 | 468 | } else { |
| 457 | 469 | this.stopProcess(session); |
| 458 | 470 | session.cwd = input.cwd; |
| ... | ... | @@ -506,6 +518,10 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 506 | 518 | clearTimeout(timer); |
| 507 | 519 | } |
| 508 | 520 | this.persistTimers.clear(); |
| 521 | for (const timer of this.killEscalationTimers.values()) { |
| 522 | clearTimeout(timer); |
| 523 | } |
| 524 | this.killEscalationTimers.clear(); |
| 509 | 525 | this.pendingPersistHistory.clear(); |
| 510 | 526 | this.threadLocks.clear(); |
| 511 | 527 | this.persistQueues.clear(); |
| ... | ... | @@ -584,17 +600,14 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 584 | 600 | }); |
| 585 | 601 | } catch (error) { |
| 586 | 602 | if (ptyProcess) { |
| 587 | | try { |
| 588 | | ptyProcess.kill(); |
| 589 | | } catch { |
| 590 | | // Ignore kill errors during failed startup cleanup. |
| 591 | | } |
| 603 | this.killProcessWithEscalation(ptyProcess, session.threadId, session.terminalId); |
| 592 | 604 | } |
| 593 | 605 | session.status = "error"; |
| 594 | 606 | session.pid = null; |
| 595 | 607 | session.process = null; |
| 596 | 608 | session.hasRunningSubprocess = false; |
| 597 | 609 | session.updatedAt = new Date().toISOString(); |
| 610 | this.evictInactiveSessionsIfNeeded(); |
| 598 | 611 | this.updateSubprocessPollingState(); |
| 599 | 612 | const message = error instanceof Error ? error.message : "Terminal start failed"; |
| 600 | 613 | this.emitEvent({ |
| ... | ... | @@ -627,6 +640,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 627 | 640 | } |
| 628 | 641 | |
| 629 | 642 | private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void { |
| 643 | this.clearKillEscalationTimer(session.process); |
| 630 | 644 | this.cleanupProcessHandles(session); |
| 631 | 645 | session.process = null; |
| 632 | 646 | session.pid = null; |
| ... | ... | @@ -643,6 +657,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 643 | 657 | exitCode: session.exitCode, |
| 644 | 658 | exitSignal: session.exitSignal, |
| 645 | 659 | }); |
| 660 | this.evictInactiveSessionsIfNeeded(); |
| 646 | 661 | this.updateSubprocessPollingState(); |
| 647 | 662 | } |
| 648 | 663 | |
| ... | ... | @@ -655,24 +670,80 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> |
| 655 | 670 | session.hasRunningSubprocess = false; |
| 656 | 671 | session.status = "exited"; |
| 657 | 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 | 695 | try { |
| 659 | | process.kill(); |
| 696 | process.kill("SIGTERM"); |
| 660 | 697 | } catch (error) { |
| 661 | 698 | const message = error instanceof Error ? error.message : String(error); |
| 662 | 699 | this.logger.warn("failed to kill terminal process", { |
| 663 | | threadId: session.threadId, |
| 664 | | terminalId: session.terminalId, |
| 700 | threadId, |
| 701 | terminalId, |
| 702 | signal: "SIGTERM", |
| 665 | 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 { |
| 672 | | session.unsubscribeData?.(); |
| 673 | | session.unsubscribeData = null; |
| 674 | | session.unsubscribeExit?.(); |
| 675 | | session.unsubscribeExit = null; |
| 726 | private evictInactiveSessionsIfNeeded(): void { |
| 727 | const inactiveSessions = [...this.sessions.values()].filter((session) => session.status !== "running"); |
| 728 | if (inactiveSessions.length <= this.maxRetainedInactiveSessions) { |
| 729 | return; |
| 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 | 749 | private queuePersist(threadId: string, terminalId: string, history: string): void { |