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
21 changes: 20 additions & 1 deletion apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { db } from "main/lib/db";
import { terminalManager } from "main/lib/terminal-manager";
import { nanoid } from "nanoid";
import { SUPERSET_DIR_NAME, WORKTREES_DIR_NAME } from "shared/constants";
import { z } from "zod";
Expand Down Expand Up @@ -283,9 +284,13 @@ export const createWorkspacesRouter = () => {
canDelete: false,
reason: "Workspace not found",
workspace: null,
activeTerminalCount: 0,
};
}

const activeTerminalCount =
terminalManager.getSessionCountByWorkspaceId(input.id);

const worktree = db.data.worktrees.find(
(wt) => wt.id === workspace.worktreeId,
);
Expand All @@ -307,6 +312,7 @@ export const createWorkspacesRouter = () => {
workspace,
warning:
"Worktree not found in git (may have been manually removed)",
activeTerminalCount,
};
}

Expand All @@ -315,12 +321,14 @@ export const createWorkspacesRouter = () => {
reason: null,
workspace,
warning: null,
activeTerminalCount,
};
} catch (error) {
return {
canDelete: false,
reason: `Failed to check worktree status: ${error instanceof Error ? error.message : String(error)}`,
workspace,
activeTerminalCount,
};
}
}
Expand All @@ -330,6 +338,7 @@ export const createWorkspacesRouter = () => {
reason: null,
workspace,
warning: "No associated worktree found",
activeTerminalCount,
};
}),

Expand All @@ -342,6 +351,11 @@ export const createWorkspacesRouter = () => {
return { success: false, error: "Workspace not found" };
}

// Kill all terminal processes in this workspace first
const terminalResult = await terminalManager.killByWorkspaceId(
input.id,
);

const worktree = db.data.worktrees.find(
(wt) => wt.id === workspace.worktreeId,
);
Expand Down Expand Up @@ -418,7 +432,12 @@ export const createWorkspacesRouter = () => {
}
});

return { success: true, teardownError };
const terminalWarning =
terminalResult.failed > 0
? `${terminalResult.failed} terminal process(es) may still be running`
: undefined;

return { success: true, teardownError, terminalWarning };
}),

setActive: publicProcedure
Expand Down
154 changes: 154 additions & 0 deletions apps/desktop/src/main/lib/terminal-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,160 @@ describe("TerminalManager", () => {
});
});

describe("killByWorkspaceId", () => {
it("should kill session for a workspace and return count", async () => {
await manager.createOrAttach({
tabId: "tab-kill-single",
workspaceId: "workspace-kill-single",
tabTitle: "Test Tab",
workspaceName: "Test Workspace",
});

const result = await manager.killByWorkspaceId("workspace-kill-single");

// With the mock, the session exits cleanly via the kill mock's setImmediate
expect(result.killed + result.failed).toBe(1);
});

it("should not kill sessions from other workspaces", async () => {
await manager.createOrAttach({
tabId: "tab-other",
workspaceId: "workspace-other",
tabTitle: "Test Tab",
workspaceName: "Other Workspace",
});

await manager.killByWorkspaceId("workspace-different");

// Session should still exist
expect(manager.getSession("tab-other")).not.toBeNull();
expect(manager.getSession("tab-other")?.isAlive).toBe(true);
});

it("should return zero counts for non-existent workspace", async () => {
const result = await manager.killByWorkspaceId("non-existent");

expect(result.killed).toBe(0);
expect(result.failed).toBe(0);
});

it("should delete history for killed sessions", async () => {
await manager.createOrAttach({
tabId: "tab-kill-history",
workspaceId: "workspace-kill",
tabTitle: "Test Tab",
workspaceName: "Test Workspace",
});

// Trigger some data to create history
const onDataCallback =
mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0];
if (onDataCallback) {
onDataCallback("test output\n");
}

await manager.killByWorkspaceId("workspace-kill");

// Wait a bit for cleanup to complete
await new Promise((resolve) => setTimeout(resolve, 100));

// Verify history directory was deleted
const historyDir = join(
testTmpDir,
".superset/terminal-history/workspace-kill/tab-kill-history",
);
const exists = await fs
.stat(historyDir)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});

it("should clean up already-dead sessions", async () => {
await manager.createOrAttach({
tabId: "tab-dead",
workspaceId: "workspace-dead",
tabTitle: "Test Tab",
workspaceName: "Test Workspace",
});

// Simulate the session dying naturally
const onExitCallback =
mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0];
if (onExitCallback) {
await onExitCallback({ exitCode: 0, signal: undefined });
}

// Wait for the dead session to be kept in map (5s timeout in onExit)
await new Promise((resolve) => setTimeout(resolve, 100));

const result = await manager.killByWorkspaceId("workspace-dead");

expect(result.killed).toBe(1);
expect(result.failed).toBe(0);
});
});

