authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-19 04:42:30-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 04:42:30-04:00
log24c4ba68f536d56e8482a1e4d7070a6771da551d
tree2aba3323a27ce8b9b5bdb85b0e324c7b5e47d7f6
parent8bbbab50514910040d2a4fa2be5021c83134afcf
signature Signed by PGP key B5690EEEBB952194

fix(desktop): close the window before quit cleanup (#6562)


4 files changed, 253 insertions(+), 93 deletions(-)

apps/desktop/src/app/DesktopLifecycle.test.ts+187-68
......@@ -1,4 +1,5 @@
11import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
45import * as Ref from "effect/Ref";
......@@ -7,91 +8,110 @@ import type * as Electron from "electron";
78
89import * as ElectronApp from "../electron/ElectronApp.ts";
910import * as ElectronTheme from "../electron/ElectronTheme.ts";
11import * as ElectronWindow from "../electron/ElectronWindow.ts";
1012import * as DesktopEnvironment from "./DesktopEnvironment.ts";
1113import * as DesktopLifecycle from "./DesktopLifecycle.ts";
1214import * as DesktopShutdown from "./DesktopShutdown.ts";
1315import * as DesktopState from "./DesktopState.ts";
1416import * as DesktopWindow from "../window/DesktopWindow.ts";
1517
18function makeElectronAppLayer(
19 appListeners: Map<string, (...args: readonly unknown[]) => void>,
20 quit: Effect.Effect<void> = Effect.void,
21) {
22 const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) =>
23 Effect.acquireRelease(
24 Effect.sync(() => {
25 appListeners.set(eventName, listener);
26 }),
27 () =>
28 Effect.sync(() => {
29 appListeners.delete(eventName);
30 }),
31 ).pipe(Effect.asVoid);
32
33 return Layer.succeed(ElectronApp.ElectronApp, {
34 metadata: Effect.die("unexpected metadata read"),
35 name: Effect.succeed("T3 Code"),
36 systemLocale: Effect.succeed("en-US"),
37 whenReady: Effect.void,
38 quit,
39 exit: () => Effect.void,
40 relaunch: () => Effect.void,
41 setPath: () => Effect.void,
42 setName: () => Effect.void,
43 setAboutPanelOptions: () => Effect.void,
44 setAppUserModelId: () => Effect.void,
45 getAppMetrics: Effect.succeed([]),
46 isDefaultProtocolClient: () => Effect.succeed(false),
47 setAsDefaultProtocolClient: () => Effect.succeed(true),
48 setDesktopName: () => Effect.void,
49 setDockIcon: () => Effect.void,
50 appendCommandLineSwitch: () => Effect.void,
51 removeCommandLineSwitch: () => Effect.void,
52 onBeforeQuitForUpdate: (listener) => registerListener("before-quit-for-update", listener),
53 on: (eventName, listener) =>
54 registerListener(eventName, listener as unknown as (...args: readonly unknown[]) => void),
55 } satisfies ElectronApp.ElectronApp["Service"]);
56}
57
58const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
59 shouldUseDarkColors: Effect.succeed(false),
60 setSource: () => Effect.void,
61 onUpdated: () => Effect.void,
62});
63
64function makeElectronWindowLayer(destroyAll: Effect.Effect<void> = Effect.void) {
65 return Layer.succeed(ElectronWindow.ElectronWindow, {
66 create: () => Effect.die("unexpected window creation"),
67 main: Effect.die("unexpected main window read"),
68 currentMainOrFirst: Effect.die("unexpected current window read"),
69 focusedMainOrFirst: Effect.die("unexpected focused window read"),
70 setMain: () => Effect.void,
71 clearMain: () => Effect.void,
72 reveal: () => Effect.void,
73 sendAll: () => Effect.void,
74 destroyAll,
75 syncAllAppearance: () => Effect.void,
76 });
77}
78
79function makeDesktopWindowLayer(
80 input: {
81 readonly activate?: Effect.Effect<void>;
82 readonly flushMainWindowBounds?: Effect.Effect<void>;
83 } = {},
84) {
85 return Layer.succeed(DesktopWindow.DesktopWindow, {
86 createMain: Effect.die("unexpected window creation"),
87 ensureMain: Effect.die("unexpected window creation"),
88 revealOrCreateMain: Effect.die("unexpected window creation"),
89 activate: input.activate ?? Effect.void,
90 createMainIfBackendReady: Effect.void,
91 showConnectingSplash: Effect.void,
92 handleBackendReady: () => Effect.void,
93 handleBackendNotReady: Effect.void,
94 flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void,
95 dispatchMenuAction: () => Effect.void,
96 zoomMain: () => Effect.void,
97 syncAppearance: Effect.void,
98 });
99}
100
16101describe("DesktopLifecycle", () => {
17102 for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) {
18103 it.effect(`lets the updater's quit event proceed on ${platform}`, () => {
19104 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
20
21 const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
22 metadata: Effect.die("unexpected metadata read"),
23 name: Effect.succeed("T3 Code"),
24 systemLocale: Effect.succeed("en-US"),
25 whenReady: Effect.void,
26 quit: Effect.void,
27 exit: () => Effect.void,
28 relaunch: () => Effect.void,
29 setPath: () => Effect.void,
30 setName: () => Effect.void,
31 setAboutPanelOptions: () => Effect.void,
32 setAppUserModelId: () => Effect.void,
33 getAppMetrics: Effect.succeed([]),
34 isDefaultProtocolClient: () => Effect.succeed(false),
35 setAsDefaultProtocolClient: () => Effect.succeed(true),
36 setDesktopName: () => Effect.void,
37 setDockIcon: () => Effect.void,
38 appendCommandLineSwitch: () => Effect.void,
39 removeCommandLineSwitch: () => Effect.void,
40 onBeforeQuitForUpdate: (listener) =>
41 Effect.acquireRelease(
42 Effect.sync(() => {
43 appListeners.set("before-quit-for-update", listener);
44 }),
45 () =>
46 Effect.sync(() => {
47 appListeners.delete("before-quit-for-update");
48 }),
49 ).pipe(Effect.asVoid),
50 on: (eventName, listener) =>
51 Effect.acquireRelease(
52 Effect.sync(() => {
53 appListeners.set(
54 eventName,
55 listener as unknown as (...args: readonly unknown[]) => void,
56 );
57 }),
58 () =>
59 Effect.sync(() => {
60 appListeners.delete(eventName);
61 }),
62 ).pipe(Effect.asVoid),
63 } satisfies ElectronApp.ElectronApp["Service"]);
64
65 const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
66 shouldUseDarkColors: Effect.succeed(false),
67 setSource: () => Effect.void,
68 onUpdated: () => Effect.void,
69 });
70
71 const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, {
72 createMain: Effect.die("unexpected window creation"),
73 ensureMain: Effect.die("unexpected window creation"),
74 revealOrCreateMain: Effect.die("unexpected window creation"),
75 activate: Effect.void,
76 createMainIfBackendReady: Effect.void,
77 showConnectingSplash: Effect.void,
78 handleBackendReady: () => Effect.void,
79 handleBackendNotReady: Effect.void,
80 flushMainWindowBounds: Effect.void,
81 dispatchMenuAction: () => Effect.void,
82 zoomMain: () => Effect.void,
83 syncAppearance: Effect.void,
84 });
85
86105 const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
87106 platform,
88107 isDevelopment: false,
89108 } as DesktopEnvironment.DesktopEnvironment["Service"]);
90109
91110 const layer = DesktopLifecycle.layer.pipe(
92 Layer.provideMerge(electronAppLayer),
111 Layer.provideMerge(makeElectronAppLayer(appListeners)),
93112 Layer.provideMerge(electronThemeLayer),
94 Layer.provideMerge(desktopWindowLayer),
113 Layer.provideMerge(makeElectronWindowLayer()),
114 Layer.provideMerge(makeDesktopWindowLayer()),
95115 Layer.provideMerge(environmentLayer),
96116 Layer.provideMerge(DesktopShutdown.layer),
97117 Layer.provideMerge(DesktopState.layer),
......@@ -123,4 +143,103 @@ describe("DesktopLifecycle", () => {
123143 ).pipe(Effect.provide(layer));
124144 });
125145 }
146
147 it.effect("destroys windows before waiting for backend shutdown", () =>
148 Effect.gen(function* () {
149 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
150 const shutdownRequested = yield* Deferred.make<void>();
151 const allowShutdown = yield* Deferred.make<void>();
152 const quitRequested = yield* Deferred.make<void>();
153 const events: string[] = [];
154
155 const quit = Effect.sync(() => {
156 events.push("quit");
157 }).pipe(Effect.andThen(Deferred.succeed(quitRequested, undefined)), Effect.asVoid);
158 const destroyAll = Effect.sync(() => {
159 events.push("destroy");
160 });
161 const flushMainWindowBounds = Effect.sync(() => {
162 events.push("flush");
163 });
164
165 const desktopShutdownLayer = Layer.succeed(DesktopShutdown.DesktopShutdown, {
166 request: Effect.sync(() => {
167 events.push("request");
168 }).pipe(Effect.andThen(Deferred.succeed(shutdownRequested, undefined)), Effect.asVoid),
169 awaitRequest: Deferred.await(shutdownRequested),
170 markComplete: Deferred.succeed(allowShutdown, undefined).pipe(Effect.asVoid),
171 awaitComplete: Deferred.await(allowShutdown),
172 isComplete: Deferred.isDone(allowShutdown),
173 });
174
175 const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
176 platform: "darwin",
177 isDevelopment: false,
178 } as DesktopEnvironment.DesktopEnvironment["Service"]);
179
180 const layer = DesktopLifecycle.layer.pipe(
181 Layer.provideMerge(makeElectronAppLayer(appListeners, quit)),
182 Layer.provideMerge(electronThemeLayer),
183 Layer.provideMerge(makeElectronWindowLayer(destroyAll)),
184 Layer.provideMerge(makeDesktopWindowLayer({ flushMainWindowBounds })),
185 Layer.provideMerge(environmentLayer),
186 Layer.provideMerge(desktopShutdownLayer),
187 Layer.provideMerge(DesktopState.layer),
188 );
189
190 yield* Effect.scoped(
191 Effect.gen(function* () {
192 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
193 yield* lifecycle.register;
194
195 const event = { preventDefault: () => undefined } as Electron.Event;
196 appListeners.get("before-quit")?.(event);
197
198 yield* Deferred.await(shutdownRequested);
199 const eventsBeforeCleanup = [...events];
200 yield* Deferred.succeed(allowShutdown, undefined);
201 yield* Deferred.await(quitRequested);
202
203 assert.deepEqual(eventsBeforeCleanup, ["flush", "destroy", "request"]);
204 assert.deepEqual(events, ["flush", "destroy", "request", "quit"]);
205 }),
206 ).pipe(Effect.provide(layer));
207 }),
208 );
209
210 it.effect("ignores app activation while quitting", () =>
211 Effect.gen(function* () {
212 const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
213 let activationCount = 0;
214 const activate = Effect.sync(() => {
215 activationCount += 1;
216 });
217 const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
218 platform: "darwin",
219 isDevelopment: false,
220 } as DesktopEnvironment.DesktopEnvironment["Service"]);
221 const layer = DesktopLifecycle.layer.pipe(
222 Layer.provideMerge(makeElectronAppLayer(appListeners)),
223 Layer.provideMerge(electronThemeLayer),
224 Layer.provideMerge(makeElectronWindowLayer()),
225 Layer.provideMerge(makeDesktopWindowLayer({ activate })),
226 Layer.provideMerge(environmentLayer),
227 Layer.provideMerge(DesktopShutdown.layer),
228 Layer.provideMerge(DesktopState.layer),
229 );
230
231 yield* Effect.scoped(
232 Effect.gen(function* () {
233 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
234 const state = yield* DesktopState.DesktopState;
235 yield* lifecycle.register;
236 yield* Ref.set(state.quitting, true);
237
238 appListeners.get("activate")?.();
239
240 assert.equal(activationCount, 0);
241 }),
242 ).pipe(Effect.provide(layer));
243 }),
244 );
126245});
apps/desktop/src/app/DesktopLifecycle.ts+37-12
......@@ -12,6 +12,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts";
1212import * as DesktopShutdown from "./DesktopShutdown.ts";
1313import * as ElectronApp from "../electron/ElectronApp.ts";
1414import * as ElectronTheme from "../electron/ElectronTheme.ts";
15import * as ElectronWindow from "../electron/ElectronWindow.ts";
1516import * as DesktopState from "./DesktopState.ts";
1617import * as DesktopWindow from "../window/DesktopWindow.ts";
1718
......@@ -35,8 +36,12 @@ export type DesktopLifecycleRuntimeServices =
3536 | ElectronApp.ElectronApp
3637 | ElectronTheme.ElectronTheme;
3738
39type DesktopLifecycleRegistrationServices =
40 | DesktopLifecycleRuntimeServices
41 | ElectronWindow.ElectronWindow;
42
3843/**
39 * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme
44 * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow
4045 */
4146export class DesktopLifecycle extends Context.Service<
4247 DesktopLifecycle,
......@@ -44,7 +49,11 @@ export class DesktopLifecycle extends Context.Service<
4449 readonly relaunch: (
4550 reason: string,
4651 ) => Effect.Effect<void, never, DesktopLifecycleRuntimeServices>;
47 readonly register: Effect.Effect<void, never, Scope.Scope | DesktopLifecycleRuntimeServices>;
52 readonly register: Effect.Effect<
53 void,
54 never,
55 Scope.Scope | DesktopLifecycleRegistrationServices
56 >;
4857 }
4958>()("@t3tools/desktop/app/DesktopLifecycle") {}
5059
......@@ -73,14 +82,13 @@ function addScopedListener<Args extends ReadonlyArray<unknown>>(
7382}
7483
7584const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(
76 function* (): Effect.fn.Return<
77 void,
78 never,
79 DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow
80 > {
85 function* (
86 afterBoundsFlush: Effect.Effect<void> = Effect.void,
87 ): Effect.fn.Return<void, never, DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow> {
8188 const shutdown = yield* DesktopShutdown.DesktopShutdown;
8289 const desktopWindow = yield* DesktopWindow.DesktopWindow;
8390 yield* desktopWindow.flushMainWindowBounds;
91 yield* afterBoundsFlush;
8492 yield* shutdown.request;
8593 yield* shutdown.awaitComplete;
8694 },
......@@ -88,7 +96,9 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo
8896
8997function handleBeforeQuit(
9098 event: Electron.Event,
91 runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>,
99 runEffect: <A, E>(
100 effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
101 ) => Promise<A>,
92102 allowQuit: () => boolean,
93103 markQuitAllowed: () => void,
94104): void {
......@@ -107,9 +117,16 @@ function handleBeforeQuit(
107117 void runEffect(
108118 Effect.gen(function* () {
109119 const state = yield* DesktopState.DesktopState;
120 const electronWindow = yield* ElectronWindow.ElectronWindow;
110121 yield* Ref.set(state.quitting, true);
111122 yield* logLifecycleInfo("before-quit received");
112 yield* requestDesktopShutdownAndWait();
123 yield* requestDesktopShutdownAndWait(
124 electronWindow.destroyAll.pipe(
125 Effect.catchCause((cause) =>
126 logLifecycleError("failed to destroy windows before shutdown", { cause }),
127 ),
128 ),
129 );
113130 }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
114131 ).finally(() => {
115132 markQuitAllowed();
......@@ -124,7 +141,9 @@ function handleBeforeQuit(
124141
125142function quitFromSignal(
126143 signal: "SIGINT" | "SIGTERM",
127 runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>,
144 runEffect: <A, E>(
145 effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
146 ) => Promise<A>,
128147): void {
129148 void runEffect(
130149 Effect.gen(function* () {
......@@ -173,7 +192,7 @@ export const make = DesktopLifecycle.of({
173192 const electronApp = yield* ElectronApp.ElectronApp;
174193 const electronTheme = yield* ElectronTheme.ElectronTheme;
175194 const environment = yield* DesktopEnvironment.DesktopEnvironment;
176 const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();
195 const context = yield* Effect.context<DesktopLifecycleRegistrationServices>();
177196 const runEffect = Effect.runPromiseWith(context);
178197 let quitAllowed = false;
179198 let updaterQuitAllowed = false;
......@@ -204,7 +223,13 @@ export const make = DesktopLifecycle.of({
204223 );
205224 });
206225 yield* electronApp.on("activate", () => {
207 void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate")));
226 void runEffect(
227 Effect.gen(function* () {
228 const state = yield* DesktopState.DesktopState;
229 if (yield* Ref.get(state.quitting)) return;
230 yield* desktopWindow.activate;
231 }).pipe(Effect.withSpan("desktop.lifecycle.activate")),
232 );
208233 });
209234 yield* electronApp.on("window-all-closed", () => {
210235 void runEffect(
apps/desktop/src/electron/ElectronWindow.test.ts+7-2
......@@ -208,7 +208,7 @@ describe("ElectronWindow", () => {
208208 }).pipe(Effect.provide(TestLayer)),
209209 );
210210
211 it.effect("preserves destroy failures with the target window", () =>
211 it.effect("preserves destroy failures and continues with later windows", () =>
212212 Effect.gen(function* () {
213213 const cause = new Error("window destroy failed");
214214 const window = {
......@@ -217,7 +217,11 @@ describe("ElectronWindow", () => {
217217 throw cause;
218218 }),
219219 } as unknown as Electron.BrowserWindow;
220 getAllWindowsMock.mockReturnValueOnce([window]);
220 const laterWindow = {
221 id: 44,
222 destroy: vi.fn(),
223 } as unknown as Electron.BrowserWindow;
224 getAllWindowsMock.mockReturnValueOnce([window, laterWindow]);
221225
222226 const electronWindow = yield* ElectronWindow.ElectronWindow;
223227 const exit = yield* Effect.exit(electronWindow.destroyAll);
......@@ -231,6 +235,7 @@ describe("ElectronWindow", () => {
231235 assert.isNull(error.channel);
232236 assert.strictEqual(error.cause, cause);
233237 }
238 assert.equal(vi.mocked(laterWindow.destroy).mock.calls.length, 1);
234239 }).pipe(Effect.provide(TestLayer)),
235240 );
236241});
apps/desktop/src/electron/ElectronWindow.ts+22-11
......@@ -1,6 +1,8 @@
11import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import type * as Cause from "effect/Cause";
23import * as Context from "effect/Context";
34import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
46import * as Layer from "effect/Layer";
57import * as Option from "effect/Option";
68import * as Ref from "effect/Ref";
......@@ -257,18 +259,27 @@ export const make = Effect.gen(function* () {
257259 }
258260 }),
259261 destroyAll: Effect.gen(function* () {
262 let firstFailure: Cause.Cause<never> | undefined;
260263 for (const window of yield* listWindows) {
261 yield* Effect.try({
262 try: () => window.destroy(),
263 catch: (cause) =>
264 new ElectronWindowOperationError({
265 operation: "destroy-window",
266 platform,
267 windowId: window.id,
268 channel: null,
269 cause,
270 }),
271 }).pipe(Effect.orDie);
264 const exit = yield* Effect.exit(
265 Effect.try({
266 try: () => window.destroy(),
267 catch: (cause) =>
268 new ElectronWindowOperationError({
269 operation: "destroy-window",
270 platform,
271 windowId: window.id,
272 channel: null,
273 cause,
274 }),
275 }).pipe(Effect.orDie),
276 );
277 if (Exit.isFailure(exit)) {
278 firstFailure ??= exit.cause;
279 }
280 }
281 if (firstFailure !== undefined) {
282 return yield* Effect.failCause(firstFailure);
272283 }
273284 }),
274285 syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* <E, R>(