fix(server): harden worktree archive with parallel teardown

Fan out agent close + terminal kill concurrently with Promise.allSettled
so one failure never leaves the worktree half-dead. Await real PTY exit
with SIGHUP→SIGKILL escalation before rm to avoid EBUSY races. Tolerate
missing repoRoot and retry directory removal with backoff. Purge stale
client workspace layout/tab state after successful archive.
This commit is contained in:
Mohamed Boudra
2026-04-17 15:50:48 +07:00
parent ed2dc08443
commit d6585f7c3a
9 changed files with 537 additions and 65 deletions
@@ -28,6 +28,10 @@ export interface TerminalManager {
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
getTerminal(id: string): TerminalSession | undefined;
killTerminal(id: string): void;
killTerminalAndWait(
id: string,
options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number },
): Promise<void>;
listDirectories(): string[];
killAll(): void;
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
@@ -200,6 +204,21 @@ export function createTerminalManager(): TerminalManager {
removeSessionById(id, { kill: true });
},
async killTerminalAndWait(
id: string,
options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number },
): Promise<void> {
const session = terminalsById.get(id);
if (!session) {
return;
}
try {
await session.killAndWait(options);
} finally {
removeSessionById(id, { kill: false });
}
},
listDirectories(): string[] {
return Array.from(terminalsByCwd.keys());
},
+62
View File
@@ -44,6 +44,7 @@ export interface TerminalSession {
getTitle(): string | undefined;
getExitInfo(): TerminalExitInfo | null;
kill(): void;
killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise<void>;
}
export interface CreateTerminalOptions {
@@ -527,6 +528,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
let killed = false;
let disposed = false;
let exitEmitted = false;
let processExited = false;
const processExitWaiters = new Set<() => void>();
let exitInfo: TerminalExitInfo | null = null;
let recentOutputText = "";
let title: string | undefined;
@@ -670,6 +673,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
ptyProcess.onExit((event) => {
killed = true;
processExited = true;
for (const waiter of Array.from(processExitWaiters)) {
try {
waiter();
} catch {
// no-op
}
}
processExitWaiters.clear();
emitExit(
buildExitInfo({
exitCode: event.exitCode,
@@ -783,6 +795,55 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
disposeResources();
}
function waitForProcessExit(timeoutMs: number): Promise<boolean> {
if (processExited) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
const waiter = (): void => {
clearTimeout(timer);
resolve(true);
};
const timer = setTimeout(() => {
processExitWaiters.delete(waiter);
resolve(false);
}, timeoutMs);
processExitWaiters.add(waiter);
});
}
async function killAndWait(options?: {
gracefulTimeoutMs?: number;
forceTimeoutMs?: number;
}): Promise<void> {
const gracefulTimeoutMs = options?.gracefulTimeoutMs ?? 2000;
const forceTimeoutMs = options?.forceTimeoutMs ?? 1000;
if (processExited) {
kill();
return;
}
try {
ptyProcess.kill();
} catch {
// process may already be gone
}
const exitedGracefully = await waitForProcessExit(gracefulTimeoutMs);
if (!exitedGracefully) {
try {
ptyProcess.kill("SIGKILL");
} catch {
// process may already be gone
}
await waitForProcessExit(forceTimeoutMs);
}
// Finalize bookkeeping (idempotent if ptyProcess.onExit already fired).
kill();
}
// Small delay to let shell initialize
await new Promise((resolve) => setTimeout(resolve, 50));
@@ -799,5 +860,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
getTitle,
getExitInfo,
kill,
killAndWait,
};
}