Skip to content

Commit a14e7b0

Browse files
authored
refactor: Consolidate shutdown paths and fix native resource teardown (#1080)
1 parent 462dbf2 commit a14e7b0

5 files changed

Lines changed: 84 additions & 98 deletions

File tree

apps/twig/src/main/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ app.on("before-quit", async (event) => {
9393

9494
// If shutdown is already in progress, force-kill immediately
9595
if (lifecycleService.isShuttingDown) {
96-
lifecycleService.forceExit();
96+
lifecycleService.forceKill();
9797
}
9898

9999
event.preventDefault();
@@ -113,7 +113,7 @@ app.on("before-quit", async (event) => {
113113
// Updates service not available, fall through to normal shutdown
114114
}
115115

116-
await lifecycleService.shutdownAndExit();
116+
await lifecycleService.gracefulExit();
117117
});
118118

119119
const handleShutdownSignal = async (signal: string) => {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ describe("AppLifecycleService", () => {
174174
});
175175
});
176176

177-
describe("shutdownAndExit", () => {
177+
describe("gracefulExit", () => {
178178
it("calls shutdown before exit", async () => {
179179
const callOrder: string[] = [];
180180

@@ -185,7 +185,7 @@ describe("AppLifecycleService", () => {
185185
callOrder.push("exit");
186186
});
187187

188-
const promise = service.shutdownAndExit();
188+
const promise = service.gracefulExit();
189189
await vi.runAllTimersAsync();
190190
await promise;
191191

@@ -194,7 +194,7 @@ describe("AppLifecycleService", () => {
194194
});
195195

196196
it("exits with code 0", async () => {
197-
const promise = service.shutdownAndExit();
197+
const promise = service.gracefulExit();
198198
await vi.runAllTimersAsync();
199199
await promise;
200200
expect(mockApp.exit).toHaveBeenCalledWith(0);

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

Lines changed: 66 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ const log = logger.scope("app-lifecycle");
1313

1414
@injectable()
1515
export class AppLifecycleService {
16+
private static readonly SHUTDOWN_TIMEOUT_MS = 3000;
17+
1618
private _isQuittingForUpdate = false;
1719
private _isShuttingDown = false;
18-
private static readonly SHUTDOWN_TIMEOUT_MS = 3000;
1920

2021
get isQuittingForUpdate(): boolean {
2122
return this._isQuittingForUpdate;
@@ -29,60 +30,21 @@ export class AppLifecycleService {
2930
this._isQuittingForUpdate = true;
3031
}
3132

32-
forceExit(): never {
33+
/**
34+
* Immediately kills the process. Used when shutdown is stuck or re-entrant.
35+
*/
36+
forceKill(): never {
3337
log.warn("Force-killing process");
3438
process.exit(1);
3539
}
3640

37-
async cleanupForUpdate(): Promise<void> {
38-
log.info("Cleanup for update started");
39-
40-
// Shut down watchers
41-
log.info("Shutting down native watchers");
42-
try {
43-
const watcherRegistry = container.get<WatcherRegistryService>(
44-
MAIN_TOKENS.WatcherRegistryService,
45-
);
46-
await watcherRegistry.shutdownAll();
47-
} catch (error) {
48-
log.warn("Failed to shutdown watcher registry", error);
49-
}
50-
51-
// Kill all tracked processes
52-
try {
53-
const processTracking = container.get<ProcessTrackingService>(
54-
MAIN_TOKENS.ProcessTrackingService,
55-
);
56-
const snapshot = await processTracking.getSnapshot(true);
57-
log.info("Process snapshot before update", {
58-
tracked: {
59-
shell: snapshot.tracked.shell.length,
60-
agent: snapshot.tracked.agent.length,
61-
child: snapshot.tracked.child.length,
62-
},
63-
});
64-
65-
if (
66-
snapshot.tracked.shell.length +
67-
snapshot.tracked.agent.length +
68-
snapshot.tracked.child.length >
69-
0
70-
) {
71-
log.info("Killing all tracked processes before update");
72-
processTracking.killAll();
73-
}
74-
} catch (error) {
75-
log.warn("Failed to kill processes before update", error);
76-
}
77-
78-
// Skip container unbind, PostHog shutdown - app is restarting anyway
79-
log.info("Cleanup for update complete");
80-
}
81-
41+
/**
42+
* Full graceful shutdown with timeout. Force-kills if already in progress or times out.
43+
*/
8244
async shutdown(): Promise<void> {
8345
if (this._isShuttingDown) {
8446
log.warn("Shutdown already in progress, forcing exit");
85-
this.forceExit();
47+
this.forceKill();
8648
}
8749

8850
this._isShuttingDown = true;
@@ -96,14 +58,57 @@ export class AppLifecycleService {
9658
log.warn("Shutdown timeout reached, forcing exit", {
9759
timeoutMs: AppLifecycleService.SHUTDOWN_TIMEOUT_MS,
9860
});
99-
this.forceExit();
61+
this.forceKill();
10062
}
10163
}
10264

65+
/**
66+
* Tears down watchers and processes but keeps the DI container alive
67+
* so the before-quit handler can still access services. Used before quitAndInstall.
68+
*/
69+
async shutdownWithoutContainer(): Promise<void> {
70+
log.info("Partial shutdown started (keeping container)");
71+
await this.teardownNativeResources();
72+
}
73+
74+
/**
75+
* Runs a full shutdown then exits the Electron app.
76+
*/
77+
async gracefulExit(): Promise<void> {
78+
await this.shutdown();
79+
app.exit(0);
80+
}
81+
82+
/**
83+
* Runs the full shutdown sequence: native resources, container, analytics.
84+
*/
10385
private async doShutdown(): Promise<void> {
10486
log.info("Shutdown started");
10587

106-
log.info("Shutting down native watchers first");
88+
await this.teardownNativeResources();
89+
90+
try {
91+
await container.unbindAll();
92+
} catch (error) {
93+
log.warn("Failed to unbind container", error);
94+
}
95+
96+
trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);
97+
98+
try {
99+
await shutdownPostHog();
100+
} catch (error) {
101+
log.warn("Failed to shutdown PostHog", error);
102+
}
103+
104+
log.info("Shutdown complete");
105+
}
106+
107+
/**
108+
* Shuts down file watchers and kills child processes, then drains the
109+
* event loop so pending native callbacks fire while JS is still alive.
110+
*/
111+
private async teardownNativeResources(): Promise<void> {
107112
try {
108113
const watcherRegistry = container.get<WatcherRegistryService>(
109114
MAIN_TOKENS.WatcherRegistryService,
@@ -118,54 +123,30 @@ export class AppLifecycleService {
118123
MAIN_TOKENS.ProcessTrackingService,
119124
);
120125
const snapshot = await processTracking.getSnapshot(true);
121-
log.info("Process snapshot at shutdown", {
126+
log.debug("Process snapshot", {
122127
tracked: {
123128
shell: snapshot.tracked.shell.length,
124129
agent: snapshot.tracked.agent.length,
125130
child: snapshot.tracked.child.length,
126131
},
127132
discovered: snapshot.discovered?.length ?? 0,
128-
untrackedDiscovered:
129-
snapshot.discovered?.filter((p) => !p.tracked).length ?? 0,
130133
});
131134

132-
if (
135+
const trackedCount =
133136
snapshot.tracked.shell.length +
134-
snapshot.tracked.agent.length +
135-
snapshot.tracked.child.length >
136-
0
137-
) {
138-
log.info("Killing all tracked processes before container unbind");
137+
snapshot.tracked.agent.length +
138+
snapshot.tracked.child.length;
139+
140+
if (trackedCount > 0) {
141+
log.info(`Killing ${trackedCount} tracked processes`);
139142
processTracking.killAll();
140143
}
141144
} catch (error) {
142-
log.warn("Failed to get process snapshot at shutdown", error);
145+
log.warn("Failed to kill tracked processes", error);
143146
}
144147

145-
log.info("Unbinding container");
146-
try {
147-
await container.unbindAll();
148-
log.info("Container unbound successfully");
149-
} catch (error) {
150-
log.error("Failed to unbind container", error);
151-
}
152-
153-
trackAppEvent(ANALYTICS_EVENTS.APP_QUIT);
154-
155-
log.info("Shutting down PostHog");
156-
try {
157-
await shutdownPostHog();
158-
log.info("PostHog shutdown complete");
159-
} catch (error) {
160-
log.error("Failed to shutdown PostHog", error);
161-
}
162-
163-
log.info("Graceful shutdown complete");
164-
}
165-
166-
async shutdownAndExit(): Promise<void> {
167-
await this.shutdown();
168-
log.info("Calling app.exit(0)");
169-
app.exit(0);
148+
// Drain pending native callbacks (e.g. @parcel/watcher ThreadSafeFunction)
149+
// so they fire while JS is still alive, not during FreeEnvironment teardown
150+
await new Promise((resolve) => setImmediate(resolve));
170151
}
171152
}

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const { mockApp, mockAutoUpdater, mockLifecycleService } = vi.hoisted(() => ({
1717
},
1818
mockLifecycleService: {
1919
shutdown: vi.fn(() => Promise.resolve()),
20-
cleanupForUpdate: vi.fn(() => Promise.resolve()),
20+
shutdownWithoutContainer: vi.fn(() => Promise.resolve()),
2121
setQuittingForUpdate: vi.fn(),
2222
},
2323
}));
@@ -360,24 +360,27 @@ describe("UpdatesService", () => {
360360
updateDownloadedHandler({}, "Release notes", "v2.0.0");
361361
}
362362

363-
const result = await service.installUpdate();
363+
const resultPromise = service.installUpdate();
364+
await vi.runOnlyPendingTimersAsync();
365+
const result = await resultPromise;
364366
expect(result).toEqual({ installed: true });
365367

366368
// Verify setQuittingForUpdate is called first
367369
expect(mockLifecycleService.setQuittingForUpdate).toHaveBeenCalled();
368370

369-
// Verify cleanupForUpdate is called (not full shutdown)
370-
expect(mockLifecycleService.cleanupForUpdate).toHaveBeenCalled();
371+
// Verify shutdownWithoutContainer is called (not full shutdown)
372+
expect(mockLifecycleService.shutdownWithoutContainer).toHaveBeenCalled();
371373
expect(mockLifecycleService.shutdown).not.toHaveBeenCalled();
372374

373375
// Verify quitAndInstall is called after cleanup
374376
expect(mockAutoUpdater.quitAndInstall).toHaveBeenCalled();
375377

376-
// Verify order: setQuittingForUpdate -> cleanupForUpdate -> quitAndInstall
378+
// Verify order: setQuittingForUpdate -> shutdownWithoutContainer -> quitAndInstall
377379
const setQuittingOrder =
378380
mockLifecycleService.setQuittingForUpdate.mock.invocationCallOrder[0];
379381
const cleanupOrder =
380-
mockLifecycleService.cleanupForUpdate.mock.invocationCallOrder[0];
382+
mockLifecycleService.shutdownWithoutContainer.mock
383+
.invocationCallOrder[0];
381384
const quitAndInstallOrder =
382385
mockAutoUpdater.quitAndInstall.mock.invocationCallOrder[0];
383386

@@ -401,7 +404,9 @@ describe("UpdatesService", () => {
401404
throw new Error("Failed to install");
402405
});
403406

404-
const result = await service.installUpdate();
407+
const resultPromise = service.installUpdate();
408+
await vi.runOnlyPendingTimersAsync();
409+
const result = await resultPromise;
405410
expect(result).toEqual({ installed: false });
406411
});
407412
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> {
126126

127127
// Do lightweight cleanup: kill processes, shut down watchers
128128
// Skip container teardown so before-quit handler can still access services
129-
await this.lifecycleService.cleanupForUpdate();
129+
await this.lifecycleService.shutdownWithoutContainer();
130130

131131
autoUpdater.quitAndInstall();
132132
return { installed: true };

0 commit comments

Comments
 (0)