| ... | ... | @@ -21,9 +21,13 @@ import { |
| 21 | 21 | |
| 22 | 22 | import { createLogger } from "./logger"; |
| 23 | 23 | import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; |
| 24 | import { runProcess } from "./processRunner"; |
| 24 | 25 | |
| 25 | 26 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; |
| 26 | 27 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; |
| 28 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; |
| 29 | |
| 30 | type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; |
| 27 | 31 | |
| 28 | 32 | export interface TerminalManagerEvents { |
| 29 | 33 | event: [event: TerminalEvent]; |
| ... | ... | @@ -34,6 +38,8 @@ export interface TerminalManagerOptions { |
| 34 | 38 | historyLineLimit?: number; |
| 35 | 39 | ptyAdapter?: PtyAdapter; |
| 36 | 40 | shellResolver?: () => string; |
| 41 | subprocessChecker?: TerminalSubprocessChecker; |
| 42 | subprocessPollIntervalMs?: number; |
| 37 | 43 | } |
| 38 | 44 | |
| 39 | 45 | interface TerminalSessionState { |
| ... | ... | @@ -51,6 +57,7 @@ interface TerminalSessionState { |
| 51 | 57 | process: PtyProcess | null; |
| 52 | 58 | unsubscribeData: (() => void) | null; |
| 53 | 59 | unsubscribeExit: (() => void) | null; |
| 60 | hasRunningSubprocess: boolean; |
| 54 | 61 | } |
| 55 | 62 | |
| 56 | 63 | function defaultShellResolver(): string { |
| ... | ... | @@ -122,6 +129,83 @@ function isRetryableShellSpawnError(error: unknown): boolean { |
| 122 | 129 | ); |
| 123 | 130 | } |
| 124 | 131 | |
| 132 | async function checkWindowsSubprocessActivity(terminalPid: number): Promise<boolean> { |
| 133 | const command = [ |
| 134 | `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, |
| 135 | "if ($children) { exit 0 }", |
| 136 | "exit 1", |
| 137 | ].join("; "); |
| 138 | try { |
| 139 | const result = await runProcess( |
| 140 | "powershell.exe", |
| 141 | ["-NoProfile", "-NonInteractive", "-Command", command], |
| 142 | { |
| 143 | timeoutMs: 1_500, |
| 144 | allowNonZeroExit: true, |
| 145 | maxBufferBytes: 32_768, |
| 146 | outputMode: "truncate", |
| 147 | }, |
| 148 | ); |
| 149 | return result.code === 0; |
| 150 | } catch { |
| 151 | return false; |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolean> { |
| 156 | try { |
| 157 | const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], { |
| 158 | timeoutMs: 1_000, |
| 159 | allowNonZeroExit: true, |
| 160 | maxBufferBytes: 32_768, |
| 161 | outputMode: "truncate", |
| 162 | }); |
| 163 | if (pgrepResult.code === 0) { |
| 164 | return pgrepResult.stdout.trim().length > 0; |
| 165 | } |
| 166 | if (pgrepResult.code === 1) { |
| 167 | return false; |
| 168 | } |
| 169 | } catch { |
| 170 | // Fall back to ps when pgrep is unavailable. |
| 171 | } |
| 172 | |
| 173 | try { |
| 174 | const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { |
| 175 | timeoutMs: 1_000, |
| 176 | allowNonZeroExit: true, |
| 177 | maxBufferBytes: 262_144, |
| 178 | outputMode: "truncate", |
| 179 | }); |
| 180 | if (psResult.code !== 0) { |
| 181 | return false; |
| 182 | } |
| 183 | |
| 184 | for (const line of psResult.stdout.split(/\r?\n/g)) { |
| 185 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); |
| 186 | const pid = Number(pidRaw); |
| 187 | const ppid = Number(ppidRaw); |
| 188 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; |
| 189 | if (ppid === terminalPid) { |
| 190 | return true; |
| 191 | } |
| 192 | } |
| 193 | return false; |
| 194 | } catch { |
| 195 | return false; |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | async function defaultSubprocessChecker(terminalPid: number): Promise<boolean> { |
| 200 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { |
| 201 | return false; |
| 202 | } |
| 203 | if (process.platform === "win32") { |
| 204 | return checkWindowsSubprocessActivity(terminalPid); |
| 205 | } |
| 206 | return checkPosixSubprocessActivity(terminalPid); |
| 207 | } |
| 208 | |
| 125 | 209 | function capHistory(history: string, maxLines: number): string { |
| 126 | 210 | if (history.length === 0) return history; |
| 127 | 211 | const hasTrailingNewline = history.endsWith("\n"); |
| ... | ... | @@ -161,6 +245,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 161 | 245 | private readonly pendingPersistHistory = new Map<string, string>(); |
| 162 | 246 | private readonly threadLocks = new Map<string, Promise<void>>(); |
| 163 | 247 | private readonly persistDebounceMs: number; |
| 248 | private readonly subprocessChecker: TerminalSubprocessChecker; |
| 249 | private readonly subprocessPollIntervalMs: number; |
| 250 | private subprocessPollTimer: ReturnType<typeof setInterval> | null = null; |
| 251 | private subprocessPollInFlight = false; |
| 164 | 252 | private readonly logger = createLogger("terminal"); |
| 165 | 253 | |
| 166 | 254 | constructor(options: TerminalManagerOptions = {}) { |
| ... | ... | @@ -170,6 +258,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 170 | 258 | this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter(); |
| 171 | 259 | this.shellResolver = options.shellResolver ?? defaultShellResolver; |
| 172 | 260 | this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; |
| 261 | this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; |
| 262 | this.subprocessPollIntervalMs = |
| 263 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; |
| 173 | 264 | fs.mkdirSync(this.logsDir, { recursive: true }); |
| 174 | 265 | } |
| 175 | 266 | |
| ... | ... | @@ -198,6 +289,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 198 | 289 | process: null, |
| 199 | 290 | unsubscribeData: null, |
| 200 | 291 | unsubscribeExit: null, |
| 292 | hasRunningSubprocess: false, |
| 201 | 293 | }; |
| 202 | 294 | this.sessions.set(sessionKey, session); |
| 203 | 295 | this.startSession(session, input, "started"); |
| ... | ... | @@ -294,6 +386,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 294 | 386 | process: null, |
| 295 | 387 | unsubscribeData: null, |
| 296 | 388 | unsubscribeExit: null, |
| 389 | hasRunningSubprocess: false, |
| 297 | 390 | }; |
| 298 | 391 | this.sessions.set(sessionKey, session); |
| 299 | 392 | } else { |
| ... | ... | @@ -334,14 +427,17 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 334 | 427 | if (input.deleteHistory) { |
| 335 | 428 | await this.deleteAllHistoryForThread(input.threadId); |
| 336 | 429 | } |
| 430 | this.updateSubprocessPollingState(); |
| 337 | 431 | }); |
| 338 | 432 | } |
| 339 | 433 | |
| 340 | 434 | dispose(): void { |
| 341 | | for (const session of this.sessions.values()) { |
| 435 | this.stopSubprocessPolling(); |
| 436 | const sessions = [...this.sessions.values()]; |
| 437 | this.sessions.clear(); |
| 438 | for (const session of sessions) { |
| 342 | 439 | this.stopProcess(session); |
| 343 | 440 | } |
| 344 | | this.sessions.clear(); |
| 345 | 441 | for (const timer of this.persistTimers.values()) { |
| 346 | 442 | clearTimeout(timer); |
| 347 | 443 | } |
| ... | ... | @@ -364,6 +460,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 364 | 460 | session.rows = input.rows; |
| 365 | 461 | session.exitCode = null; |
| 366 | 462 | session.exitSignal = null; |
| 463 | session.hasRunningSubprocess = false; |
| 367 | 464 | session.updatedAt = new Date().toISOString(); |
| 368 | 465 | |
| 369 | 466 | let ptyProcess: PtyProcess | null = null; |
| ... | ... | @@ -413,6 +510,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 413 | 510 | session.unsubscribeExit = ptyProcess.onExit((event) => { |
| 414 | 511 | this.onProcessExit(session, event); |
| 415 | 512 | }); |
| 513 | this.updateSubprocessPollingState(); |
| 416 | 514 | this.emitEvent({ |
| 417 | 515 | type: eventType, |
| 418 | 516 | threadId: session.threadId, |
| ... | ... | @@ -431,7 +529,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 431 | 529 | session.status = "error"; |
| 432 | 530 | session.pid = null; |
| 433 | 531 | session.process = null; |
| 532 | session.hasRunningSubprocess = false; |
| 434 | 533 | session.updatedAt = new Date().toISOString(); |
| 534 | this.updateSubprocessPollingState(); |
| 435 | 535 | const message = error instanceof Error ? error.message : "Terminal start failed"; |
| 436 | 536 | this.emitEvent({ |
| 437 | 537 | type: "error", |
| ... | ... | @@ -466,6 +566,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 466 | 566 | this.cleanupProcessHandles(session); |
| 467 | 567 | session.process = null; |
| 468 | 568 | session.pid = null; |
| 569 | session.hasRunningSubprocess = false; |
| 469 | 570 | session.status = "exited"; |
| 470 | 571 | session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; |
| 471 | 572 | session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; |
| ... | ... | @@ -478,6 +579,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 478 | 579 | exitCode: session.exitCode, |
| 479 | 580 | exitSignal: session.exitSignal, |
| 480 | 581 | }); |
| 582 | this.updateSubprocessPollingState(); |
| 481 | 583 | } |
| 482 | 584 | |
| 483 | 585 | private stopProcess(session: TerminalSessionState): void { |
| ... | ... | @@ -486,6 +588,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 486 | 588 | this.cleanupProcessHandles(session); |
| 487 | 589 | session.process = null; |
| 488 | 590 | session.pid = null; |
| 591 | session.hasRunningSubprocess = false; |
| 489 | 592 | session.status = "exited"; |
| 490 | 593 | session.updatedAt = new Date().toISOString(); |
| 491 | 594 | try { |
| ... | ... | @@ -498,6 +601,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 498 | 601 | error: message, |
| 499 | 602 | }); |
| 500 | 603 | } |
| 604 | this.updateSubprocessPollingState(); |
| 501 | 605 | } |
| 502 | 606 | |
| 503 | 607 | private cleanupProcessHandles(session: TerminalSessionState): void { |
| ... | ... | @@ -660,6 +764,86 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 660 | 764 | } |
| 661 | 765 | } |
| 662 | 766 | |
| 767 | private updateSubprocessPollingState(): void { |
| 768 | const hasRunningSessions = [...this.sessions.values()].some( |
| 769 | (session) => session.status === "running" && session.pid !== null, |
| 770 | ); |
| 771 | if (hasRunningSessions) { |
| 772 | this.ensureSubprocessPolling(); |
| 773 | return; |
| 774 | } |
| 775 | this.stopSubprocessPolling(); |
| 776 | } |
| 777 | |
| 778 | private ensureSubprocessPolling(): void { |
| 779 | if (this.subprocessPollTimer) return; |
| 780 | this.subprocessPollTimer = setInterval(() => { |
| 781 | void this.pollSubprocessActivity(); |
| 782 | }, this.subprocessPollIntervalMs); |
| 783 | this.subprocessPollTimer.unref?.(); |
| 784 | void this.pollSubprocessActivity(); |
| 785 | } |
| 786 | |
| 787 | private stopSubprocessPolling(): void { |
| 788 | if (!this.subprocessPollTimer) return; |
| 789 | clearInterval(this.subprocessPollTimer); |
| 790 | this.subprocessPollTimer = null; |
| 791 | } |
| 792 | |
| 793 | private async pollSubprocessActivity(): Promise<void> { |
| 794 | if (this.subprocessPollInFlight) return; |
| 795 | |
| 796 | const runningSessions = [...this.sessions.values()].filter( |
| 797 | (session): session is TerminalSessionState & { pid: number } => |
| 798 | session.status === "running" && Number.isInteger(session.pid), |
| 799 | ); |
| 800 | if (runningSessions.length === 0) { |
| 801 | this.stopSubprocessPolling(); |
| 802 | return; |
| 803 | } |
| 804 | |
| 805 | this.subprocessPollInFlight = true; |
| 806 | try { |
| 807 | await Promise.all( |
| 808 | runningSessions.map(async (session) => { |
| 809 | const terminalPid = session.pid; |
| 810 | let hasRunningSubprocess = false; |
| 811 | try { |
| 812 | hasRunningSubprocess = await this.subprocessChecker(terminalPid); |
| 813 | } catch (error) { |
| 814 | this.logger.warn("failed to check terminal subprocess activity", { |
| 815 | threadId: session.threadId, |
| 816 | terminalId: session.terminalId, |
| 817 | terminalPid, |
| 818 | error: error instanceof Error ? error.message : String(error), |
| 819 | }); |
| 820 | return; |
| 821 | } |
| 822 | |
| 823 | const liveSession = this.sessions.get(toSessionKey(session.threadId, session.terminalId)); |
| 824 | if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) { |
| 825 | return; |
| 826 | } |
| 827 | if (liveSession.hasRunningSubprocess === hasRunningSubprocess) { |
| 828 | return; |
| 829 | } |
| 830 | |
| 831 | liveSession.hasRunningSubprocess = hasRunningSubprocess; |
| 832 | liveSession.updatedAt = new Date().toISOString(); |
| 833 | this.emitEvent({ |
| 834 | type: "activity", |
| 835 | threadId: liveSession.threadId, |
| 836 | terminalId: liveSession.terminalId, |
| 837 | createdAt: new Date().toISOString(), |
| 838 | hasRunningSubprocess, |
| 839 | }); |
| 840 | }), |
| 841 | ); |
| 842 | } finally { |
| 843 | this.subprocessPollInFlight = false; |
| 844 | } |
| 845 | } |
| 846 | |
| 663 | 847 | private async assertValidCwd(cwd: string): Promise<void> { |
| 664 | 848 | let stats: fs.Stats; |
| 665 | 849 | try { |
| ... | ... | @@ -686,6 +870,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 686 | 870 | this.stopProcess(session); |
| 687 | 871 | this.sessions.delete(key); |
| 688 | 872 | } |
| 873 | this.updateSubprocessPollingState(); |
| 689 | 874 | await this.flushPersistQueue(threadId, terminalId); |
| 690 | 875 | if (deleteHistory) { |
| 691 | 876 | await this.deleteHistory(threadId, terminalId); |