Skip to content

Commit 844e1f4

Browse files
authored
feat: Implement AppLifecycleService and refactor to fix auto updater (#530)
## Alright here's what I found in the cave... **the bug:** when `autoUpdater.quitAndInstall()` was invoked, it triggered the `before-quit` event, but the handler would intercept it, prevent the default behavior, and forcibly exit the app before the updater could complete its installation flow. **solution:** this PR introduces a new `AppLifecycleService` that centralizes all shutdown logic and tracks an `isQuittingForUpdate` flag. Now when installing an update, the `UpdatesService` first calls `lifecycleService.shutdown()` to gracefully clean up, then sets `lifecycleService.setQuittingForUpdate()` before calling `quitAndInstall()`. The `before-quit` handler now checks this flag and returns early if set, allowing the auto updater to proceed unblocked. This separates the "normal quit with cleanup" path from the "quit for update" path ## NOTE: There is a different event sequence for `quitAndInstall()` _Excerpt taken from electron docs_ The Event Sequence for `quitAndInstall()` **Normal quit:** 1. `before-quit` emitted 2. Windows receive `close` events 3. Windows close 4. `will-quit` emitted 5. App exits **`quitAndInstall()`** **sequence (different!):** 1. All windows receive `close` events first 2. Windows close 3. `before-quit` emitted **after** windows are closed 4. Installer runs
1 parent 6254975 commit 844e1f4

7 files changed

Lines changed: 261 additions & 22 deletions

File tree

apps/twig/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import "reflect-metadata";
22
import { Container } from "inversify";
33
import { AgentService } from "../services/agent/service.js";
4+
import { AppLifecycleService } from "../services/app-lifecycle/service.js";
45
import { ConnectivityService } from "../services/connectivity/service.js";
56
import { ContextMenuService } from "../services/context-menu/service.js";
67
import { DeepLinkService } from "../services/deep-link/service.js";
@@ -23,6 +24,7 @@ export const container = new Container({
2324
});
2425

2526
container.bind(MAIN_TOKENS.AgentService).to(AgentService);
27+
container.bind(MAIN_TOKENS.AppLifecycleService).to(AppLifecycleService);
2628
container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService);
2729
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
2830
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);

