Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/desktop/src/main/lib/terminal-host/headless-emulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
import "../../terminal-host/xterm-env-polyfill";
import { SerializeAddon } from "@xterm/addon-serialize";
import { Terminal } from "@xterm/headless";
import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants";
import {
DEFAULT_TERMINAL_SCROLLBACK,
MAX_TERMINAL_SCROLLBACK,
} from "shared/constants";
import {
DEFAULT_MODES,
type TerminalModes,
Expand Down Expand Up @@ -86,7 +89,10 @@ export class HeadlessEmulator {
this.terminal = new Terminal({
cols,
rows,
scrollback,
scrollback: Math.min(
Math.max(0, Math.floor(Number.isFinite(Number(scrollback)) ? Number(scrollback) : 0)),
MAX_TERMINAL_SCROLLBACK,
),
allowProposedApi: true,
});

Expand Down
240 changes: 240 additions & 0 deletions apps/desktop/src/main/terminal-host/terminal-host-rss-sweep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/**
* Tests for TerminalHost RSS sweep (Task 3.3 — Phase 3 memory optimizations)
*
* The sweep runs every 5 minutes and kills any terminal session whose
* process-tree RSS exceeds 512 MB.
*/

import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from "bun:test";

// ---------------------------------------------------------------------------
// Inner mock functions — reset per-test, delegated from spies set in beforeAll
// ---------------------------------------------------------------------------

const mockCaptureProcessSnapshot = mock(async () => ({
byPid: new Map<
number,
{ pid: number; ppid: number; cpu: number; memory: number }
>(),
childrenOf: new Map<number, number[]>(),
}));

const mockGetSubtreeResources = mock(
(
_snap: unknown,
_pid: number,
): { cpu: number; memory: number; pids: number[] } => ({
cpu: 0,
memory: 0,
pids: [],
}),
);

// ---------------------------------------------------------------------------
// Lazy TerminalHost — imported after spies are in place
// ---------------------------------------------------------------------------

let TerminalHost: typeof import("./terminal-host").TerminalHost;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const MB = 1024 * 1024;
const LIMIT_BYTES = 512 * MB;

function makeFakeSession(
sessionId: string,
pid: number | null,
isAttachable = true,
) {
return {
sessionId,
pid,
isAttachable,
isTerminating: false,
isAlive: true,
clientCount: 0,
kill: mock(() => {}),
dispose: mock(async () => {}),
};
}

// ---------------------------------------------------------------------------
// Private-member access helper (avoids repeated casts in each test)
// ---------------------------------------------------------------------------

type FakeSession = ReturnType<typeof makeFakeSession>;

type HostInternal = {
stopIdleSweep: () => void;
stopRssSweep: () => void;
kill: ReturnType<typeof mock>;
sessions: Map<string, FakeSession>;
runRssSweep: () => Promise<void>;
};

function internal(h: InstanceType<typeof TerminalHost>): HostInternal {
return h as unknown as HostInternal;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("TerminalHost.runRssSweep", () => {
let host: InstanceType<typeof TerminalHost>;

beforeAll(async () => {
// Set up spies on the module namespace BEFORE TerminalHost is imported.
// Bun's ES live bindings ensure TerminalHost sees the spy implementations.
const processTree = await import("../lib/resource-metrics/process-tree");
spyOn(processTree, "captureProcessSnapshot").mockImplementation((...args) =>
mockCaptureProcessSnapshot(...args),
);
spyOn(processTree, "getSubtreeResources").mockImplementation((...args) =>
mockGetSubtreeResources(...args),
);
({ TerminalHost } = await import("./terminal-host"));
});

afterAll(() => {
mock.restore();
});

beforeEach(() => {
// Reset call history AND implementations so tests are fully isolated.
mockCaptureProcessSnapshot.mockReset();
mockGetSubtreeResources.mockReset();

// Re-establish default (no-op) implementations after reset.
mockCaptureProcessSnapshot.mockImplementation(async () => ({
byPid: new Map<
number,
{ pid: number; ppid: number; cpu: number; memory: number }
>(),
childrenOf: new Map<number, number[]>(),
}));
mockGetSubtreeResources.mockImplementation(() => ({
cpu: 0,
memory: 0,
pids: [] as number[],
}));

host = new TerminalHost();
// Stop background timers immediately — we drive sweep manually.
internal(host).stopIdleSweep();
internal(host).stopRssSweep();
// Stub out `kill` so fake sessions don't trigger kill-timers.
internal(host).kill = mock(() => ({ success: true }));
});

afterEach(async () => {
await host.dispose();
});

it("skips captureProcessSnapshot when no session has a PID", async () => {
const s = makeFakeSession("s-nopid", null);
internal(host).sessions.set("s-nopid", s);

await internal(host).runRssSweep();

expect(mockCaptureProcessSnapshot).not.toHaveBeenCalled();
});

it("silently swallows captureProcessSnapshot failures", async () => {
mockCaptureProcessSnapshot.mockImplementation(() =>
Promise.reject(new Error("ps unavailable")),
);

const s = makeFakeSession("s-psfail", 1001);
internal(host).sessions.set("s-psfail", s);

// Must resolve, not throw.
await expect(internal(host).runRssSweep()).resolves.toBeUndefined();
expect(internal(host).kill).not.toHaveBeenCalled();
});

it("kills a session whose RSS exceeds 512 MB", async () => {
mockGetSubtreeResources.mockImplementation(() => ({
cpu: 20,
memory: LIMIT_BYTES + 1,
pids: [1002],
}));

const s = makeFakeSession("s-heavy", 1002);
internal(host).sessions.set("s-heavy", s);

await internal(host).runRssSweep();

expect(internal(host).kill).toHaveBeenCalledWith({
sessionId: "s-heavy",
deleteHistory: false,
});
});

it("does not kill a session whose RSS is at or below 512 MB", async () => {
mockGetSubtreeResources.mockImplementation(() => ({
cpu: 5,
memory: LIMIT_BYTES,
pids: [1003],
}));

const s = makeFakeSession("s-ok", 1003);
internal(host).sessions.set("s-ok", s);

await internal(host).runRssSweep();

expect(internal(host).kill).not.toHaveBeenCalled();
});

it("skips sessions that are not attachable", async () => {
mockGetSubtreeResources.mockImplementation(() => ({
cpu: 99,
memory: LIMIT_BYTES * 2,
pids: [1004],
}));

// isAttachable = false → should be filtered before captureProcessSnapshot
const s = makeFakeSession("s-dead", 1004, false);
internal(host).sessions.set("s-dead", s);

await internal(host).runRssSweep();

expect(mockCaptureProcessSnapshot).not.toHaveBeenCalled();
expect(internal(host).kill).not.toHaveBeenCalled();
});

it("only kills over-limit sessions when mixed with healthy ones", async () => {
mockGetSubtreeResources.mockImplementation(
(_snap: unknown, pid: number) => ({
cpu: 0,
memory: pid === 2001 ? LIMIT_BYTES + 1 : 100 * MB,
pids: [pid],
}),
);

const heavy = makeFakeSession("s-heavy2", 2001);
const light = makeFakeSession("s-light", 2002);
internal(host).sessions.set("s-heavy2", heavy);
internal(host).sessions.set("s-light", light);

await internal(host).runRssSweep();

expect(internal(host).kill).toHaveBeenCalledTimes(1);
expect(internal(host).kill).toHaveBeenCalledWith({
sessionId: "s-heavy2",
deleteHistory: false,
});
});
});
85 changes: 85 additions & 0 deletions apps/desktop/src/main/terminal-host/terminal-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
*/

import type { Socket } from "node:net";
import {
captureProcessSnapshot,
getSubtreeResources,
type ProcessSnapshot,
} from "../lib/resource-metrics/process-tree";
import { TerminalAttachCanceledError } from "../lib/terminal/errors";
import type {
CancelCreateOrAttachRequest,
Expand All @@ -35,6 +40,14 @@ const KILL_TIMEOUT_MS = 5000;
const MAX_CONCURRENT_SPAWNS = 3;
const SPAWN_READY_TIMEOUT_MS = 5000;

/** Auto-kill idle sessions with no attached clients after this duration */
const IDLE_SESSION_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
const IDLE_SWEEP_INTERVAL_MS = 10 * 60 * 1000; // sweep every 10 minutes

/** Kill sessions whose process tree RSS exceeds this threshold */
const MAX_SESSION_RSS_BYTES = 512 * 1024 * 1024; // 512 MB
const RSS_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // check every 5 minutes

interface PendingAttach {
requestId: string;
abortController: AbortController;
Expand Down Expand Up @@ -72,6 +85,8 @@ export class TerminalHost {
private killTimers: Map<string, NodeJS.Timeout> = new Map();
private pendingAttaches: Map<string, PendingAttach> = new Map();
private spawnLimiter = new Semaphore(MAX_CONCURRENT_SPAWNS);
private idleSweepTimer: NodeJS.Timeout | null = null;
private rssSweepTimer: NodeJS.Timeout | null = null;
private onUnattachedExit?: (event: {
sessionId: string;
exitCode: number;
Expand All @@ -88,6 +103,73 @@ export class TerminalHost {
}) => void;
} = {}) {
this.onUnattachedExit = onUnattachedExit;
this.startIdleSweep();
this.startRssSweep();
}

private startIdleSweep(): void {
this.idleSweepTimer = setInterval(() => {
const now = Date.now();
for (const session of this.sessions.values()) {
if (!session.isAttachable) continue; // already terminating/dead
if (session.clientCount > 0) continue; // has attached clients
const meta = session.getMeta();
const lastActive = new Date(meta.lastAttachedAt).getTime();
if (now - lastActive > IDLE_SESSION_TIMEOUT_MS) {
console.log(
`[TerminalHost] Auto-killing idle session ${session.sessionId} (idle for ${Math.round((now - lastActive) / 60000)}min)`,
);
this.kill({ sessionId: session.sessionId, deleteHistory: false });
}
}
}, IDLE_SWEEP_INTERVAL_MS);
}

private stopIdleSweep(): void {
if (this.idleSweepTimer) {
clearInterval(this.idleSweepTimer);
this.idleSweepTimer = null;
}
}

private startRssSweep(): void {
this.rssSweepTimer = setInterval(() => {
void this.runRssSweep();
}, RSS_SWEEP_INTERVAL_MS);
}

private stopRssSweep(): void {
if (this.rssSweepTimer) {
clearInterval(this.rssSweepTimer);
this.rssSweepTimer = null;
}
}

private async runRssSweep(): Promise<void> {
const sessionsWithPid = Array.from(this.sessions.values()).filter(
(s) => s.isAttachable && s.pid !== null,
);
if (sessionsWithPid.length === 0) return;

let snapshot: ProcessSnapshot | undefined;
try {
snapshot = await captureProcessSnapshot();
} catch (err) {
console.warn("[resource-metrics] captureProcessSnapshot failed:", err);
return;
}

for (const session of sessionsWithPid) {
const pid = session.pid;
if (pid === null) continue;
const { memory } = getSubtreeResources(snapshot, pid);
if (memory > MAX_SESSION_RSS_BYTES) {
console.warn(
`[TerminalHost] Killing session ${session.sessionId} — RSS ${Math.round(memory / 1024 / 1024)} MB exceeds ${Math.round(MAX_SESSION_RSS_BYTES / 1024 / 1024)} MB limit`,
);
this.kill({ sessionId: session.sessionId, deleteHistory: false });
}
}
}

/**
Expand Down Expand Up @@ -382,6 +464,9 @@ export class TerminalHost {
}

async dispose(): Promise<void> {
this.stopIdleSweep();
this.stopRssSweep();

for (const pendingAttach of this.pendingAttaches.values()) {
pendingAttach.abortController.abort();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const queryClient = new QueryClient({
queries: {
networkMode: "always",
retry: false,
staleTime: 30_000, // 30s — avoids refetch on every mount
gcTime: 5 * 60 * 1000, // 5 minutes — explicit (matches TanStack Query default)
},
mutations: {
networkMode: "always",
Expand Down
Loading