Skip to content

Commit eb679c4

Browse files
authored
fix: properly shutdown all services using preDestroy and unbindAll (#646)
1 parent e04ca1a commit eb679c4

9 files changed

Lines changed: 61 additions & 50 deletions

File tree

apps/twig/src/main/services/agent/service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from "@agentclientprotocol/sdk";
1313
import { Agent, getLlmGatewayUrl, type OnLogCallback } from "@posthog/agent";
1414
import { app } from "electron";
15-
import { injectable } from "inversify";
15+
import { injectable, preDestroy } from "inversify";
1616
import type { AcpMessage } from "../../../shared/types/session-events.js";
1717
import { logger } from "../../lib/logger.js";
1818
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
@@ -748,6 +748,7 @@ For git operations while detached:
748748
return `Your worktree is back on branch \`${context.branchName}\`. Normal git commands work again.`;
749749
}
750750

751+
@preDestroy()
751752
async cleanupAll(): Promise<void> {
752753
log.info("Cleaning up all agent sessions", {
753754
sessionCount: this.sessions.size,

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

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { AppLifecycleService } from "./service.js";
33

4-
const { mockApp, mockAgentService, mockTrackAppEvent, mockShutdownPostHog } =
4+
const { mockApp, mockContainer, mockTrackAppEvent, mockShutdownPostHog } =
55
vi.hoisted(() => ({
66
mockApp: {
77
exit: vi.fn(),
88
},
9-
mockAgentService: {
10-
cleanupAll: vi.fn(() => Promise.resolve()),
9+
mockContainer: {
10+
unbindAll: vi.fn(() => Promise.resolve()),
1111
},
1212
mockTrackAppEvent: vi.fn(),
1313
mockShutdownPostHog: vi.fn(() => Promise.resolve()),
@@ -33,10 +33,8 @@ vi.mock("../posthog-analytics.js", () => ({
3333
shutdownPostHog: mockShutdownPostHog,
3434
}));
3535

36-
vi.mock("../../di/tokens.js", () => ({
37-
MAIN_TOKENS: {
38-
AgentService: Symbol.for("AgentService"),
39-
},
36+
vi.mock("../../di/container.js", () => ({
37+
container: mockContainer,
4038
}));
4139

4240
vi.mock("../../../types/analytics.js", () => ({
@@ -50,11 +48,7 @@ describe("AppLifecycleService", () => {
5048

5149
beforeEach(() => {
5250
vi.clearAllMocks();
53-
5451
service = new AppLifecycleService();
55-
(
56-
service as unknown as { agentService: typeof mockAgentService }
57-
).agentService = mockAgentService;
5852
});
5953

6054
describe("isQuittingForUpdate", () => {
@@ -69,9 +63,9 @@ describe("AppLifecycleService", () => {
6963
});
7064

7165
describe("shutdown", () => {
72-
it("cleans up agents", async () => {
66+
it("unbinds all container services", async () => {
7367
await service.shutdown();
74-
expect(mockAgentService.cleanupAll).toHaveBeenCalled();
68+
expect(mockContainer.unbindAll).toHaveBeenCalled();
7569
});
7670

7771
it("tracks app quit event", async () => {
@@ -87,8 +81,8 @@ describe("AppLifecycleService", () => {
8781
it("calls cleanup steps in order", async () => {
8882
const callOrder: string[] = [];
8983

90-
mockAgentService.cleanupAll.mockImplementation(async () => {
91-
callOrder.push("cleanupAll");
84+
mockContainer.unbindAll.mockImplementation(async () => {
85+
callOrder.push("unbindAll");
9286
});
9387
mockTrackAppEvent.mockImplementation(() => {
9488
callOrder.push("trackAppEvent");
@@ -100,16 +94,14 @@ describe("AppLifecycleService", () => {
10094
await service.shutdown();
10195

10296
expect(callOrder).toEqual([
103-
"cleanupAll",
97+
"unbindAll",
10498
"trackAppEvent",
10599
"shutdownPostHog",
106100
]);
107101
});
108102

109-
it("continues shutdown if agent cleanup fails", async () => {
110-
mockAgentService.cleanupAll.mockRejectedValue(
111-
new Error("cleanup failed"),
112-
);
103+
it("continues shutdown if container unbind fails", async () => {
104+
mockContainer.unbindAll.mockRejectedValue(new Error("unbind failed"));
113105

114106
await service.shutdown();
115107

@@ -120,7 +112,6 @@ describe("AppLifecycleService", () => {
120112
it("continues shutdown if PostHog shutdown fails", async () => {
121113
mockShutdownPostHog.mockRejectedValue(new Error("posthog failed"));
122114

123-
// Should not throw
124115
await expect(service.shutdown()).resolves.toBeUndefined();
125116
});
126117
});
@@ -129,16 +120,16 @@ describe("AppLifecycleService", () => {
129120
it("calls shutdown before exit", async () => {
130121
const callOrder: string[] = [];
131122

132-
mockAgentService.cleanupAll.mockImplementation(async () => {
133-
callOrder.push("cleanupAll");
123+
mockContainer.unbindAll.mockImplementation(async () => {
124+
callOrder.push("unbindAll");
134125
});
135126
mockApp.exit.mockImplementation(() => {
136127
callOrder.push("exit");
137128
});
138129

139130
await service.shutdownAndExit();
140131

141-
expect(callOrder[0]).toBe("cleanupAll");
132+
expect(callOrder[0]).toBe("unbindAll");
142133
expect(callOrder[callOrder.length - 1]).toBe("exit");
143134
});
144135

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

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
import { app } from "electron";
2-
import { inject, injectable } from "inversify";
2+
import { injectable } from "inversify";
33
import { ANALYTICS_EVENTS } from "../../../types/analytics.js";
4-
import { MAIN_TOKENS } from "../../di/tokens.js";
4+
import { container } from "../../di/container.js";
55
import { logger } from "../../lib/logger.js";
6-
import type { AgentService } from "../agent/service.js";
76
import { shutdownPostHog, trackAppEvent } from "../posthog-analytics.js";
8-
import type { ShellService } from "../shell/service.js";
97

108
const log = logger.scope("app-lifecycle");
119

1210
@injectable()
1311
export class AppLifecycleService {
14-
@inject(MAIN_TOKENS.AgentService)
15-
private agentService!: AgentService;
16-
17-
@inject(MAIN_TOKENS.ShellService)
18-
private shellService!: ShellService;
19-
2012
private _isQuittingForUpdate = false;
2113

2214
get isQuittingForUpdate(): boolean {
@@ -31,15 +23,9 @@ export class AppLifecycleService {
3123
log.info("Performing graceful shutdown...");
3224

3325
try {
34-
this.shellService.destroyAll();
35-
} catch (error) {
36-
log.error("Error cleaning up ShellService during shutdown", error);
37-
}
38-
39-
try {
40-
await this.agentService.cleanupAll();
26+
await container.unbindAll();
4127
} catch (error) {
42-
log.error("Error cleaning up agents during shutdown", error);
28+
log.error("Error during container unbind", error);
4329
}
4430

4531
trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);

apps/twig/src/main/services/connectivity/service.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { net } from "electron";
2-
import { injectable, postConstruct } from "inversify";
2+
import { injectable, postConstruct, preDestroy } from "inversify";
33
import { getBackoffDelay } from "../../../shared/utils/backoff.js";
44
import { logger } from "../../lib/logger.js";
55
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
@@ -102,4 +102,12 @@ export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> {
102102
this.schedulePoll();
103103
}, interval);
104104
}
105+
106+
@preDestroy()
107+
stopPolling(): void {
108+
if (this.pollTimeoutId) {
109+
clearTimeout(this.pollTimeoutId);
110+
this.pollTimeoutId = null;
111+
}
112+
}
105113
}

apps/twig/src/main/services/file-watcher/service.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
33
import path from "node:path";
44
import * as watcher from "@parcel/watcher";
55
import { app } from "electron";
6-
import { injectable } from "inversify";
6+
import { injectable, preDestroy } from "inversify";
77
import { logger } from "../../lib/logger.js";
88
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
99
import {
@@ -89,6 +89,15 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {
8989
this.watchers.delete(repoPath);
9090
}
9191

92+
@preDestroy()
93+
async shutdown(): Promise<void> {
94+
log.info("Shutting down file watcher service", {
95+
watcherCount: this.watchers.size,
96+
});
97+
const repoPaths = Array.from(this.watchers.keys());
98+
await Promise.all(repoPaths.map((repoPath) => this.stopWatching(repoPath)));
99+
}
100+
92101
private get snapshotsDir(): string {
93102
return path.join(app.getPath("userData"), "snapshots");
94103
}

apps/twig/src/main/services/focus/service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
33
import * as path from "node:path";
44
import { promisify } from "node:util";
55
import * as watcher from "@parcel/watcher";
6-
import { injectable } from "inversify";
6+
import { injectable, preDestroy } from "inversify";
77
import { logger } from "../../lib/logger";
88
import { TypedEventEmitter } from "../../lib/typed-event-emitter";
99
import { type FocusSession, focusStore } from "../../utils/store.js";
@@ -108,6 +108,7 @@ export class FocusService extends TypedEventEmitter<FocusServiceEvents> {
108108
});
109109
}
110110

111+
@preDestroy()
111112
async stopWatchingMainRepo(): Promise<void> {
112113
if (this.mainRepoWatcher) {
113114
await this.mainRepoWatcher.unsubscribe();

apps/twig/src/main/services/focus/sync-service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
22
import path from "node:path";
33
import * as watcher from "@parcel/watcher";
44
import ignore, { type Ignore } from "ignore";
5-
import { injectable } from "inversify";
5+
import { injectable, preDestroy } from "inversify";
66
import { logger } from "../../lib/logger.js";
77
import { git, withGitLock } from "./service.js";
88

@@ -149,6 +149,7 @@ export class FocusSyncService {
149149
}
150150
}
151151

152+
@preDestroy()
152153
async stopSync(): Promise<void> {
153154
if (this.pending.timer) {
154155
clearTimeout(this.pending.timer);

apps/twig/src/main/services/shell/service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { exec, execSync } from "node:child_process";
22
import { existsSync } from "node:fs";
33
import { homedir, platform } from "node:os";
44
import path from "node:path";
5-
import { injectable } from "inversify";
5+
import { injectable, preDestroy } from "inversify";
66
import * as pty from "node-pty";
77
import { logger } from "../../lib/logger.js";
88
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
@@ -215,6 +215,7 @@ export class ShellService extends TypedEventEmitter<ShellEvents> {
215215
* Destroy all active shell sessions.
216216
* Used during application shutdown to ensure all child processes are cleaned up.
217217
*/
218+
@preDestroy()
218219
destroyAll(): void {
219220
log.info(`Destroying all shell sessions (${this.sessions.size} active)`);
220221
for (const sessionId of this.sessions.keys()) {

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { app, autoUpdater } from "electron";
2-
import { inject, injectable, postConstruct } from "inversify";
2+
import { inject, injectable, postConstruct, preDestroy } from "inversify";
33
import { MAIN_TOKENS } from "../../di/tokens.js";
44
import { logger } from "../../lib/logger.js";
55
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
@@ -30,6 +30,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> {
3030
private pendingNotification = false;
3131
private checkingForUpdates = false;
3232
private checkTimeoutId: ReturnType<typeof setTimeout> | null = null;
33+
private checkIntervalId: ReturnType<typeof setInterval> | null = null;
3334
private downloadedVersion: string | null = null;
3435
private initialized = false;
3536

@@ -155,7 +156,10 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> {
155156
this.performCheck();
156157

157158
// Set up periodic checks
158-
setInterval(() => this.performCheck(), UpdatesService.CHECK_INTERVAL_MS);
159+
this.checkIntervalId = setInterval(
160+
() => this.performCheck(),
161+
UpdatesService.CHECK_INTERVAL_MS,
162+
);
159163
}
160164

161165
private handleError(error: Error): void {
@@ -265,4 +269,13 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> {
265269
this.checkTimeoutId = null;
266270
}
267271
}
272+
273+
@preDestroy()
274+
shutdown(): void {
275+
this.clearCheckTimeout();
276+
if (this.checkIntervalId) {
277+
clearInterval(this.checkIntervalId);
278+
this.checkIntervalId = null;
279+
}
280+
}
268281
}

0 commit comments

Comments
 (0)