Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
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
4 changes: 0 additions & 4 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
name: Claude Code Review

on:
pull_request:
types: [opened]
pull_request_target:
types: [opened]
workflow_dispatch:

# Cancel in-progress runs for the same PR to prevent duplicate reviews
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ jobs:
if: steps.check_release.outputs.skip != 'true'
run: pnpm -r prebuild

- name: Lint
if: steps.check_release.outputs.skip != 'true'
run: pnpm -C apps/desktop lint

- name: Typecheck
if: steps.check_release.outputs.skip != 'true'
run: pnpm -C apps/desktop typecheck
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.9.0",
"version": "0.9.1",
"description": "ClosedLoop Desktop",
"author": "ClosedLoop AI <support@closedloop.ai>",
"private": true,
Expand Down
29 changes: 18 additions & 11 deletions apps/desktop/src/main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import {
import { LocalSessionStore } from "./local-session-store.js";
import { enrichJobSnapshot } from "../server/operations/symphony-job-snapshot.js";
import { GatewayRecoveryManager } from "./gateway-recovery.js";
import { runShutdownSequence } from "./shutdown.js";
import type { ShutdownResult } from "./shutdown.js";
import pkg from "electron-updater";
const { autoUpdater } = pkg;
import { BUILD_COMMIT_HASH } from "../shared/build-info.js";
Expand Down Expand Up @@ -360,21 +362,26 @@ export class DesktopApplication {
this.desktopWindow.show();
}

async shutdown(): Promise<void> {
async shutdown(): Promise<ShutdownResult> {
if (this.shuttingDown) {
return;
return "clean";
}

this.shuttingDown = true;
if (this.updateCheckTimer) {
clearInterval(this.updateCheckTimer);
this.updateCheckTimer = null;
}
this.cloudSocket.stop();
this.commandExecutor.dispose();
await this.server.stop();
this.desktopWindow.dispose();
this.tray.dispose();
return runShutdownSequence({
updateCheckTimer: this.updateCheckTimer,
clearUpdateCheckTimer: () => {
if (this.updateCheckTimer) {
clearInterval(this.updateCheckTimer);
this.updateCheckTimer = null;
}
},
cloudSocket: this.cloudSocket,
commandExecutor: this.commandExecutor,
server: this.server,
desktopWindow: this.desktopWindow,
tray: this.tray,
});
}

private async probeGatewayAlive(): Promise<boolean> {
Expand Down
19 changes: 15 additions & 4 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,21 @@ app.on("activate", () => {
desktopApplication.showWindow();
});

app.on("before-quit", () => {
void desktopApplication.shutdown().catch((error) => {
const message = error instanceof Error ? error.message : "unknown shutdown error";
console.error(`desktop shutdown failed: ${message}`);
let quitPromise: Promise<void> | null = null;

app.on("before-quit", (event) => {
// Prevent Electron from proceeding until async shutdown completes.
event.preventDefault();

// If shutdown is already in progress (e.g. window-all-closed fired app.quit()
// on non-macOS after DesktopWindow.dispose() closed the last window), do nothing.
// The first invocation's .then() continuation will call app.exit() exactly once.
if (quitPromise) {
return;
}

quitPromise = desktopApplication.shutdown().then((result) => {
app.exit(result === "clean" ? 0 : 1);
});
});

Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/main/shutdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export interface ShutdownDeps {
updateCheckTimer: NodeJS.Timeout | null;
clearUpdateCheckTimer: () => void;
cloudSocket: { stop: () => void };
commandExecutor: { dispose: () => void };
server: { stop: () => Promise<void> };
desktopWindow: { dispose: () => void };
tray: { dispose: () => void };
}

export type ShutdownResult = "clean" | "timed_out" | "failed";

export async function runShutdownSequence(
deps: ShutdownDeps,
options?: { timeoutMs?: number; setTimeoutFn?: typeof setTimeout }
): Promise<ShutdownResult> {
const timeoutMs = options?.timeoutMs ?? 5000;
const setTimeoutFn = options?.setTimeoutFn ?? setTimeout;

let timer: ReturnType<typeof setTimeout> | null = null;

const cleanup = async (): Promise<"clean"> => {
Comment thread
shafty023 marked this conversation as resolved.
deps.clearUpdateCheckTimer();
deps.cloudSocket.stop();
deps.commandExecutor.dispose();
await deps.server.stop();
deps.desktopWindow.dispose();
deps.tray.dispose();
return "clean";
};

const timeout = new Promise<"timed_out">((resolve) => {
timer = setTimeoutFn(() => resolve("timed_out"), timeoutMs);
});

try {
const result = await Promise.race([cleanup(), timeout]);
return result;
} catch {
return "failed";
} finally {
if (timer != null) {
clearTimeout(timer);
}
}
}
4 changes: 2 additions & 2 deletions apps/desktop/src/server/operations/symphony-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ function buildClaudePipeline(
`tee -a ${shellEscape(jsonlFile)}`,
`python3 ${shellEscape(formatter)}`,
].join(" | ");
return { cmd: "bash", args: ["-c", pipeline] };
return { cmd: "bash", args: ["-c", `${pipeline}; exit \${PIPESTATUS[0]}`] };
}

// No formatter — wrap in bash pipeline so grep|tee still writes claude-output.jsonl
Expand All @@ -320,7 +320,7 @@ function buildClaudePipeline(
"grep --line-buffered '^{'",
`tee -a ${shellEscape(jsonlFile)}`,
].join(" | ");
return { cmd: "bash", args: ["-c", pipeline] };
return { cmd: "bash", args: ["-c", `${pipeline}; exit \${PIPESTATUS[0]}`] };
}

/** Find the local repo path for a given fullName (e.g. "org/repo"). */
Expand Down
138 changes: 138 additions & 0 deletions apps/desktop/test/shutdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import {
runShutdownSequence,
type ShutdownDeps,
} from "../src/main/shutdown.js";

/** Build stub deps that record call order. */
function makeStubDeps(overrides?: Partial<ShutdownDeps>) {
const calls: string[] = [];
const deps: ShutdownDeps = {
updateCheckTimer: null,
clearUpdateCheckTimer: () => {
calls.push("clearUpdateCheckTimer");
},
cloudSocket: {
stop: () => {
calls.push("cloudSocket.stop");
},
},
commandExecutor: {
dispose: () => {
calls.push("commandExecutor.dispose");
},
},
server: {
stop: async () => {
calls.push("server.stop");
},
},
desktopWindow: {
dispose: () => {
calls.push("desktopWindow.dispose");
},
},
tray: {
dispose: () => {
calls.push("tray.dispose");
},
},
...overrides,
};
return { deps, calls };
}

describe("runShutdownSequence", () => {
test("clean path: all deps succeed, cleanup steps called in order", async () => {
const { deps, calls } = makeStubDeps();

const result = await runShutdownSequence(deps);

assert.equal(result, "clean");
assert.deepEqual(calls, [
"clearUpdateCheckTimer",
"cloudSocket.stop",
"commandExecutor.dispose",
"server.stop",
"desktopWindow.dispose",
"tray.dispose",
]);
});

test("timeout path: result is 'timed_out' when server.stop never resolves", async () => {
const { deps } = makeStubDeps({
server: {
stop: () => new Promise<void>(() => {}), // never resolves
},
});

// Stub setTimeoutFn that fires the callback immediately
const stubSetTimeout = ((cb: () => void) => {
cb();
return 999 as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof setTimeout;

const result = await runShutdownSequence(deps, {
setTimeoutFn: stubSetTimeout,
});

assert.equal(result, "timed_out");
});

test("failed path: server.stop rejects with an error", async () => {
const { deps } = makeStubDeps({
server: {
stop: () => Promise.reject(new Error("stop failed")),
},
});

// Use a setTimeoutFn that never fires so timeout doesn't win
const neverTimeout = (() =>
42 as unknown as ReturnType<typeof setTimeout>) as unknown as typeof setTimeout;

const result = await runShutdownSequence(deps, {
setTimeoutFn: neverTimeout,
});

assert.equal(result, "failed");
});

test("timer is cleared after cleanup resolves (no leaked handles)", async () => {
const { deps } = makeStubDeps();

let capturedTimerId: ReturnType<typeof setTimeout> | null = null;
let clearTimeoutCalledWith: unknown = null;

// Monkey-patch clearTimeout to observe the call
const origClearTimeout = globalThis.clearTimeout;
globalThis.clearTimeout = ((id: unknown) => {
clearTimeoutCalledWith = id;
origClearTimeout(id as ReturnType<typeof setTimeout>);
}) as typeof clearTimeout;

try {
// Use a real-ish setTimeoutFn that returns a recognizable timer id
const stubSetTimeout = ((_cb: () => void, _ms: number) => {
const id = origClearTimeout.bind(
null
) as unknown as ReturnType<typeof setTimeout>;
capturedTimerId = 12345 as unknown as ReturnType<typeof setTimeout>;
return capturedTimerId;
}) as unknown as typeof setTimeout;

const result = await runShutdownSequence(deps, {
setTimeoutFn: stubSetTimeout,
});

assert.equal(result, "clean");
assert.equal(
clearTimeoutCalledWith,
capturedTimerId,
"clearTimeout should be called with the timer id returned by setTimeoutFn"
);
} finally {
globalThis.clearTimeout = origClearTimeout;
}
});
});
Loading