authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 08:39:05-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log9b8184df7e1a5fd1837b21eaa225d06f614b574b
tree274dce5f888f4e0c94eaa3dd42e0bcd418f6ff2e
parentaa5a144d04c5067b9344289c6bc6f28a8acb9765

fix memory leaks


6 files changed, 172 insertions(+), 19 deletions(-)

apps/server/src/main.test.ts+1
......@@ -25,6 +25,7 @@ const serverStart = Effect.acquireRelease(
2525);
2626const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred));
2727
28// Shared service layer used by this CLI test suite.
2829const testLayer = Layer.mergeAll(
2930 Layer.succeed(CliConfig, {
3031 cwd: "/tmp/t3-test-workspace",
apps/server/src/provider/Layers/CodexAdapter.test.ts+31
......@@ -219,6 +219,37 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
219219 assert.equal(firstEvent.value.turnId, "turn-1");
220220 }),
221221 );
222
223 it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () =>
224 Effect.gen(function* () {
225 const adapter = yield* CodexAdapter;
226 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
227
228 const event: ProviderEvent = {
229 id: asEventId("evt-session-closed"),
230 kind: "session",
231 provider: "codex",
232 sessionId: asSessionId("sess-1"),
233 createdAt: new Date().toISOString(),
234 method: "session/closed",
235 message: "Session stopped",
236 };
237
238 lifecycleManager.emit("event", event);
239 const firstEvent = yield* Fiber.join(firstEventFiber);
240
241 assert.equal(firstEvent._tag, "Some");
242 if (firstEvent._tag !== "Some") {
243 return;
244 }
245 assert.equal(firstEvent.value.type, "session.exited");
246 if (firstEvent.value.type !== "session.exited") {
247 return;
248 }
249 assert.equal(firstEvent.value.sessionId, "sess-1");
250 assert.equal(firstEvent.value.message, "Session stopped");
251 }),
252 );
222253});
223254
224255afterAll(() => {
apps/server/src/provider/Layers/CodexAdapter.ts+1-1
......@@ -333,7 +333,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
333333 ];
334334 }
335335
336 if (event.method === "session/exited") {
336 if (event.method === "session/exited" || event.method === "session/closed") {
337337 return [
338338 {
339339 type: "session.exited",
apps/server/src/terminal/Layers/Manager.test.ts+53-1
......@@ -16,6 +16,7 @@ import { Encoding } from "effect";
1616class FakePtyProcess implements PtyProcess {
1717 readonly writes: string[] = [];
1818 readonly resizeCalls: Array<{ cols: number; rows: number }> = [];
19 readonly killSignals: Array<string | undefined> = [];
1920 private readonly dataListeners = new Set<(data: string) => void>();
2021 private readonly exitListeners = new Set<(event: PtyExitEvent) => void>();
2122 killed = false;
......@@ -30,8 +31,9 @@ class FakePtyProcess implements PtyProcess {
3031 this.resizeCalls.push({ cols, rows });
3132 }
3233
33 kill(): void {
34 kill(signal?: string): void {
3435 this.killed = true;
36 this.killSignals.push(signal);
3537 }
3638
3739 onData(callback: (data: string) => void): () => void {
......@@ -146,6 +148,8 @@ describe("TerminalManager", () => {
146148 shellResolver?: () => string;
147149 subprocessChecker?: (terminalPid: number) => Promise<boolean>;
148150 subprocessPollIntervalMs?: number;
151 processKillGraceMs?: number;
152 maxRetainedInactiveSessions?: number;
149153 } = {},
150154 ) {
151155 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));
......@@ -160,6 +164,10 @@ describe("TerminalManager", () => {
160164 ...(options.subprocessPollIntervalMs
161165 ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }
162166 : {}),
167 ...(options.processKillGraceMs ? { processKillGraceMs: options.processKillGraceMs } : {}),
168 ...(options.maxRetainedInactiveSessions
169 ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions }
170 : {}),
163171 });
164172 return { logsDir, ptyAdapter, manager };
165173 }
......@@ -437,6 +445,50 @@ describe("TerminalManager", () => {
437445 manager.dispose();
438446 });
439447
448 it("escalates terminal shutdown to SIGKILL when process does not exit in time", async () => {
449 const { manager, ptyAdapter } = makeManager(5, { processKillGraceMs: 10 });
450 await manager.open(openInput());
451 const process = ptyAdapter.processes[0];
452 expect(process).toBeDefined();
453 if (!process) return;
454
455 await manager.close({ threadId: "thread-1" });
456 await waitFor(() => process.killSignals.includes("SIGKILL"));
457
458 expect(process.killSignals[0]).toBe("SIGTERM");
459 expect(process.killSignals).toContain("SIGKILL");
460
461 manager.dispose();
462 });
463
464 it("evicts oldest inactive terminal sessions when retention limit is exceeded", async () => {
465 const { manager, ptyAdapter } = makeManager(5, { maxRetainedInactiveSessions: 1 });
466
467 await manager.open(openInput({ threadId: "thread-1" }));
468 await manager.open(openInput({ threadId: "thread-2" }));
469
470 const first = ptyAdapter.processes[0];
471 const second = ptyAdapter.processes[1];
472 expect(first).toBeDefined();
473 expect(second).toBeDefined();
474 if (!first || !second) return;
475
476 first.emitExit({ exitCode: 0, signal: 0 });
477 await new Promise((resolve) => setTimeout(resolve, 5));
478 second.emitExit({ exitCode: 0, signal: 0 });
479
480 await waitFor(() => {
481 const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions;
482 return sessions.size === 1;
483 });
484
485 const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions;
486 const keys = [...sessions.keys()];
487 expect(keys).toEqual(["thread-2\u0000default"]);
488
489 manager.dispose();
490 });
491
440492 it("migrates legacy transcript filenames to terminal-scoped history path on open", async () => {
441493 const { manager, logsDir } = makeManager();
442494 const legacyPath = path.join(logsDir, "thread-1.log");
apps/server/src/terminal/Layers/Manager.ts+85-14
......@@ -29,6 +29,8 @@ import {
2929const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
3030const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
3131const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
32const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000;
33const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128;
3234const DEFAULT_OPEN_COLS = 120;
3335const DEFAULT_OPEN_ROWS = 30;
3436const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
......@@ -279,6 +281,8 @@ interface TerminalManagerOptions {
279281 shellResolver?: () => string;
280282 subprocessChecker?: TerminalSubprocessChecker;
281283 subprocessPollIntervalMs?: number;
284 processKillGraceMs?: number;
285 maxRetainedInactiveSessions?: number;
282286}
283287
284288export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> {
......@@ -294,8 +298,11 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
294298 private readonly persistDebounceMs: number;
295299 private readonly subprocessChecker: TerminalSubprocessChecker;
296300 private readonly subprocessPollIntervalMs: number;
301 private readonly processKillGraceMs: number;
302 private readonly maxRetainedInactiveSessions: number;
297303 private subprocessPollTimer: ReturnType<typeof setInterval> | null = null;
298304 private subprocessPollInFlight = false;
305 private readonly killEscalationTimers = new Map<PtyProcess, ReturnType<typeof setTimeout>>();
299306 private readonly logger = createLogger("terminal");
300307
301308 constructor(options: TerminalManagerOptions) {
......@@ -308,6 +315,9 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
308315 this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;
309316 this.subprocessPollIntervalMs =
310317 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;
311321 fs.mkdirSync(this.logsDir, { recursive: true });
312322 }
313323
......@@ -342,6 +352,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
342352 runtimeEnv: normalizedRuntimeEnv(input.env),
343353 };
344354 this.sessions.set(sessionKey, session);
355 this.evictInactiveSessionsIfNeeded();
345356 this.startSession(session, { ...input, cols, rows }, "started");
346357 return this.snapshot(session);
347358 }
......@@ -453,6 +464,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
453464 runtimeEnv: normalizedRuntimeEnv(input.env),
454465 };
455466 this.sessions.set(sessionKey, session);
467 this.evictInactiveSessionsIfNeeded();
456468 } else {
457469 this.stopProcess(session);
458470 session.cwd = input.cwd;
......@@ -506,6 +518,10 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
506518 clearTimeout(timer);
507519 }
508520 this.persistTimers.clear();
521 for (const timer of this.killEscalationTimers.values()) {
522 clearTimeout(timer);
523 }
524 this.killEscalationTimers.clear();
509525 this.pendingPersistHistory.clear();
510526 this.threadLocks.clear();
511527 this.persistQueues.clear();
......@@ -584,17 +600,14 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
584600 });
585601 } catch (error) {
586602 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);
592604 }
593605 session.status = "error";
594606 session.pid = null;
595607 session.process = null;
596608 session.hasRunningSubprocess = false;
597609 session.updatedAt = new Date().toISOString();
610 this.evictInactiveSessionsIfNeeded();
598611 this.updateSubprocessPollingState();
599612 const message = error instanceof Error ? error.message : "Terminal start failed";
600613 this.emitEvent({
......@@ -627,6 +640,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
627640 }
628641
629642 private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void {
643 this.clearKillEscalationTimer(session.process);
630644 this.cleanupProcessHandles(session);
631645 session.process = null;
632646 session.pid = null;
......@@ -643,6 +657,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
643657 exitCode: session.exitCode,
644658 exitSignal: session.exitSignal,
645659 });
660 this.evictInactiveSessionsIfNeeded();
646661 this.updateSubprocessPollingState();
647662 }
648663
......@@ -655,24 +670,80 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
655670 session.hasRunningSubprocess = false;
656671 session.status = "exited";
657672 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);
658695 try {
659 process.kill();
696 process.kill("SIGTERM");
660697 } catch (error) {
661698 const message = error instanceof Error ? error.message : String(error);
662699 this.logger.warn("failed to kill terminal process", {
663 threadId: session.threadId,
664 terminalId: session.terminalId,
700 threadId,
701 terminalId,
702 signal: "SIGTERM",
665703 error: message,
666704 });
705 return;
667706 }
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);
669724 }
670725
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 }
676747 }
677748
678749 private queuePersist(threadId: string, terminalId: string, history: string): void {
apps/server/src/wsServer.ts+1-3
......@@ -593,9 +593,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
593593 const unsubscribeTerminalEvents = yield* terminalManager.subscribe(
594594 (event) => void Effect.runPromise(onTerminalEvent(event)),
595595 );
596 yield* Effect.addFinalizer(() =>
597 Effect.all([Effect.sync(() => unsubscribeTerminalEvents()), terminalManager.dispose()]),
598 );
596 yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents()));
599597
600598 yield* NodeHttpServer.make(() => httpServer, listenOptions);
601599