Skip to content

Commit 6406b75

Browse files
authored
fix: App hanging on shutdown (#676)
- Updated agent logger - Added ace shutdown against timeout to prevent app from hanging forever - Fixed ACP shutdown deadlock
1 parent 18698f6 commit 6406b75

5 files changed

Lines changed: 44 additions & 7 deletions

File tree

apps/twig/src/main/lib/async.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Races an operation against a timeout.
3+
* Returns success with the value if the operation completes in time,
4+
* or timeout if the operation takes longer than the specified duration.
5+
*/
6+
export async function withTimeout<T>(
7+
operation: Promise<T>,
8+
timeoutMs: number,
9+
): Promise<{ result: "success"; value: T } | { result: "timeout" }> {
10+
const timeoutPromise = new Promise<{ result: "timeout" }>((resolve) =>
11+
setTimeout(() => resolve({ result: "timeout" }), timeoutMs),
12+
);
13+
const operationPromise = operation.then((value) => ({
14+
result: "success" as const,
15+
value,
16+
}));
17+
return Promise.race([operationPromise, timeoutPromise]);
18+
}

apps/twig/src/main/services/app-lifecycle/service.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { app } from "electron";
22
import { injectable } from "inversify";
33
import { ANALYTICS_EVENTS } from "../../../types/analytics.js";
44
import { container } from "../../di/container.js";
5+
import { withTimeout } from "../../lib/async.js";
56
import { logger } from "../../lib/logger.js";
67
import { shutdownPostHog, trackAppEvent } from "../posthog-analytics.js";
78

@@ -10,6 +11,7 @@ const log = logger.scope("app-lifecycle");
1011
@injectable()
1112
export class AppLifecycleService {
1213
private _isQuittingForUpdate = false;
14+
private static readonly SHUTDOWN_TIMEOUT_MS = 3000;
1315

1416
get isQuittingForUpdate(): boolean {
1517
return this._isQuittingForUpdate;
@@ -20,23 +22,33 @@ export class AppLifecycleService {
2022
}
2123

2224
async shutdown(): Promise<void> {
23-
log.info("Performing graceful shutdown...");
25+
// Race shutdown against timeout to prevent app from hanging forever
26+
const result = await withTimeout(
27+
this.doShutdown(),
28+
AppLifecycleService.SHUTDOWN_TIMEOUT_MS,
29+
);
30+
31+
if (result.result === "timeout") {
32+
log.warn("Shutdown timeout reached, proceeding anyway", {
33+
timeoutMs: AppLifecycleService.SHUTDOWN_TIMEOUT_MS,
34+
});
35+
}
36+
}
2437

38+
private async doShutdown(): Promise<void> {
2539
try {
2640
await container.unbindAll();
2741
} catch (error) {
28-
log.error("Error during container unbind", error);
42+
log.error("Failed to unbind container", error);
2943
}
3044

3145
trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);
3246

3347
try {
3448
await shutdownPostHog();
3549
} catch (error) {
36-
log.error("Error shutting down PostHog", error);
50+
log.error("Failed to shutdown PostHog", error);
3751
}
38-
39-
log.info("Graceful shutdown complete");
4052
}
4153

4254
async shutdownAndExit(): Promise<void> {

packages/agent/src/adapters/acp-connection.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type AcpConnectionConfig = {
1515
logWriter?: SessionLogWriter;
1616
sessionId?: string;
1717
taskId?: string;
18+
logger?: Logger;
1819
};
1920

2021
export type InProcessAcpConnection = {
@@ -32,7 +33,9 @@ export type InProcessAcpConnection = {
3233
export function createAcpConnection(
3334
config: AcpConnectionConfig = {},
3435
): InProcessAcpConnection {
35-
const logger = new Logger({ debug: true, prefix: "[AcpConnection]" });
36+
const logger =
37+
config.logger?.child("AcpConnection") ??
38+
new Logger({ debug: true, prefix: "[AcpConnection]" });
3639
const streams = createBidirectionalStreams();
3740

3841
const { logWriter } = config;

packages/agent/src/adapters/base-acp-agent.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,11 @@ export abstract class BaseAcpAgent implements Agent {
6262

6363
async closeSession(): Promise<void> {
6464
try {
65-
await this.cancel({ sessionId: this.sessionId });
65+
// Abort first so in-flight HTTP requests are cancelled,
66+
// otherwise interrupt() deadlocks waiting for the query to stop
67+
// while the query waits on an API call that will never abort.
6668
this.session.abortController.abort();
69+
await this.cancel({ sessionId: this.sessionId });
6770
this.logger.info("Closed session", { sessionId: this.sessionId });
6871
} catch (err) {
6972
this.logger.warn("Failed to close session", {

packages/agent/src/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export class Agent {
6262
logWriter: this.sessionLogWriter,
6363
sessionId: taskRunId,
6464
taskId,
65+
logger: this.logger,
6566
});
6667

6768
return this.acpConnection;

0 commit comments

Comments
 (0)