describe("getSessionCountByWorkspaceId", () => {
it("should return count of active sessions for workspace", async () => {
await manager.createOrAttach({
tabId: "tab-1",
workspaceId: "workspace-count",
tabTitle: "Test Tab 1",
workspaceName: "Test Workspace",
});

await manager.createOrAttach({
tabId: "tab-2",
workspaceId: "workspace-count",
tabTitle: "Test Tab 2",
workspaceName: "Test Workspace",
});

await manager.createOrAttach({
tabId: "tab-3",
workspaceId: "other-workspace",
tabTitle: "Test Tab 3",
workspaceName: "Other Workspace",
});

expect(manager.getSessionCountByWorkspaceId("workspace-count")).toBe(2);
expect(manager.getSessionCountByWorkspaceId("other-workspace")).toBe(1);
});

it("should return zero for non-existent workspace", () => {
expect(manager.getSessionCountByWorkspaceId("non-existent")).toBe(0);
});

it("should not count dead sessions", async () => {
await manager.createOrAttach({
tabId: "tab-alive",
workspaceId: "workspace-mixed",
tabTitle: "Test Tab Alive",
workspaceName: "Test Workspace",
});

await manager.createOrAttach({
tabId: "tab-dead",
workspaceId: "workspace-mixed",
tabTitle: "Test Tab Dead",
workspaceName: "Test Workspace",
});

// Simulate the second session dying
const onExitCallback =
mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0];
if (onExitCallback) {
await onExitCallback({ exitCode: 0, signal: undefined });
}

// Wait for state to update
await new Promise((resolve) => setTimeout(resolve, 100));

expect(manager.getSessionCountByWorkspaceId("workspace-mixed")).toBe(1);
});
});

describe("multi-session history persistence", () => {
it("should persist history across multiple sessions", async () => {
// Session 1: Create and write data
Expand Down
105 changes: 105 additions & 0 deletions apps/desktop/src/main/lib/terminal-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,111 @@ export class TerminalManager extends EventEmitter {
};
}

async killByWorkspaceId(
workspaceId: string,
): Promise<{ killed: number; failed: number }> {
const sessionsToKill = Array.from(this.sessions.entries()).filter(
([, session]) => session.workspaceId === workspaceId,
);

if (sessionsToKill.length === 0) {
return { killed: 0, failed: 0 };
}

const results: Promise<boolean>[] = [];

for (const [tabId, session] of sessionsToKill) {
if (session.isAlive) {
session.deleteHistoryOnExit = true;

const killPromise = new Promise<boolean>((resolve) => {
let resolved = false;
let sigtermTimeout: ReturnType<typeof setTimeout> | undefined;
let sigkillTimeout: ReturnType<typeof setTimeout> | undefined;

const cleanup = (success: boolean) => {
if (resolved) return;
resolved = true;
this.off(`exit:${tabId}`, exitHandler);
if (sigtermTimeout) clearTimeout(sigtermTimeout);
if (sigkillTimeout) clearTimeout(sigkillTimeout);
resolve(success);
};

const exitHandler = () => cleanup(true);
this.once(`exit:${tabId}`, exitHandler);

// First timeout: SIGTERM didn't work, try SIGKILL
sigtermTimeout = setTimeout(() => {
if (resolved || !session.isAlive) return;

try {
session.pty.kill("SIGKILL");
} catch (error) {
console.error(
`Failed to send SIGKILL to terminal ${tabId}:`,
error,
);
}

// Second timeout: SIGKILL didn't work either
sigkillTimeout = setTimeout(() => {
if (resolved) return;

if (session.isAlive) {
console.error(
`Terminal ${tabId} did not exit after SIGKILL, forcing cleanup. Process may still be running.`,
);
// Clean up session state before resolving
session.isAlive = false;
this.sessions.delete(tabId);
this.closeHistory(session).catch(() => {});
}
cleanup(false);
}, 500);
sigkillTimeout.unref();
}, 2000);
sigtermTimeout.unref();

// Send initial SIGTERM
try {
session.pty.kill();
} catch (error) {
console.error(
`Failed to send SIGTERM to terminal ${tabId}:`,
error,
);
// Mark as failed immediately since we can't even signal it
session.isAlive = false;
this.sessions.delete(tabId);
this.closeHistory(session).catch(() => {});
cleanup(false);
}
});

results.push(killPromise);
} else {
// Clean up history for already-dead sessions
session.deleteHistoryOnExit = true;
await this.closeHistory(session);
this.sessions.delete(tabId);
results.push(Promise.resolve(true));
}
}

const outcomes = await Promise.all(results);
const killed = outcomes.filter(Boolean).length;
const failed = outcomes.length - killed;

return { killed, failed };
}

getSessionCountByWorkspaceId(workspaceId: string): number {
return Array.from(this.sessions.values()).filter(
(session) => session.workspaceId === workspaceId && session.isAlive,
).length;
}

async cleanup(): Promise<void> {
const exitPromises: Promise<void>[] = [];

Expand Down
Loading