Skip to content

Commit e50e87a

Browse files
authored
fix: Force-kill process on shutdown timeout to prevent hang (#730)
1 parent fffdf68 commit e50e87a

File tree

3 files changed

+131
-27
lines changed

3 files changed

+131
-27
lines changed

apps/twig/src/main/index.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,27 +83,45 @@ app.on("window-all-closed", () => {
8383
});
8484

8585
app.on("before-quit", async (event) => {
86-
const lifecycleService = container.get<AppLifecycleService>(
87-
MAIN_TOKENS.AppLifecycleService,
88-
);
86+
let lifecycleService: AppLifecycleService;
87+
try {
88+
lifecycleService = container.get<AppLifecycleService>(
89+
MAIN_TOKENS.AppLifecycleService,
90+
);
91+
} catch {
92+
// Container already torn down (e.g. second quit during shutdown), let Electron quit
93+
return;
94+
}
8995

9096
// If quitting to install an update, don't block and let the updater handle it
9197
// we already gracefully shutdown the app in the updates service when the update is ready
9298
if (lifecycleService.isQuittingForUpdate) {
9399
return;
94100
}
95101

102+
// If shutdown is already in progress, force-kill immediately
103+
if (lifecycleService.isShuttingDown) {
104+
lifecycleService.forceExit();
105+
}
106+
96107
event.preventDefault();
97108
await lifecycleService.shutdownAndExit();
98109
});
99110

100-
const handleShutdownSignal = async (_signal: string) => {
111+
const handleShutdownSignal = async (signal: string) => {
112+
log.info(`Received ${signal}, starting shutdown`);
101113
try {
102114
const lifecycleService = container.get<AppLifecycleService>(
103115
MAIN_TOKENS.AppLifecycleService,
104116
);
117+
if (lifecycleService.isShuttingDown) {
118+
log.warn(`${signal} received during shutdown, forcing exit`);
119+
process.exit(1);
120+
}
105121
await lifecycleService.shutdown();
106-
} catch (_err) {}
122+
} catch (_err) {
123+
// Container torn down or shutdown failed
124+
}
107125
process.exit(0);
108126
};
109127

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

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import { AppLifecycleService } from "./service.js";
33

4-
const { mockApp, mockContainer, mockTrackAppEvent, mockShutdownPostHog } =
5-
vi.hoisted(() => ({
6-
mockApp: {
7-
exit: vi.fn(),
8-
},
9-
mockContainer: {
10-
unbindAll: vi.fn(() => Promise.resolve()),
11-
},
12-
mockTrackAppEvent: vi.fn(),
13-
mockShutdownPostHog: vi.fn(() => Promise.resolve()),
14-
}));
4+
const {
5+
mockApp,
6+
mockContainer,
7+
mockTrackAppEvent,
8+
mockShutdownPostHog,
9+
mockProcessExit,
10+
} = vi.hoisted(() => ({
11+
mockApp: {
12+
exit: vi.fn(),
13+
},
14+
mockContainer: {
15+
unbindAll: vi.fn(() => Promise.resolve()),
16+
},
17+
mockTrackAppEvent: vi.fn(),
18+
mockShutdownPostHog: vi.fn(() => Promise.resolve()),
19+
mockProcessExit: vi.fn() as unknown as (code?: number) => never,
20+
}));
1521

1622
vi.mock("electron", () => ({
1723
app: mockApp,
@@ -45,12 +51,20 @@ vi.mock("../../../types/analytics.js", () => ({
4551

4652
describe("AppLifecycleService", () => {
4753
let service: AppLifecycleService;
54+
const originalProcessExit = process.exit;
4855

4956
beforeEach(() => {
5057
vi.clearAllMocks();
58+
vi.useFakeTimers();
59+
process.exit = mockProcessExit;
5160
service = new AppLifecycleService();
5261
});
5362

63+
afterEach(() => {
64+
vi.useRealTimers();
65+
process.exit = originalProcessExit;
66+
});
67+
5468
describe("isQuittingForUpdate", () => {
5569
it("returns false by default", () => {
5670
expect(service.isQuittingForUpdate).toBe(false);
@@ -62,19 +76,38 @@ describe("AppLifecycleService", () => {
6276
});
6377
});
6478

79+
describe("isShuttingDown", () => {
80+
it("returns false by default", () => {
81+
expect(service.isShuttingDown).toBe(false);
82+
});
83+
84+
it("returns true after shutdown is called", async () => {
85+
const shutdownPromise = service.shutdown();
86+
expect(service.isShuttingDown).toBe(true);
87+
await vi.runAllTimersAsync();
88+
await shutdownPromise;
89+
});
90+
});
91+
6592
describe("shutdown", () => {
6693
it("unbinds all container services", async () => {
67-
await service.shutdown();
94+
const promise = service.shutdown();
95+
await vi.runAllTimersAsync();
96+
await promise;
6897
expect(mockContainer.unbindAll).toHaveBeenCalled();
6998
});
7099

71100
it("tracks app quit event", async () => {
72-
await service.shutdown();
101+
const promise = service.shutdown();
102+
await vi.runAllTimersAsync();
103+
await promise;
73104
expect(mockTrackAppEvent).toHaveBeenCalledWith("app_quit");
74105
});
75106

76107
it("shuts down PostHog", async () => {
77-
await service.shutdown();
108+
const promise = service.shutdown();
109+
await vi.runAllTimersAsync();
110+
await promise;
78111
expect(mockShutdownPostHog).toHaveBeenCalled();
79112
});
80113

@@ -91,7 +124,9 @@ describe("AppLifecycleService", () => {
91124
callOrder.push("shutdownPostHog");
92125
});
93126

94-
await service.shutdown();
127+
const promise = service.shutdown();
128+
await vi.runAllTimersAsync();
129+
await promise;
95130

96131
expect(callOrder).toEqual([
97132
"unbindAll",
@@ -103,7 +138,9 @@ describe("AppLifecycleService", () => {
103138
it("continues shutdown if container unbind fails", async () => {
104139
mockContainer.unbindAll.mockRejectedValue(new Error("unbind failed"));
105140

106-
await service.shutdown();
141+
const promise = service.shutdown();
142+
await vi.runAllTimersAsync();
143+
await promise;
107144

108145
expect(mockTrackAppEvent).toHaveBeenCalled();
109146
expect(mockShutdownPostHog).toHaveBeenCalled();
@@ -112,7 +149,28 @@ describe("AppLifecycleService", () => {
112149
it("continues shutdown if PostHog shutdown fails", async () => {
113150
mockShutdownPostHog.mockRejectedValue(new Error("posthog failed"));
114151

115-
await expect(service.shutdown()).resolves.toBeUndefined();
152+
const promise = service.shutdown();
153+
await vi.runAllTimersAsync();
154+
await expect(promise).resolves.toBeUndefined();
155+
});
156+
157+
it("force-exits on re-entrant shutdown call", async () => {
158+
const promise = service.shutdown();
159+
service.shutdown();
160+
expect(mockProcessExit).toHaveBeenCalledWith(1);
161+
await vi.runAllTimersAsync();
162+
await promise;
163+
});
164+
165+
it("force-exits when shutdown times out", async () => {
166+
mockContainer.unbindAll.mockReturnValue(new Promise(() => {}));
167+
168+
const promise = service.shutdown();
169+
170+
await vi.advanceTimersByTimeAsync(3000);
171+
await promise;
172+
173+
expect(mockProcessExit).toHaveBeenCalledWith(1);
116174
});
117175
});
118176

@@ -127,14 +185,18 @@ describe("AppLifecycleService", () => {
127185
callOrder.push("exit");
128186
});
129187

130-
await service.shutdownAndExit();
188+
const promise = service.shutdownAndExit();
189+
await vi.runAllTimersAsync();
190+
await promise;
131191

132192
expect(callOrder[0]).toBe("unbindAll");
133193
expect(callOrder[callOrder.length - 1]).toBe("exit");
134194
});
135195

136196
it("exits with code 0", async () => {
137-
await service.shutdownAndExit();
197+
const promise = service.shutdownAndExit();
198+
await vi.runAllTimersAsync();
199+
await promise;
138200
expect(mockApp.exit).toHaveBeenCalledWith(0);
139201
});
140202
});

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,48 +11,72 @@ const log = logger.scope("app-lifecycle");
1111
@injectable()
1212
export class AppLifecycleService {
1313
private _isQuittingForUpdate = false;
14+
private _isShuttingDown = false;
1415
private static readonly SHUTDOWN_TIMEOUT_MS = 3000;
1516

1617
get isQuittingForUpdate(): boolean {
1718
return this._isQuittingForUpdate;
1819
}
1920

21+
get isShuttingDown(): boolean {
22+
return this._isShuttingDown;
23+
}
24+
2025
setQuittingForUpdate(): void {
2126
this._isQuittingForUpdate = true;
2227
}
2328

29+
forceExit(): never {
30+
log.warn("Force-killing process");
31+
process.exit(1);
32+
}
33+
2434
async shutdown(): Promise<void> {
25-
// Race shutdown against timeout to prevent app from hanging forever
35+
if (this._isShuttingDown) {
36+
log.warn("Shutdown already in progress, forcing exit");
37+
this.forceExit();
38+
}
39+
40+
this._isShuttingDown = true;
41+
2642
const result = await withTimeout(
2743
this.doShutdown(),
2844
AppLifecycleService.SHUTDOWN_TIMEOUT_MS,
2945
);
3046

3147
if (result.result === "timeout") {
32-
log.warn("Shutdown timeout reached, proceeding anyway", {
48+
log.warn("Shutdown timeout reached, forcing exit", {
3349
timeoutMs: AppLifecycleService.SHUTDOWN_TIMEOUT_MS,
3450
});
51+
this.forceExit();
3552
}
3653
}
3754

3855
private async doShutdown(): Promise<void> {
56+
log.info("Shutdown started: unbinding container");
3957
try {
4058
await container.unbindAll();
59+
log.info("Container unbound successfully");
4160
} catch (error) {
4261
log.error("Failed to unbind container", error);
4362
}
4463

4564
trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);
4665

66+
log.info("Shutting down PostHog");
4767
try {
4868
await shutdownPostHog();
69+
log.info("PostHog shutdown complete");
4970
} catch (error) {
5071
log.error("Failed to shutdown PostHog", error);
5172
}
73+
74+
log.info("Graceful shutdown complete");
5275
}
5376

5477
async shutdownAndExit(): Promise<void> {
5578
await this.shutdown();
79+
log.info("Calling app.exit(0)");
5680
app.exit(0);
5781
}
5882
}

0 commit comments

Comments
 (0)