Skip to content

Commit 9d962b8

Browse files
Merge branch 'main' into fix/diagnostics-border-radius
2 parents 407711e + 5bb54a0 commit 9d962b8

208 files changed

Lines changed: 6369 additions & 3482 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/desktop/src/app/DesktopLifecycle.test.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as Deferred from "effect/Deferred";
33
import * as Effect from "effect/Effect";
44
import * as Layer from "effect/Layer";
55
import * as Ref from "effect/Ref";
6+
import * as TestClock from "effect/testing/TestClock";
67

78
import type * as Electron from "electron";
89

@@ -18,6 +19,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts";
1819
function layerElectronApp(
1920
appListeners: Map<string, (...args: readonly unknown[]) => void>,
2021
quit: Effect.Effect<void> = Effect.void,
22+
exit: (code: number) => Effect.Effect<void> = () => Effect.void,
2123
) {
2224
const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) =>
2325
Effect.acquireRelease(
@@ -37,7 +39,7 @@ function layerElectronApp(
3739
whenReady: Effect.void,
3840
requestSingleInstanceLock: Effect.succeed(true),
3941
quit,
40-
exit: () => Effect.void,
42+
exit,
4143
relaunch: () => Effect.void,
4244
setPath: () => Effect.void,
4345
setName: () => Effect.void,
@@ -221,6 +223,60 @@ describe("DesktopLifecycle", () => {
221223
}),
222224
);
223225

226+
it.effect("exits on a repeated quit only once shutdown has stalled", () =>
227+
Effect.gen(function* () {
228+
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
229+
const exitCodes: number[] = [];
230+
const exit = (code: number) =>
231+
Effect.sync(() => {
232+
exitCodes.push(code);
233+
});
234+
const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
235+
platform: "darwin",
236+
isDevelopment: false,
237+
} as DesktopEnvironment.DesktopEnvironment["Service"]);
238+
const layer = DesktopLifecycle.layer.pipe(
239+
Layer.provideMerge(layerElectronApp(appListeners, Effect.void, exit)),
240+
Layer.provideMerge(layerElectronTheme),
241+
Layer.provideMerge(layerElectronWindow()),
242+
Layer.provideMerge(layerDesktopWindow()),
243+
Layer.provideMerge(layerEnvironment),
244+
// Nothing marks this shutdown complete, so it stays pending.
245+
Layer.provideMerge(DesktopShutdown.layer),
246+
Layer.provideMerge(DesktopState.layer),
247+
);
248+
249+
yield* Effect.scoped(
250+
Effect.gen(function* () {
251+
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
252+
yield* lifecycle.register;
253+
254+
let prevented = 0;
255+
const event = {
256+
preventDefault: () => {
257+
prevented += 1;
258+
},
259+
} as Electron.Event;
260+
appListeners.get("before-quit")?.(event);
261+
appListeners.get("before-quit")?.(event);
262+
yield* TestClock.adjust("9999 millis");
263+
appListeners.get("before-quit")?.(event);
264+
assert.deepEqual(
265+
exitCodes,
266+
[],
267+
"a repeat before the threshold must not cut shutdown short",
268+
);
269+
270+
yield* TestClock.adjust("1 millis");
271+
appListeners.get("before-quit")?.(event);
272+
273+
assert.deepEqual(exitCodes, [0]);
274+
assert.equal(prevented, 4);
275+
}),
276+
).pipe(Effect.provide(layer));
277+
}),
278+
);
279+
224280
it.effect("ignores app activation while quitting", () =>
225281
Effect.gen(function* () {
226282
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();

‎apps/desktop/src/app/DesktopLifecycle.ts‎

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import * as Clock from "effect/Clock";
12
import * as Context from "effect/Context";
3+
import * as Duration from "effect/Duration";
24
import * as Effect from "effect/Effect";
35
import * as Layer from "effect/Layer";
46
import * as Ref from "effect/Ref";
@@ -94,15 +96,50 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo
9496
},
9597
);
9698

99+
interface QuitState {
100+
phase: "running" | "waiting" | "allowed";
101+
waitingSinceNanos: bigint;
102+
}
103+
104+
// Longer than a backend's stop budget (SIGTERM, 5s grace, SIGKILL, 1s wait),
105+
// so a double Cmd-Q at the start of a normal quit cannot cut the backend stop
106+
// short and orphan it.
107+
const SHUTDOWN_STALL_THRESHOLD = Duration.seconds(10);
108+
109+
const startWaitingForShutdown = Effect.fn("desktop.lifecycle.startWaitingForShutdown")(function* (
110+
quit: QuitState,
111+
) {
112+
quit.phase = "waiting";
113+
quit.waitingSinceNanos = yield* Clock.monotonicTimeNanos;
114+
});
115+
116+
// While a quit waits on shutdown, `quitting` makes activation open nothing. If
117+
// that shutdown stalls, a repeated Quit or SIGTERM is the user's only way out
118+
// short of SIGKILL.
119+
const exitIfShutdownStalled = Effect.fn("desktop.lifecycle.exitIfShutdownStalled")(function* (
120+
quit: QuitState,
121+
source: string,
122+
) {
123+
const pendingMs = Number(
124+
((yield* Clock.monotonicTimeNanos) - quit.waitingSinceNanos) / 1_000_000n,
125+
);
126+
if (pendingMs < Duration.toMillis(SHUTDOWN_STALL_THRESHOLD)) {
127+
yield* logLifecycleInfo("quit already in progress", { source, pendingMs });
128+
return;
129+
}
130+
yield* logLifecycleError("desktop shutdown stalled; exiting without it", { source, pendingMs });
131+
const electronApp = yield* ElectronApp.ElectronApp;
132+
yield* electronApp.exit(0);
133+
});
134+
97135
function handleBeforeQuit(
98136
event: Electron.Event,
99137
runEffect: <A, E>(
100138
effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
101139
) => Promise<A>,
102-
allowQuit: () => boolean,
103-
markQuitAllowed: () => void,
140+
quit: QuitState,
104141
): void {
105-
if (allowQuit()) {
142+
if (quit.phase === "allowed") {
106143
void runEffect(
107144
Effect.gen(function* () {
108145
const state = yield* DesktopState.DesktopState;
@@ -114,10 +151,15 @@ function handleBeforeQuit(
114151
}
115152

116153
event.preventDefault();
154+
if (quit.phase === "waiting") {
155+
void runEffect(exitIfShutdownStalled(quit, "before-quit"));
156+
return;
157+
}
117158
void runEffect(
118159
Effect.gen(function* () {
119160
const state = yield* DesktopState.DesktopState;
120161
const electronWindow = yield* ElectronWindow.ElectronWindow;
162+
yield* startWaitingForShutdown(quit);
121163
yield* Ref.set(state.quitting, true);
122164
yield* logLifecycleInfo("before-quit received");
123165
yield* requestDesktopShutdownAndWait(
@@ -129,7 +171,7 @@ function handleBeforeQuit(
129171
);
130172
}).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
131173
).finally(() => {
132-
markQuitAllowed();
174+
quit.phase = "allowed";
133175
void runEffect(
134176
Effect.gen(function* () {
135177
const electronApp = yield* ElectronApp.ElectronApp;
@@ -144,16 +186,23 @@ function quitFromSignal(
144186
runEffect: <A, E>(
145187
effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
146188
) => Promise<A>,
189+
quit: QuitState,
147190
): void {
191+
if (quit.phase === "waiting") {
192+
void runEffect(exitIfShutdownStalled(quit, signal));
193+
return;
194+
}
148195
void runEffect(
149196
Effect.gen(function* () {
150197
yield* Effect.annotateCurrentSpan({ signal });
151198
const electronApp = yield* ElectronApp.ElectronApp;
152199
const state = yield* DesktopState.DesktopState;
153200
const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
154201
if (wasQuitting) return;
202+
yield* startWaitingForShutdown(quit);
155203
yield* logLifecycleInfo("process signal received", { signal });
156204
yield* requestDesktopShutdownAndWait();
205+
quit.phase = "allowed";
157206
yield* electronApp.quit;
158207
}).pipe(Effect.withSpan("desktop.lifecycle.processSignal")),
159208
);
@@ -196,8 +245,7 @@ export const make = DesktopLifecycle.of({
196245
const environment = yield* DesktopEnvironment.DesktopEnvironment;
197246
const context = yield* Effect.context<DesktopLifecycleRegistrationServices>();
198247
const runEffect = Effect.runPromiseWith(context);
199-
let quitAllowed = false;
200-
let updaterQuitAllowed = false;
248+
const quit: QuitState = { phase: "running", waitingSinceNanos: 0n };
201249
yield* electronTheme.onUpdated(() => {
202250
void runEffect(
203251
desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
@@ -207,7 +255,7 @@ export const make = DesktopLifecycle.of({
207255
// Electron's updater owns the remaining quit/install/relaunch sequence.
208256
// Cancelling the following app "before-quit" event breaks that sequence,
209257
// most visibly on macOS where the native updater performs the relaunch.
210-
updaterQuitAllowed = true;
258+
quit.phase = "allowed";
211259
// This event is synchronous and the updater's quit proceeds as soon as
212260
// the listener returns, so a forked destroyAll would race the quit
213261
// and windows could still be open when the process exits (visible on
@@ -223,14 +271,7 @@ export const make = DesktopLifecycle.of({
223271
);
224272
});
225273
yield* electronApp.on("before-quit", (event: Electron.Event) => {
226-
handleBeforeQuit(
227-
event,
228-
runEffect,
229-
() => quitAllowed || updaterQuitAllowed,
230-
() => {
231-
quitAllowed = true;
232-
},
233-
);
274+
handleBeforeQuit(event, runEffect, quit);
234275
});
235276
yield* electronApp.on("activate", () => {
236277
void runEffect(
@@ -255,10 +296,10 @@ export const make = DesktopLifecycle.of({
255296

256297
if (environment.platform !== "win32") {
257298
yield* addScopedListener(process, "SIGINT", () => {
258-
quitFromSignal("SIGINT", runEffect);
299+
quitFromSignal("SIGINT", runEffect, quit);
259300
});
260301
yield* addScopedListener(process, "SIGTERM", () => {
261-
quitFromSignal("SIGTERM", runEffect);
302+
quitFromSignal("SIGTERM", runEffect, quit);
262303
});
263304
}
264305
}).pipe(Effect.withSpan("desktop.lifecycle.register")),

0 commit comments

Comments
 (0)