Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/desktop/src/main/lib/terminal/port-manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EventEmitter } from "node:events";
import type { DetectedPort } from "shared/types";
import { treeKillWithEscalation } from "../tree-kill-with-escalation";
import { treeKillWithEscalation } from "../tree-kill";
import {
getListeningPortsForPids,
getProcessTree,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import treeKill from "tree-kill";
const DEFAULT_ESCALATION_TIMEOUT_MS = 2000;
const POLL_INTERVAL_MS = 50;

export function treeKillAsync(pid: number, signal: string): Promise<void> {
return new Promise<void>((resolve) => {
treeKill(pid, signal, (err) => {
if (err) {
console.warn(
`[treeKillAsync] Failed to ${signal} pid ${pid}:`,
err.message,
);
}
resolve();
});
});
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Kill a process tree with escalation to SIGKILL if the process survives.
* Sends SIGTERM, polls for exit, escalates to SIGKILL after timeout.
Expand Down
28 changes: 13 additions & 15 deletions apps/desktop/src/main/terminal-host/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,14 +759,13 @@ async function startServer(): Promise<void> {
});
}

function stopServer(): Promise<void> {
return new Promise((resolve) => {
// Dispose terminal host (kills all sessions)
if (terminalHost) {
terminalHost.dispose();
log("info", "Terminal host disposed");
}
async function stopServer(): Promise<void> {
if (terminalHost) {
await terminalHost.dispose();
log("info", "Terminal host disposed");
}

await new Promise<void>((resolve) => {
if (server) {
server.close(() => {
log("info", "Server closed");
Expand All @@ -775,15 +774,14 @@ function stopServer(): Promise<void> {
} else {
resolve();
}

// Clean up socket and PID files
try {
if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH);
if (existsSync(PID_PATH)) unlinkSync(PID_PATH);
} catch {
// Best effort cleanup
}
});

try {
if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH);
if (existsSync(PID_PATH)) unlinkSync(PID_PATH);
} catch {
// Best effort cleanup
}
}

// =============================================================================
Expand Down
72 changes: 32 additions & 40 deletions apps/desktop/src/main/terminal-host/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
TerminalExitEvent,
TerminalSnapshot,
} from "../lib/terminal-host/types";
import { treeKillAsync } from "../lib/tree-kill";
import {
createFrameHeader,
PtySubprocessFrameDecoder,
Expand Down Expand Up @@ -340,24 +341,7 @@ export class Session {
this.ptyReadyResolve = null;
}

this.subprocess = null;
this.subprocessReady = false;
this.subprocessDecoder = null;
this.subprocessStdinQueue = [];
this.subprocessStdinQueuedBytes = 0;
this.subprocessStdinDrainArmed = false;
this.subprocessStdoutPaused = false;

this.emulatorWriteQueue = [];
this.emulatorWriteQueuedBytes = 0;
this.emulatorWriteScheduled = false;
this.snapshotBoundaryIndex = null;
const waiters = this.emulatorFlushWaiters;
this.emulatorFlushWaiters = [];
for (const resolve of waiters) resolve();
const boundaryWaiters = this.snapshotBoundaryWaiters;
this.snapshotBoundaryWaiters = [];
for (const resolve of boundaryWaiters) resolve();
this.resetProcessState();
}

/**
Expand Down Expand Up @@ -814,33 +798,46 @@ export class Session {
}
}

/**
* Dispose of the session
*/
dispose(): void {
if (this.disposed) return;
/** Callers that don't need to wait can fire-and-forget. */
dispose(): Promise<void> {
if (this.disposed) return Promise.resolve();
this.disposed = true;

const pidsToKill = this.collectProcessPids();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dispose frame never sent due to early disposed flag

Medium Severity

this.disposed is set to true before sendDisposeToSubprocess() is called. But sendFrameToSubprocess (which sendDisposeToSubprocess delegates to) has an early return if (this.disposed) return false, so the dispose frame is silently dropped and never reaches the subprocess. The graceful shutdown notification intended by the code never actually fires.

Additional Locations (1)

Fix in Cursor Fix in Web

if (this.subprocess) {
// Capture reference before nullifying - the timeout needs it
const subprocess = this.subprocess;
this.sendDisposeToSubprocess();
// Force kill after timeout if dispose frame didn't terminate it
const killTimer = setTimeout(() => {
try {
subprocess.kill("SIGKILL");
} catch {
// Process may already be dead
}
}, 1000);
killTimer.unref(); // Don't keep daemon alive for this timer
this.subprocess = null;
}

this.resetProcessState();
this.emulator.dispose();
this.attachedClients.clear();
this.clientSocketsWaitingForDrain.clear();

if (pidsToKill.length === 0) return Promise.resolve();

// Must await: treeKill enumerates descendants via ps/pgrep before signaling
return Promise.all(
pidsToKill.map((pid) => treeKillAsync(pid, "SIGKILL")),
).then(() => {});
}

/** Includes PTY PID as safety net in case the shell was reparented after subprocess exit. */
private collectProcessPids(): number[] {
const pids: number[] = [];
if (this.subprocess?.pid) pids.push(this.subprocess.pid);
if (this.ptyPid) pids.push(this.ptyPid);
return pids;
}

private resetProcessState(): void {
this.subprocess = null;
this.subprocessReady = false;
this.subprocessDecoder = null;
this.subprocessStdinQueue = [];
this.subprocessStdinQueuedBytes = 0;
this.subprocessStdinDrainArmed = false;
this.subprocessStdoutPaused = false;

this.emulatorWriteQueue = [];
this.emulatorWriteQueuedBytes = 0;
Expand All @@ -852,11 +849,6 @@ export class Session {
const boundaryWaiters = this.snapshotBoundaryWaiters;
this.snapshotBoundaryWaiters = [];
for (const resolve of boundaryWaiters) resolve();

this.emulator.dispose();
this.attachedClients.clear();
this.clientSocketsWaitingForDrain.clear();
this.subprocessStdoutPaused = false;
}

/**
Expand Down
27 changes: 16 additions & 11 deletions apps/desktop/src/main/terminal-host/terminal-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@ export class TerminalHost {

// Force-dispose terminating sessions to prevent race conditions
if (session?.isTerminating) {
session.dispose();
void session.dispose();
this.sessions.delete(sessionId);
this.clearKillTimer(sessionId);
session = undefined;
}

if (session && !session.isAlive) {
session.dispose();
void session.dispose();
this.sessions.delete(sessionId);
session = undefined;
}
Expand Down Expand Up @@ -137,7 +137,7 @@ export class TerminalHost {
}

if (!session.isAlive) {
session.dispose();
void session.dispose();
throw new Error("Session spawn failed: PTY process exited immediately");
}

Expand Down Expand Up @@ -207,7 +207,7 @@ export class TerminalHost {
if (session) {
session.detach(socket);
if (!session.isAlive && session.clientCount === 0) {
session.dispose();
void session.dispose();
this.sessions.delete(request.sessionId);
}
}
Expand Down Expand Up @@ -253,7 +253,7 @@ export class TerminalHost {
console.warn(
`[TerminalHost] Force disposing stuck session ${sessionId} after ${KILL_TIMEOUT_MS}ms`,
);
s.dispose();
void s.dispose();
this.sessions.delete(sessionId);
}
this.killTimers.delete(sessionId);
Expand Down Expand Up @@ -318,22 +318,27 @@ export class TerminalHost {
session.detach(socket);
// Clean up dead sessions when last client detaches
if (!session.isAlive && session.clientCount === 0) {
session.dispose();
void session.dispose();
this.sessions.delete(sessionId);
}
}
}

dispose(): void {
async dispose(): Promise<void> {
for (const timer of this.killTimers.values()) {
clearTimeout(timer);
}
this.killTimers.clear();

for (const session of this.sessions.values()) {
session.dispose();
}
const sessions = [...this.sessions.values()];
this.sessions.clear();

if (sessions.length === 0) return;

await Promise.race([
Promise.all(sessions.map((s) => s.dispose())),
new Promise<void>((resolve) => setTimeout(resolve, 5000)),
]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaked timeout in TerminalHost.dispose() Promise.race

Low Severity

The 5-second setTimeout in Promise.race is never cleared when Promise.all resolves first. Unlike promiseWithTimeout in the same file which properly clears its timer, this timeout leaks and keeps the event loop alive unnecessarily. In production process.exit() masks this, but it could affect tests or other callers.

Fix in Cursor Fix in Web

}

/**
Expand Down Expand Up @@ -393,7 +398,7 @@ export class TerminalHost {
}

if (session.clientCount === 0) {
session.dispose();
void session.dispose();
this.sessions.delete(sessionId);
} else {
this.scheduleSessionCleanup(sessionId);
Expand Down
Loading