authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-08-14 10:32:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 23:02:25+05:30
log80991402dcdc488838fb4d8b21171bd38d9ab0aa
tree87da320d9437981e71192e4dcf3b664fa1398efe
parent1a6599437b6ad77330923819613cc28be3b33945
signature Signed by PGP key B5690EEEBB952194

fix(server): terminal subprocess polling no longer floods the PID space (#6377)


2 files changed, 299 insertions(+), 217 deletions(-)

apps/server/src/terminal/Manager.test.ts+120
......@@ -24,6 +24,7 @@ import * as Ref from "effect/Ref";
2424import * as Schedule from "effect/Schedule";
2525import * as Scope from "effect/Scope";
2626import * as TestClock from "effect/testing/TestClock";
27import { ChildProcessSpawner } from "effect/unstable/process";
2728import { expect } from "vite-plus/test";
2829
2930import * as ProcessRunner from "../processRunner.ts";
......@@ -953,6 +954,125 @@ it.layer(
953954 }),
954955 );
955956
957 it.effect("derives subprocess activity for every terminal from one shared process snapshot", () =>
958 Effect.gen(function* () {
959 const runCalls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
960 // FakePtyAdapter assigns pids starting at 9000, so the two terminals
961 // opened below run as pids 9000 and 9001.
962 const psStdout = [" 100 9000 vim", " 101 100 git", " 200 9001 /usr/bin/python3"].join(
963 "\n",
964 );
965 const processRunner: ProcessRunner.ProcessRunner["Service"] = {
966 run: (input) =>
967 Effect.sync(() => {
968 runCalls.push({ command: input.command, args: input.args });
969 return {
970 stdout: psStdout,
971 stderr: "",
972 code: ChildProcessSpawner.ExitCode(0),
973 timedOut: false,
974 stdoutTruncated: false,
975 stderrTruncated: false,
976 stdoutInvalidUtf8: false,
977 stderrInvalidUtf8: false,
978 };
979 }),
980 };
981
982 const { manager, getEvents } = yield* createManager(5, {
983 subprocessPollIntervalMs: 20,
984 }).pipe(
985 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
986 Effect.provide(withHostPlatform("linux")),
987 );
988
989 yield* manager.open(openInput());
990 yield* manager.open(openInput({ threadId: "thread-2" }));
991
992 yield* waitFor(
993 Effect.map(
994 getEvents,
995 (events) =>
996 events.some(
997 (event) =>
998 event.type === "activity" &&
999 event.hasRunningSubprocess === true &&
1000 event.label === "vim",
1001 ) &&
1002 events.some(
1003 (event) =>
1004 event.type === "activity" &&
1005 event.hasRunningSubprocess === true &&
1006 event.label === "python3",
1007 ),
1008 ),
1009 "1200 millis",
1010 );
1011 yield* waitFor(
1012 Effect.sync(() => runCalls.length >= 3),
1013 "1200 millis",
1014 );
1015
1016 // Every spawn is the shared table snapshot — no per-terminal `pgrep`
1017 // or per-child `ps -p` invocations.
1018 expect(runCalls.every((call) => call.args.join(" ") === "-eo pid=,ppid=,comm=")).toBe(true);
1019 }),
1020 );
1021
1022 it.effect("keeps last known subprocess state when the process snapshot fails", () =>
1023 Effect.gen(function* () {
1024 let failSnapshots = false;
1025 let failedCalls = 0;
1026 const processRunner: ProcessRunner.ProcessRunner["Service"] = {
1027 run: () =>
1028 Effect.sync(() => {
1029 if (failSnapshots) failedCalls += 1;
1030 return {
1031 stdout: failSnapshots ? "" : " 100 9000 vim",
1032 stderr: "",
1033 code: ChildProcessSpawner.ExitCode(failSnapshots ? 1 : 0),
1034 timedOut: false,
1035 stdoutTruncated: false,
1036 stderrTruncated: false,
1037 stdoutInvalidUtf8: false,
1038 stderrInvalidUtf8: false,
1039 };
1040 }),
1041 };
1042
1043 const { manager, getEvents } = yield* createManager(5, {
1044 subprocessPollIntervalMs: 20,
1045 }).pipe(
1046 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
1047 Effect.provide(withHostPlatform("linux")),
1048 );
1049
1050 yield* manager.open(openInput());
1051 yield* waitFor(
1052 Effect.map(getEvents, (events) =>
1053 events.some(
1054 (event) =>
1055 event.type === "activity" &&
1056 event.hasRunningSubprocess === true &&
1057 event.label === "vim",
1058 ),
1059 ),
1060 "1200 millis",
1061 );
1062
1063 failSnapshots = true;
1064 yield* waitFor(
1065 Effect.sync(() => failedCalls >= 3),
1066 "1200 millis",
1067 );
1068
1069 // A failed snapshot is not authoritative: no terminal flips to idle.
1070 const activityEvents = (yield* getEvents).filter((event) => event.type === "activity");
1071 expect(activityEvents.length).toBeGreaterThan(0);
1072 expect(activityEvents.every((event) => event.hasRunningSubprocess === true)).toBe(true);
1073 }),
1074 );
1075
9561076 it.effect("caps persisted history to configured line limit", () =>
9571077 Effect.gen(function* () {
9581078 const { manager, ptyAdapter } = yield* createManager(3);
apps/server/src/terminal/Manager.ts+179-217
......@@ -89,12 +89,21 @@ class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubpr
8989 "TerminalSubprocessCheckError",
9090 {
9191 cause: Schema.optional(Schema.Defect()),
92 terminalPid: Schema.Number,
93 command: Schema.Literals(["powershell", "pgrep", "ps"]),
92 command: Schema.Literals(["powershell", "ps"]),
93 exitCode: Schema.optional(Schema.NullOr(Schema.Number)),
94 timedOut: Schema.optional(Schema.Boolean),
95 stdoutTruncated: Schema.optional(Schema.Boolean),
9496 },
9597) {
9698 override get message(): string {
97 return `Failed to inspect terminal subprocesses for PID ${this.terminalPid} with ${this.command}`;
99 const details = [
100 this.exitCode !== undefined && this.exitCode !== null ? `exit code ${this.exitCode}` : null,
101 this.timedOut ? "timed out" : null,
102 this.stdoutTruncated ? "output truncated" : null,
103 ]
104 .filter((detail) => detail !== null)
105 .join(", ");
106 return `Failed to inspect terminal subprocesses with ${this.command}${details.length > 0 ? ` (${details})` : ""}`;
98107 }
99108}
100109
......@@ -610,125 +619,102 @@ function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean {
610619 );
611620}
612621
613function parseFirstChildPidFromPgrep(stdout: string): number | null {
622interface TerminalProcessTableSnapshot {
623 readonly childrenByParent: ReadonlyMap<number, ReadonlyArray<number>>;
624 readonly commandById: ReadonlyMap<number, string>;
625}
626
627function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot {
628 const childrenByParent = new Map<number, number[]>();
629 const commandById = new Map<number, string>();
614630 for (const line of stdout.split(/\r?\n/g)) {
615 const n = Number.parseInt(line.trim(), 10);
616 if (Number.isInteger(n) && n > 0) {
617 return n;
618 }
631 // `comm=` is the final column and may itself contain spaces, so only the
632 // first two tokens are structural.
633 const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
634 if (!match) continue;
635 const pid = Number(match[1]);
636 const ppid = Number(match[2]);
637 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
638 commandById.set(pid, (match[3] ?? "").trim());
639 const children = childrenByParent.get(ppid) ?? [];
640 children.push(pid);
641 childrenByParent.set(ppid, children);
619642 }
620 return null;
643 return { childrenByParent, commandById };
621644}
622645
623function windowsInspectSubprocess(
624 terminalPid: number,
625 platform: NodeJS.Platform,
626): Effect.Effect<
627 TerminalSubprocessInspectResult,
628 TerminalSubprocessCheckError,
629 ProcessRunner.ProcessRunner
630> {
631 const command =
632 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }';
633 return Effect.gen(function* () {
634 const processRunner = yield* ProcessRunner.ProcessRunner;
635 return yield* processRunner.run({
636 // powershell.exe is a real executable — never spawn it through cmd.exe
637 // shell mode, which would re-tokenize the `-Command` payload (pipes,
638 // semicolons) before PowerShell ever sees it.
639 command: "powershell.exe",
640 args: ["-NoProfile", "-NonInteractive", "-Command", command],
641 timeout: "1500 millis",
642 maxOutputBytes: 32_768,
643 outputMode: "truncate",
644 timeoutBehavior: "timedOutResult",
645 });
646 }).pipe(
647 Effect.map((result) => {
648 if (result.code !== 0) {
649 return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const;
650 }
651 const processNameById = new Map<number, string>();
652 const childrenByParent = new Map<number, number[]>();
653 for (const line of result.stdout.split(/\r?\n/g)) {
654 const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3);
655 const pid = Number(pidRaw);
656 const parentPid = Number(parentPidRaw);
657 if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
658 processNameById.set(pid, nameRaw?.trim() ?? "");
659 const children = childrenByParent.get(parentPid) ?? [];
660 children.push(pid);
661 childrenByParent.set(parentPid, children);
662 }
663 const directChildren = childrenByParent.get(terminalPid) ?? [];
664 const childPid = directChildren[0];
665 if (childPid === undefined) {
666 return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const;
667 }
668 const processIds = new Set<number>([terminalPid]);
669 const pending = [terminalPid];
670 while (pending.length > 0) {
671 const parentPid = pending.pop();
672 if (parentPid === undefined) continue;
673 for (const pid of childrenByParent.get(parentPid) ?? []) {
674 if (processIds.has(pid)) continue;
675 processIds.add(pid);
676 pending.push(pid);
677 }
678 }
679 const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform);
680 return {
681 hasRunningSubprocess: true,
682 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
683 processIds: [...processIds],
684 } as const;
685 }),
686 Effect.mapError(
687 (cause) =>
688 new TerminalSubprocessCheckError({
689 cause,
690 terminalPid,
691 command: "powershell",
692 }),
693 ),
694 );
646function parseWindowsProcessTable(stdout: string): TerminalProcessTableSnapshot {
647 const childrenByParent = new Map<number, number[]>();
648 const commandById = new Map<number, string>();
649 for (const line of stdout.split(/\r?\n/g)) {
650 const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3);
651 const pid = Number(pidRaw);
652 const parentPid = Number(parentPidRaw);
653 if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
654 commandById.set(pid, nameRaw?.trim() ?? "");
655 const children = childrenByParent.get(parentPid) ?? [];
656 children.push(pid);
657 childrenByParent.set(parentPid, children);
658 }
659 return { childrenByParent, commandById };
695660}
696661
697const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* (
662function deriveSubprocessInspectResult(
663 snapshot: TerminalProcessTableSnapshot,
698664 terminalPid: number,
699665 platform: NodeJS.Platform,
666): TerminalSubprocessInspectResult {
667 const childPid = (snapshot.childrenByParent.get(terminalPid) ?? [])[0];
668 if (childPid === undefined) {
669 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
670 }
671 const processIds = new Set<number>([terminalPid]);
672 const pending = [terminalPid];
673 while (pending.length > 0) {
674 const parentPid = pending.pop();
675 if (parentPid === undefined) continue;
676 for (const pid of snapshot.childrenByParent.get(parentPid) ?? []) {
677 if (processIds.has(pid)) continue;
678 processIds.add(pid);
679 pending.push(pid);
680 }
681 }
682 const normalized = normalizeChildCommandName(snapshot.commandById.get(childPid) ?? "", platform);
683 return {
684 hasRunningSubprocess: true,
685 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
686 processIds: [...processIds],
687 };
688}
689
690const POSIX_PS_ABSOLUTE_PATHS = ["/bin/ps", "/usr/bin/ps"] as const;
691
692// Resolve `ps` to an absolute path once at startup. Spawning by bare name
693// walks every PATH entry per spawn (one failed posix_spawn per directory
694// until the hit), which is measurable at a 1s poll cadence on long PATHs.
695const resolvePosixPsCommand = Effect.fn("terminal.resolvePosixPsCommand")(function* () {
696 const fileSystem = yield* FileSystem.FileSystem;
697 for (const candidate of POSIX_PS_ABSOLUTE_PATHS) {
698 const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false));
699 if (exists) return candidate;
700 }
701 return "ps";
702});
703
704const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot")(function* (
705 psCommand: string,
700706): Effect.fn.Return<
701 TerminalSubprocessInspectResult,
707 TerminalProcessTableSnapshot,
702708 TerminalSubprocessCheckError,
703709 ProcessRunner.ProcessRunner
704710> {
705711 const processRunner = yield* ProcessRunner.ProcessRunner;
706 const runPgrep = processRunner
707 .run({
708 command: "pgrep",
709 args: ["-P", String(terminalPid)],
710 timeout: "1 second",
711 maxOutputBytes: 32_768,
712 outputMode: "truncate",
713 timeoutBehavior: "timedOutResult",
714 })
715 .pipe(
716 Effect.mapError(
717 (cause) =>
718 new TerminalSubprocessCheckError({
719 cause,
720 terminalPid,
721 command: "pgrep",
722 }),
723 ),
724 );
725
726 const runPs = processRunner
712 const result = yield* processRunner
727713 .run({
728 command: "ps",
729 args: ["-eo", "pid=,ppid="],
714 command: psCommand,
715 args: ["-eo", "pid=,ppid=,comm="],
730716 timeout: "1 second",
731 maxOutputBytes: 262_144,
717 maxOutputBytes: 524_288,
732718 outputMode: "truncate",
733719 timeoutBehavior: "timedOutResult",
734720 })
......@@ -737,120 +723,66 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func
737723 (cause) =>
738724 new TerminalSubprocessCheckError({
739725 cause,
740 terminalPid,
741726 command: "ps",
742727 }),
743728 ),
744729 );
745
746 let childPid: number | null = null;
747
748 const pgrepResult = yield* Effect.exit(runPgrep);
749 if (pgrepResult._tag === "Success") {
750 if (pgrepResult.value.code === 0) {
751 childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout);
752 } else if (pgrepResult.value.code === 1) {
753 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
754 }
755 }
756
757 if (childPid === null) {
758 const psResult = yield* Effect.exit(runPs);
759 if (psResult._tag === "Failure" || psResult.value.code !== 0) {
760 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
761 }
762 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
763 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
764 const pid = Number(pidRaw);
765 const ppid = Number(ppidRaw);
766 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
767 if (ppid === terminalPid) {
768 childPid = pid;
769 break;
770 }
771 }
772 }
773
774 if (childPid === null) {
775 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
776 }
777
778 const runComm = processRunner.run({
779 command: "ps",
780 args: ["-p", String(childPid), "-o", "comm="],
781 timeout: "1 second",
782 maxOutputBytes: 8_192,
783 outputMode: "truncate",
784 timeoutBehavior: "timedOutResult",
785 });
786
787 const commResult = yield* Effect.exit(runComm);
788 let rawComm: string | null = null;
789 if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) {
790 rawComm = commResult.value.stdout.trim();
791 }
792
793 if (!rawComm || rawComm.length === 0) {
794 const runArgs = processRunner.run({
730 if (result.code !== 0 || result.timedOut || result.stdoutTruncated) {
731 // Not authoritative: an empty or partial table would mark every terminal
732 // idle and clear its registered process ids. Failing skips the tick.
733 return yield* new TerminalSubprocessCheckError({
795734 command: "ps",
796 args: ["-p", String(childPid), "-o", "args="],
797 timeout: "1 second",
798 maxOutputBytes: 16_384,
799 outputMode: "truncate",
800 timeoutBehavior: "timedOutResult",
735 exitCode: result.code,
736 timedOut: result.timedOut,
737 stdoutTruncated: result.stdoutTruncated,
801738 });
802 const argsResult = yield* Effect.exit(runArgs);
803 if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) {
804 const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? "";
805 rawComm = first.length > 0 ? first : null;
806 }
807739 }
808
809 const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null;
810 const processIds = new Set<number>([terminalPid]);
811 const psResult = yield* Effect.exit(runPs);
812 if (psResult._tag === "Success" && psResult.value.code === 0) {
813 const childrenByParent = new Map<number, number[]>();
814 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
815 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
816 const pid = Number(pidRaw);
817 const ppid = Number(ppidRaw);
818 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
819 const children = childrenByParent.get(ppid) ?? [];
820 children.push(pid);
821 childrenByParent.set(ppid, children);
822 }
823 const pending = [terminalPid];
824 while (pending.length > 0) {
825 const parentPid = pending.pop();
826 if (parentPid === undefined) continue;
827 for (const child of childrenByParent.get(parentPid) ?? []) {
828 if (processIds.has(child)) continue;
829 processIds.add(child);
830 pending.push(child);
831 }
832 }
833 } else {
834 processIds.add(childPid);
835 }
836 return {
837 hasRunningSubprocess: true,
838 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
839 processIds: [...processIds],
840 };
740 return parsePosixProcessTable(result.stdout);
841741});
842742
843function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) {
844 return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) {
845 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
846 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
847 }
848 if (platform === "win32") {
849 return yield* windowsInspectSubprocess(terminalPid, platform);
743const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnapshot")(
744 function* (): Effect.fn.Return<
745 TerminalProcessTableSnapshot,
746 TerminalSubprocessCheckError,
747 ProcessRunner.ProcessRunner
748 > {
749 const command =
750 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }';
751 const processRunner = yield* ProcessRunner.ProcessRunner;
752 const result = yield* processRunner
753 .run({
754 // powershell.exe is a real executable — never spawn it through cmd.exe
755 // shell mode, which would re-tokenize the `-Command` payload (pipes,
756 // semicolons) before PowerShell ever sees it.
757 command: "powershell.exe",
758 args: ["-NoProfile", "-NonInteractive", "-Command", command],
759 timeout: "1500 millis",
760 maxOutputBytes: 262_144,
761 outputMode: "truncate",
762 timeoutBehavior: "timedOutResult",
763 })
764 .pipe(
765 Effect.mapError(
766 (cause) =>
767 new TerminalSubprocessCheckError({
768 cause,
769 command: "powershell",
770 }),
771 ),
772 );
773 if (result.code !== 0 || result.timedOut || result.stdoutTruncated) {
774 // Not authoritative: an empty or partial table would mark every terminal
775 // idle and clear its registered process ids. Failing skips the tick.
776 return yield* new TerminalSubprocessCheckError({
777 command: "powershell",
778 exitCode: result.code,
779 timedOut: result.timedOut,
780 stdoutTruncated: result.stdoutTruncated,
781 });
850782 }
851 return yield* posixInspectSubprocess(terminalPid, platform);
852 });
853}
783 return parseWindowsProcessTable(result.stdout);
784 },
785);
854786
855787function capHistory(history: string, maxLines: number): string {
856788 if (history.length === 0) return history;
......@@ -1227,12 +1159,27 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
12271159 const baseEnv = options.env ?? process.env;
12281160 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
12291161 const processRunner = yield* ProcessRunner.ProcessRunner;
1230 const subprocessInspector =
1231 options.subprocessInspector ??
1232 ((terminalPid) =>
1233 defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe(
1234 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
1235 ));
1162 // One process-table snapshot per poll tick, shared across every terminal.
1163 // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and
1164 // can exhaust the PID space on hosts with many sessions (#6332).
1165 const fetchProcessTableSnapshot = (
1166 platform === "win32"
1167 ? windowsProcessTableSnapshot()
1168 : posixProcessTableSnapshot(yield* resolvePosixPsCommand())
1169 ).pipe(Effect.provideService(ProcessRunner.ProcessRunner, processRunner));
1170 const customSubprocessInspector = options.subprocessInspector;
1171 const acquireSubprocessInspector: Effect.Effect<
1172 TerminalSubprocessInspector,
1173 TerminalSubprocessCheckError
1174 > =
1175 customSubprocessInspector !== undefined
1176 ? Effect.succeed(customSubprocessInspector)
1177 : Effect.map(
1178 fetchProcessTableSnapshot,
1179 (snapshot): TerminalSubprocessInspector =>
1180 (terminalPid) =>
1181 Effect.succeed(deriveSubprocessInspectResult(snapshot, terminalPid, platform)),
1182 );
12361183 const subprocessPollIntervalMs =
12371184 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
12381185 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
......@@ -2064,6 +2011,21 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
20642011 return;
20652012 }
20662013
2014 const inspectorOption = yield* acquireSubprocessInspector.pipe(
2015 Effect.map(Option.some),
2016 Effect.catch((reason) =>
2017 Effect.logWarning("failed to snapshot processes for terminal subprocess polling", {
2018 reason,
2019 }).pipe(Effect.as(Option.none<TerminalSubprocessInspector>())),
2020 ),
2021 );
2022
2023 if (Option.isNone(inspectorOption)) {
2024 return;
2025 }
2026
2027 const subprocessInspector = inspectorOption.value;
2028
20672029 const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* (
20682030 session: TerminalSessionState & { pid: number },
20692031 ) {