Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
import * as TestClock from "effect/testing/TestClock";

import type * as Electron from "electron";

Expand All @@ -18,6 +19,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts";
function layerElectronApp(
appListeners: Map<string, (...args: readonly unknown[]) => void>,
quit: Effect.Effect<void> = Effect.void,
exit: (code: number) => Effect.Effect<void> = () => Effect.void,
) {
const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) =>
Effect.acquireRelease(
Expand All @@ -37,7 +39,7 @@ function layerElectronApp(
whenReady: Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
quit,
exit: () => Effect.void,
exit,
relaunch: () => Effect.void,
setPath: () => Effect.void,
setName: () => Effect.void,
Expand Down Expand Up @@ -221,6 +223,54 @@ describe("DesktopLifecycle", () => {
}),
);

it.effect("exits on a repeated quit only once shutdown has stalled", () =>
Effect.gen(function* () {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
const exitCodes: number[] = [];
const exit = (code: number) =>
Effect.sync(() => {
exitCodes.push(code);
});
const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform: "darwin",
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]);
const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(layerElectronApp(appListeners, Effect.void, exit)),
Layer.provideMerge(layerElectronTheme),
Layer.provideMerge(layerElectronWindow()),
Layer.provideMerge(layerDesktopWindow()),
Layer.provideMerge(layerEnvironment),
// Nothing marks this shutdown complete, so it stays pending.
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
);

yield* Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
yield* lifecycle.register;

let prevented = 0;
const event = {
preventDefault: () => {
prevented += 1;
},
} as Electron.Event;
appListeners.get("before-quit")?.(event);
appListeners.get("before-quit")?.(event);
assert.deepEqual(exitCodes, [], "a quick repeat must not cut a normal shutdown short");

yield* TestClock.adjust("10 seconds");
appListeners.get("before-quit")?.(event);

assert.deepEqual(exitCodes, [0]);
assert.equal(prevented, 3);
}),
).pipe(Effect.provide(layer));
}),
);

it.effect("ignores app activation while quitting", () =>
Effect.gen(function* () {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
Expand Down
71 changes: 54 additions & 17 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
Expand Down Expand Up @@ -94,15 +96,46 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo
},
);

interface QuitState {
phase: "running" | "waiting" | "allowed";
waitingSince: number;
}

// Longer than a backend's stop budget (SIGTERM, 5s grace, SIGKILL, 1s wait),
// so a repeated Cmd-Q during a normal quit cannot orphan the backend.
const SHUTDOWN_STALL_THRESHOLD = Duration.seconds(10);

const startWaitingForShutdown = (quit: QuitState) =>
Effect.map(Clock.currentTimeMillis, (now) => {
quit.phase = "waiting";
quit.waitingSince = now;
});
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

// While a quit waits on shutdown, `quitting` makes activation open nothing. If
// that shutdown stalls, a repeated Quit or SIGTERM is the user's only way out
// short of SIGKILL.
const exitIfShutdownStalled = Effect.fn("desktop.lifecycle.exitIfShutdownStalled")(function* (
quit: QuitState,
source: string,
) {
const pendingMs = (yield* Clock.currentTimeMillis) - quit.waitingSince;
if (pendingMs < Duration.toMillis(SHUTDOWN_STALL_THRESHOLD)) {
Comment thread
shivamhwp marked this conversation as resolved.
Outdated
yield* logLifecycleInfo("quit already in progress", { source, pendingMs });
return;
}
yield* logLifecycleError("desktop shutdown stalled; exiting without it", { source, pendingMs });
const electronApp = yield* ElectronApp.ElectronApp;
yield* electronApp.exit(0);
});

function handleBeforeQuit(
event: Electron.Event,
runEffect: <A, E>(
effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
) => Promise<A>,
allowQuit: () => boolean,
markQuitAllowed: () => void,
quit: QuitState,
): void {
if (allowQuit()) {
if (quit.phase === "allowed") {
void runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
Expand All @@ -114,10 +147,15 @@ function handleBeforeQuit(
}

event.preventDefault();
if (quit.phase === "waiting") {
void runEffect(exitIfShutdownStalled(quit, "before-quit"));
return;
}
void runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
const electronWindow = yield* ElectronWindow.ElectronWindow;
yield* startWaitingForShutdown(quit);
yield* Ref.set(state.quitting, true);
yield* logLifecycleInfo("before-quit received");
yield* requestDesktopShutdownAndWait(
Expand All @@ -129,7 +167,7 @@ function handleBeforeQuit(
);
}).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
).finally(() => {
markQuitAllowed();
quit.phase = "allowed";
void runEffect(
Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
Expand All @@ -144,16 +182,23 @@ function quitFromSignal(
runEffect: <A, E>(
effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
) => Promise<A>,
quit: QuitState,
): void {
if (quit.phase === "waiting") {
void runEffect(exitIfShutdownStalled(quit, signal));
return;
}
void runEffect(
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ signal });
const electronApp = yield* ElectronApp.ElectronApp;
const state = yield* DesktopState.DesktopState;
const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
if (wasQuitting) return;
yield* startWaitingForShutdown(quit);
yield* logLifecycleInfo("process signal received", { signal });
yield* requestDesktopShutdownAndWait();
quit.phase = "allowed";
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.processSignal")),
);
Expand Down Expand Up @@ -196,8 +241,7 @@ export const make = DesktopLifecycle.of({
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const context = yield* Effect.context<DesktopLifecycleRegistrationServices>();
const runEffect = Effect.runPromiseWith(context);
let quitAllowed = false;
let updaterQuitAllowed = false;
const quit: QuitState = { phase: "running", waitingSince: 0 };
yield* electronTheme.onUpdated(() => {
void runEffect(
desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
Expand All @@ -207,7 +251,7 @@ export const make = DesktopLifecycle.of({
// Electron's updater owns the remaining quit/install/relaunch sequence.
// Cancelling the following app "before-quit" event breaks that sequence,
// most visibly on macOS where the native updater performs the relaunch.
updaterQuitAllowed = true;
quit.phase = "allowed";
// This event is synchronous and the updater's quit proceeds as soon as
// the listener returns, so a forked destroyAll would race the quit
// and windows could still be open when the process exits (visible on
Expand All @@ -223,14 +267,7 @@ export const make = DesktopLifecycle.of({
);
});
yield* electronApp.on("before-quit", (event: Electron.Event) => {
handleBeforeQuit(
event,
runEffect,
() => quitAllowed || updaterQuitAllowed,
() => {
quitAllowed = true;
},
);
handleBeforeQuit(event, runEffect, quit);
});
yield* electronApp.on("activate", () => {
void runEffect(
Expand All @@ -255,10 +292,10 @@ export const make = DesktopLifecycle.of({

if (environment.platform !== "win32") {
yield* addScopedListener(process, "SIGINT", () => {
quitFromSignal("SIGINT", runEffect);
quitFromSignal("SIGINT", runEffect, quit);
});
yield* addScopedListener(process, "SIGTERM", () => {
quitFromSignal("SIGTERM", runEffect);
quitFromSignal("SIGTERM", runEffect, quit);
});
}
}).pipe(Effect.withSpan("desktop.lifecycle.register")),
Expand Down
Loading