apps/twig/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
export const MAIN_TOKENS = Object.freeze({
88
// Services
99
AgentService: Symbol.for("Main.AgentService"),
10+
AppLifecycleService: Symbol.for("Main.AppLifecycleService"),
1011
ConnectivityService: Symbol.for("Main.ConnectivityService"),
1112
ContextMenuService: Symbol.for("Main.ContextMenuService"),
1213
DockBadgeService: Symbol.for("Main.DockBadgeService"),

apps/twig/src/main/index.ts

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,18 @@ import "./lib/logger";
2727
import { ANALYTICS_EVENTS } from "../types/analytics.js";
2828
import { container } from "./di/container.js";
2929
import { MAIN_TOKENS } from "./di/tokens.js";
30-
import type { AgentService } from "./services/agent/service.js";
3130
import type { DockBadgeService } from "./services/dock-badge/service.js";
3231
import type { UIService } from "./services/ui/service.js";
3332
import { setMainWindowGetter } from "./trpc/context.js";
3433
import { trpcRouter } from "./trpc/index.js";
3534

3635
import "./services/index.js";
36+
import type { AppLifecycleService } from "./services/app-lifecycle/service.js";
3737
import type { DeepLinkService } from "./services/deep-link/service.js";
3838
import type { ExternalAppsService } from "./services/external-apps/service.js";
3939
import type { OAuthService } from "./services/oauth/service.js";
4040
import {
4141
initializePostHog,
42-
shutdownPostHog,
4342
trackAppEvent,
4443
} from "./services/posthog-analytics.js";
4544
import type { TaskLinkService } from "./services/task-link/service";
@@ -365,19 +364,26 @@ app.whenReady().then(() => {
365364

366365
app.on("window-all-closed", async () => {
367366
if (process.platform !== "darwin") {
368-
trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);
369-
await shutdownPostHog();
367+
const lifecycleService = container.get<AppLifecycleService>(
368+
MAIN_TOKENS.AppLifecycleService,
369+
);
370+
await lifecycleService.shutdown();
370371
app.quit();
371372
}
372373
});
373374

374375
app.on("before-quit", async (event) => {
376+
const lifecycleService = container.get<AppLifecycleService>(
377+
MAIN_TOKENS.AppLifecycleService,
378+
);
379+
380+
// If quitting to install an update, don't block - let the updater handle it
381+
if (lifecycleService.isQuittingForUpdate) {
382+
return;
383+
}
384+
375385
event.preventDefault();
376-
const agentService = container.get<AgentService>(MAIN_TOKENS.AgentService);
377-
await agentService.cleanupAll();
378-
trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);
379-
await shutdownPostHog();
380-
app.exit(0);
386+
await lifecycleService.shutdownAndExit();
381387
});
382388

383389
app.on("activate", () => {
@@ -389,8 +395,10 @@ app.on("activate", () => {
389395
// Handle process signals to ensure clean shutdown
390396
const handleShutdownSignal = async (_signal: string) => {
391397
try {
392-
const agentService = container.get<AgentService>(MAIN_TOKENS.AgentService);
393-
await agentService.cleanupAll();
398+
const lifecycleService = container.get<AppLifecycleService>(
399+
MAIN_TOKENS.AppLifecycleService,
400+
);
401+
await lifecycleService.shutdown();
394402
} catch (_err) {}
395403
process.exit(0);
396404
};
@@ -402,16 +410,20 @@ process.on("SIGHUP", () => handleShutdownSignal("SIGHUP"));
402410
// Handle uncaught exceptions to attempt cleanup before crash
403411
process.on("uncaughtException", async (_error) => {
404412
try {
405-
const agentService = container.get<AgentService>(MAIN_TOKENS.AgentService);
406-
await agentService.cleanupAll();
413+
const lifecycleService = container.get<AppLifecycleService>(
414+
MAIN_TOKENS.AppLifecycleService,
415+
);
416+
await lifecycleService.shutdown();
407417
} catch (_cleanupErr) {}
408418
process.exit(1);
409419
});
410420

411421
process.on("unhandledRejection", async (_reason) => {
412422
try {
413-
const agentService = container.get<AgentService>(MAIN_TOKENS.AgentService);
414-
await agentService.cleanupAll();
423+
const lifecycleService = container.get<AppLifecycleService>(
424+
MAIN_TOKENS.AppLifecycleService,
425+
);
426+
await lifecycleService.shutdown();
415427
} catch (_cleanupErr) {}
416428
process.exit(1);
417429
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { AppLifecycleService } from "./service.js";
3+
4+
const { mockApp, mockAgentService, mockTrackAppEvent, mockShutdownPostHog } =
5+
vi.hoisted(() => ({
6+
mockApp: {
7+
exit: vi.fn(),
8+
},
9+
mockAgentService: {
10+
cleanupAll: vi.fn(() => Promise.resolve()),
11+
},
12+
mockTrackAppEvent: vi.fn(),
13+
mockShutdownPostHog: vi.fn(() => Promise.resolve()),
14+
}));
15+
16+
vi.mock("electron", () => ({
17+
app: mockApp,
18+
}));
19+
20+
vi.mock("../../lib/logger.js", () => ({
21+
logger: {
22+
scope: () => ({
23+
info: vi.fn(),
24+
error: vi.fn(),
25+
warn: vi.fn(),
26+
debug: vi.fn(),
27+
}),
28+
},
29+
}));
30+
31+
vi.mock("../posthog-analytics.js", () => ({
32+
trackAppEvent: mockTrackAppEvent,
33+
shutdownPostHog: mockShutdownPostHog,
34+
}));
35+
36+
vi.mock("../../di/tokens.js", () => ({
37+
MAIN_TOKENS: {
38+
AgentService: Symbol.for("AgentService"),
39+
},
40+
}));
41+
42+
vi.mock("../../../types/analytics.js", () => ({
43+
ANALYTICS_EVENTS: {
44+
APP_QUIT: "app_quit",
45+
},
46+
}));
47+
48+
describe("AppLifecycleService", () => {
49+
let service: AppLifecycleService;
50+
51+
beforeEach(() => {
52+
vi.clearAllMocks();
53+
54+
service = new AppLifecycleService();
55+
(
56+
service as unknown as { agentService: typeof mockAgentService }
57+
).agentService = mockAgentService;
58+
});
59+
60+
describe("isQuittingForUpdate", () => {
61+
it("returns false by default", () => {
62+
expect(service.isQuittingForUpdate).toBe(false);
63+
});
64+
65+
it("returns true after setQuittingForUpdate is called", () => {
66+
service.setQuittingForUpdate();
67+
expect(service.isQuittingForUpdate).toBe(true);
68+
});
69+
});
70+
71+
describe("shutdown", () => {
72+
it("cleans up agents", async () => {
73+
await service.shutdown();
74+
expect(mockAgentService.cleanupAll).toHaveBeenCalled();
75+
});
76+
77+
it("tracks app quit event", async () => {
78+
await service.shutdown();
79+
expect(mockTrackAppEvent).toHaveBeenCalledWith("app_quit");
80+
});
81+
82+
it("shuts down PostHog", async () => {
83+
await service.shutdown();
84+
expect(mockShutdownPostHog).toHaveBeenCalled();
85+
});
86+
87+
it("calls cleanup steps in order", async () => {
88+
const callOrder: string[] = [];
89+
90+
mockAgentService.cleanupAll.mockImplementation(async () => {
91+
callOrder.push("cleanupAll");
92+
});
93+
mockTrackAppEvent.mockImplementation(() => {
94+
callOrder.push("trackAppEvent");
95+
});
96+
mockShutdownPostHog.mockImplementation(async () => {
97+
callOrder.push("shutdownPostHog");
98+
});
99+
100+
await service.shutdown();
101+
102+
expect(callOrder).toEqual([
103+
"cleanupAll",
104+
"trackAppEvent",
105+
"shutdownPostHog",
106+
]);
107+
});
108+
109+
it("continues shutdown if agent cleanup fails", async () => {
110+
mockAgentService.cleanupAll.mockRejectedValue(
111+
new Error("cleanup failed"),
112+
);
113+
114+
await service.shutdown();
115+
116+
expect(mockTrackAppEvent).toHaveBeenCalled();
117+
expect(mockShutdownPostHog).toHaveBeenCalled();
118+
});
119+
120+
it("continues shutdown if PostHog shutdown fails", async () => {
121+
mockShutdownPostHog.mockRejectedValue(new Error("posthog failed"));
122+
123+
// Should not throw
124+
await expect(service.shutdown()).resolves.toBeUndefined();
125+
});
126+
});
127+
128+
describe("shutdownAndExit", () => {
129+
it("calls shutdown before exit", async () => {
130+
const callOrder: string[] = [];
131+
132+
mockAgentService.cleanupAll.mockImplementation(async () => {
133+
callOrder.push("cleanupAll");
134+
});
135+
mockApp.exit.mockImplementation(() => {
136+
callOrder.push("exit");
137+
});
138+
139+
await service.shutdownAndExit();
140+
141+
expect(callOrder[0]).toBe("cleanupAll");
142+
expect(callOrder[callOrder.length - 1]).toBe("exit");
143+
});
144+
145+
it("exits with code 0", async () => {
146+
await service.shutdownAndExit();
147+
expect(mockApp.exit).toHaveBeenCalledWith(0);
148+
});
149+
});
150+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { app } from "electron";
2+
import { inject, injectable } from "inversify";
3+
import { ANALYTICS_EVENTS } from "../../../types/analytics.js";
4+
import { MAIN_TOKENS } from "../../di/tokens.js";
5+
import { logger } from "../../lib/logger.js";
6+
import type { AgentService } from "../agent/service.js";
7+
import { shutdownPostHog, trackAppEvent } from "../posthog-analytics.js";
8+
9+
const log = logger.scope("app-lifecycle");
10+
11+
@injectable()
12+
export class AppLifecycleService {
13+
@inject(MAIN_TOKENS.AgentService)
14+
private agentService!: AgentService;
15+
16+
private _isQuittingForUpdate = false;
17+
18+
get isQuittingForUpdate(): boolean {
19+
return this._isQuittingForUpdate;
20+
}
21+
22+
setQuittingForUpdate(): void {
23+
this._isQuittingForUpdate = true;
24+
}
25+
26+
async shutdown(): Promise<void> {
27+
log.info("Performing graceful shutdown...");
28+
29+
try {
30+
await this.agentService.cleanupAll();
31+
} catch (error) {
32+
log.error("Error cleaning up agents during shutdown", error);
33+
}
34+
35+
trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);
36+
37+
try {
38+
await shutdownPostHog();
39+
} catch (error) {
40+
log.error("Error shutting down PostHog", error);
41+
}
42+
43+
log.info("Graceful shutdown complete");
44+
}
45+
46+
async shutdownAndExit(): Promise<void> {
47+
await this.shutdown();
48+
app.exit(0);
49+
}
50+
}

apps/twig/src/main/services/updates/service.test.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import { UpdatesEvent } from "./schemas.js";
33

44
// Use vi.hoisted to ensure mocks are available when vi.mock is hoisted
5-
const { mockApp, mockAutoUpdater } = vi.hoisted(() => ({
5+
const { mockApp, mockAutoUpdater, mockLifecycleService } = vi.hoisted(() => ({
66
mockAutoUpdater: {
77
setFeedURL: vi.fn(),
88
checkForUpdates: vi.fn(),
@@ -15,6 +15,10 @@ const { mockApp, mockAutoUpdater } = vi.hoisted(() => ({
1515
on: vi.fn(),
1616
whenReady: vi.fn(() => Promise.resolve()),
1717
},
18+
mockLifecycleService: {
19+
shutdown: vi.fn(() => Promise.resolve()),
20+
setQuittingForUpdate: vi.fn(),
21+
},
1822
}));
1923

2024
vi.mock("electron", () => ({
@@ -33,6 +37,12 @@ vi.mock("../../lib/logger.js", () => ({
3337
},
3438
}));
3539

40+
vi.mock("../../di/tokens.js", () => ({
41+
MAIN_TOKENS: {
42+
AppLifecycleService: Symbol.for("AppLifecycleService"),
43+
},
44+
}));
45+
3646
// Import the service after mocks are set up
3747
import { UpdatesService } from "./service.js";
3848

@@ -72,6 +82,10 @@ describe("UpdatesService", () => {
7282
delete process.env.ELECTRON_DISABLE_AUTO_UPDATE;
7383

7484
service = new UpdatesService();
85+
// Manually inject the mock lifecycle service (normally done by DI container)
86+
(
87+
service as unknown as { lifecycleService: typeof mockLifecycleService }
88+
).lifecycleService = mockLifecycleService;
7589
});
7690

7791
afterEach(() => {
@@ -292,8 +306,8 @@ describe("UpdatesService", () => {
292306
});
293307

294308
describe("installUpdate", () => {
295-
it("returns false when no update is ready", () => {
296-
const result = service.installUpdate();
309+
it("returns false when no update is ready", async () => {
310+
const result = await service.installUpdate();
297311
expect(result).toEqual({ installed: false });
298312
});
299313

@@ -309,8 +323,10 @@ describe("UpdatesService", () => {
309323
updateDownloadedHandler({}, "Release notes", "v2.0.0");
310324
}
311325

312-
const result = service.installUpdate();
326+
const result = await service.installUpdate();
313327
expect(result).toEqual({ installed: true });
328+
expect(mockLifecycleService.shutdown).toHaveBeenCalled();
329+
expect(mockLifecycleService.setQuittingForUpdate).toHaveBeenCalled();
314330
expect(mockAutoUpdater.quitAndInstall).toHaveBeenCalled();
315331
});
316332

@@ -330,7 +346,7 @@ describe("UpdatesService", () => {
330346
throw new Error("Failed to install");
331347
});
332348

333-
const result = service.installUpdate();
349+
const result = await service.installUpdate();
334350
expect(result).toEqual({ installed: false });
335351
});
336352
});

0 commit comments

Comments
 (0)