| ... | @@ -21,9 +21,13 @@ import { | ... | @@ -21,9 +21,13 @@ import { |
| 21 | | 21 | |
| 22 | import { createLogger } from "./logger"; | 22 | import { createLogger } from "./logger"; |
| 23 | import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; | 23 | import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; |
| | 24 | import { runProcess } from "./processRunner"; |
| 24 | | 25 | |
| 25 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; | 26 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; |
| 26 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; | 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 | export interface TerminalManagerEvents { | 32 | export interface TerminalManagerEvents { |
| 29 | event: [event: TerminalEvent]; | 33 | event: [event: TerminalEvent]; |
| ... | @@ -34,6 +38,8 @@ export interface TerminalManagerOptions { | ... | @@ -34,6 +38,8 @@ export interface TerminalManagerOptions { |
| 34 | historyLineLimit?: number; | 38 | historyLineLimit?: number; |
| 35 | ptyAdapter?: PtyAdapter; | 39 | ptyAdapter?: PtyAdapter; |
| 36 | shellResolver?: () => string; | 40 | shellResolver?: () => string; |
| | 41 | subprocessChecker?: TerminalSubprocessChecker; |
| | 42 | subprocessPollIntervalMs?: number; |
| 37 | } | 43 | } |
| 38 | | 44 | |
| 39 | interface TerminalSessionState { | 45 | interface TerminalSessionState { |
| ... | @@ -51,6 +57,7 @@ interface TerminalSessionState { | ... | @@ -51,6 +57,7 @@ interface TerminalSessionState { |
| 51 | process: PtyProcess | null; | 57 | process: PtyProcess | null; |
| 52 | unsubscribeData: (() => void) | null; | 58 | unsubscribeData: (() => void) | null; |
| 53 | unsubscribeExit: (() => void) | null; | 59 | unsubscribeExit: (() => void) | null; |
| | 60 | hasRunningSubprocess: boolean; |
| 54 | } | 61 | } |
| 55 | | 62 | |
| 56 | function defaultShellResolver(): string { | 63 | function defaultShellResolver(): string { |
| ... | @@ -122,6 +129,83 @@ function isRetryableShellSpawnError(error: unknown): boolean { | ... | @@ -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 | function capHistory(history: string, maxLines: number): string { | 209 | function capHistory(history: string, maxLines: number): string { |
| 126 | if (history.length === 0) return history; | 210 | if (history.length === 0) return history; |
| 127 | const hasTrailingNewline = history.endsWith("\n"); | 211 | const hasTrailingNewline = history.endsWith("\n"); |
| ... | @@ -161,6 +245,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -161,6 +245,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 161 | private readonly pendingPersistHistory = new Map<string, string>(); | 245 | private readonly pendingPersistHistory = new Map<string, string>(); |
| 162 | private readonly threadLocks = new Map<string, Promise<void>>(); | 246 | private readonly threadLocks = new Map<string, Promise<void>>(); |
| 163 | private readonly persistDebounceMs: number; | 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 | private readonly logger = createLogger("terminal"); | 252 | private readonly logger = createLogger("terminal"); |
| 165 | | 253 | |
| 166 | constructor(options: TerminalManagerOptions = {}) { | 254 | constructor(options: TerminalManagerOptions = {}) { |
| ... | @@ -170,6 +258,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -170,6 +258,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 170 | this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter(); | 258 | this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter(); |
| 171 | this.shellResolver = options.shellResolver ?? defaultShellResolver; | 259 | this.shellResolver = options.shellResolver ?? defaultShellResolver; |
| 172 | this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; | 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 | fs.mkdirSync(this.logsDir, { recursive: true }); | 264 | fs.mkdirSync(this.logsDir, { recursive: true }); |
| 174 | } | 265 | } |
| 175 | | 266 | |
| ... | @@ -198,6 +289,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -198,6 +289,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 198 | process: null, | 289 | process: null, |
| 199 | unsubscribeData: null, | 290 | unsubscribeData: null, |
| 200 | unsubscribeExit: null, | 291 | unsubscribeExit: null, |
| | 292 | hasRunningSubprocess: false, |
| 201 | }; | 293 | }; |
| 202 | this.sessions.set(sessionKey, session); | 294 | this.sessions.set(sessionKey, session); |
| 203 | this.startSession(session, input, "started"); | 295 | this.startSession(session, input, "started"); |
| ... | @@ -294,6 +386,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -294,6 +386,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 294 | process: null, | 386 | process: null, |
| 295 | unsubscribeData: null, | 387 | unsubscribeData: null, |
| 296 | unsubscribeExit: null, | 388 | unsubscribeExit: null, |
| | 389 | hasRunningSubprocess: false, |
| 297 | }; | 390 | }; |
| 298 | this.sessions.set(sessionKey, session); | 391 | this.sessions.set(sessionKey, session); |
| 299 | } else { | 392 | } else { |
| ... | @@ -334,14 +427,17 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -334,14 +427,17 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 334 | if (input.deleteHistory) { | 427 | if (input.deleteHistory) { |
| 335 | await this.deleteAllHistoryForThread(input.threadId); | 428 | await this.deleteAllHistoryForThread(input.threadId); |
| 336 | } | 429 | } |
| | 430 | this.updateSubprocessPollingState(); |
| 337 | }); | 431 | }); |
| 338 | } | 432 | } |
| 339 | | 433 | |
| 340 | dispose(): void { | 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 | this.stopProcess(session); | 439 | this.stopProcess(session); |
| 343 | } | 440 | } |
| 344 | this.sessions.clear(); | | |
| 345 | for (const timer of this.persistTimers.values()) { | 441 | for (const timer of this.persistTimers.values()) { |
| 346 | clearTimeout(timer); | 442 | clearTimeout(timer); |
| 347 | } | 443 | } |
| ... | @@ -364,6 +460,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -364,6 +460,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 364 | session.rows = input.rows; | 460 | session.rows = input.rows; |
| 365 | session.exitCode = null; | 461 | session.exitCode = null; |
| 366 | session.exitSignal = null; | 462 | session.exitSignal = null; |
| | 463 | session.hasRunningSubprocess = false; |
| 367 | session.updatedAt = new Date().toISOString(); | 464 | session.updatedAt = new Date().toISOString(); |
| 368 | | 465 | |
| 369 | let ptyProcess: PtyProcess | null = null; | 466 | let ptyProcess: PtyProcess | null = null; |
| ... | @@ -413,6 +510,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -413,6 +510,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 413 | session.unsubscribeExit = ptyProcess.onExit((event) => { | 510 | session.unsubscribeExit = ptyProcess.onExit((event) => { |
| 414 | this.onProcessExit(session, event); | 511 | this.onProcessExit(session, event); |
| 415 | }); | 512 | }); |
| | 513 | this.updateSubprocessPollingState(); |
| 416 | this.emitEvent({ | 514 | this.emitEvent({ |
| 417 | type: eventType, | 515 | type: eventType, |
| 418 | threadId: session.threadId, | 516 | threadId: session.threadId, |
| ... | @@ -431,7 +529,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -431,7 +529,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 431 | session.status = "error"; | 529 | session.status = "error"; |
| 432 | session.pid = null; | 530 | session.pid = null; |
| 433 | session.process = null; | 531 | session.process = null; |
| | 532 | session.hasRunningSubprocess = false; |
| 434 | session.updatedAt = new Date().toISOString(); | 533 | session.updatedAt = new Date().toISOString(); |
| | 534 | this.updateSubprocessPollingState(); |
| 435 | const message = error instanceof Error ? error.message : "Terminal start failed"; | 535 | const message = error instanceof Error ? error.message : "Terminal start failed"; |
| 436 | this.emitEvent({ | 536 | this.emitEvent({ |
| 437 | type: "error", | 537 | type: "error", |
| ... | @@ -466,6 +566,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -466,6 +566,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 466 | this.cleanupProcessHandles(session); | 566 | this.cleanupProcessHandles(session); |
| 467 | session.process = null; | 567 | session.process = null; |
| 468 | session.pid = null; | 568 | session.pid = null; |
| | 569 | session.hasRunningSubprocess = false; |
| 469 | session.status = "exited"; | 570 | session.status = "exited"; |
| 470 | session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; | 571 | session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; |
| 471 | session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; | 572 | session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; |
| ... | @@ -478,6 +579,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -478,6 +579,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 478 | exitCode: session.exitCode, | 579 | exitCode: session.exitCode, |
| 479 | exitSignal: session.exitSignal, | 580 | exitSignal: session.exitSignal, |
| 480 | }); | 581 | }); |
| | 582 | this.updateSubprocessPollingState(); |
| 481 | } | 583 | } |
| 482 | | 584 | |
| 483 | private stopProcess(session: TerminalSessionState): void { | 585 | private stopProcess(session: TerminalSessionState): void { |
| ... | @@ -486,6 +588,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -486,6 +588,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 486 | this.cleanupProcessHandles(session); | 588 | this.cleanupProcessHandles(session); |
| 487 | session.process = null; | 589 | session.process = null; |
| 488 | session.pid = null; | 590 | session.pid = null; |
| | 591 | session.hasRunningSubprocess = false; |
| 489 | session.status = "exited"; | 592 | session.status = "exited"; |
| 490 | session.updatedAt = new Date().toISOString(); | 593 | session.updatedAt = new Date().toISOString(); |
| 491 | try { | 594 | try { |
| ... | @@ -498,6 +601,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -498,6 +601,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 498 | error: message, | 601 | error: message, |
| 499 | }); | 602 | }); |
| 500 | } | 603 | } |
| | 604 | this.updateSubprocessPollingState(); |
| 501 | } | 605 | } |
| 502 | | 606 | |
| 503 | private cleanupProcessHandles(session: TerminalSessionState): void { | 607 | private cleanupProcessHandles(session: TerminalSessionState): void { |
| ... | @@ -660,6 +764,86 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -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 | private async assertValidCwd(cwd: string): Promise<void> { | 847 | private async assertValidCwd(cwd: string): Promise<void> { |
| 664 | let stats: fs.Stats; | 848 | let stats: fs.Stats; |
| 665 | try { | 849 | try { |
| ... | @@ -686,6 +870,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -686,6 +870,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 686 | this.stopProcess(session); | 870 | this.stopProcess(session); |
| 687 | this.sessions.delete(key); | 871 | this.sessions.delete(key); |
| 688 | } | 872 | } |
| | 873 | this.updateSubprocessPollingState(); |
| 689 | await this.flushPersistQueue(threadId, terminalId); | 874 | await this.flushPersistQueue(threadId, terminalId); |
| 690 | if (deleteHistory) { | 875 | if (deleteHistory) { |
| 691 | await this.deleteHistory(threadId, terminalId); | 876 | await this.deleteHistory(threadId, terminalId); |