Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
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
125 changes: 125 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import { assert, describe, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Ref from "effect/Ref";
import * as References from "effect/References";

import type * as Electron from "electron";
import { vi } from "vite-plus/test";

import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as PreviewManager from "../preview/Manager.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
Expand Down Expand Up @@ -80,6 +84,7 @@ function makeDesktopWindowLayer(
input: {
readonly activate?: Effect.Effect<void>;
readonly flushMainWindowBounds?: Effect.Effect<void>;
readonly preparePreviewTeardown?: Effect.Effect<void, PreviewManager.PreviewManagerError>;
} = {},
) {
return Layer.succeed(DesktopWindow.DesktopWindow, {
Expand All @@ -92,6 +97,7 @@ function makeDesktopWindowLayer(
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void,
preparePreviewTeardown: input.preparePreviewTeardown ?? Effect.void,
dispatchMenuAction: () => Effect.void,
zoomMain: () => Effect.void,
syncAppearance: Effect.void,
Expand Down Expand Up @@ -207,6 +213,125 @@ describe("DesktopLifecycle", () => {
}),
);

it.effect("logs typed teardown failures before rejecting an app quit", () =>
Effect.gen(function* () {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
const failure = new PreviewManager.PreviewAutomationCaptureDrainTimeoutError({
pendingCaptures: 2,
stage: "capture-drain",
timeoutMs: 2_500,
});
const loggedAnnotations: Array<Record<string, unknown>> = [];
const logger = Logger.make(({ fiber }) => {
const annotations = fiber.getRef(References.CurrentLogAnnotations);
if (annotations.errorTag === failure._tag) {
loggedAnnotations.push({ ...annotations });
}
});
let quitCount = 0;
const quit = Effect.sync(() => {
quitCount += 1;
});
const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform: "darwin",
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]);
const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(makeElectronAppLayer(appListeners, quit)),
Layer.provideMerge(electronThemeLayer),
Layer.provideMerge(makeElectronWindowLayer()),
Layer.provideMerge(
makeDesktopWindowLayer({ preparePreviewTeardown: Effect.fail(failure) }),
),
Layer.provideMerge(environmentLayer),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })),
);

yield* Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
const state = yield* DesktopState.DesktopState;
yield* lifecycle.register;
let prevented = false;
appListeners.get("before-quit")?.({
preventDefault: () => {
prevented = true;
},
} as Electron.Event);

yield* Effect.promise(() => vi.waitFor(() => assert.equal(loggedAnnotations.length, 1)));

assert.isTrue(prevented);
assert.isFalse(yield* Ref.get(state.quitting));
assert.equal(quitCount, 0);
assert.deepEqual(loggedAnnotations[0], {
component: "desktop-lifecycle",
errorTag: failure._tag,
message: failure.message,
pendingCaptures: 2,
stage: "capture-drain",
timeoutMs: 2_500,
});
assert.notProperty(loggedAnnotations[0]!, "error");
assert.notProperty(loggedAnnotations[0]!, "cause");
}),
).pipe(Effect.provide(layer));
}),
);

it.effect("keeps relaunch failure causes out of log annotations", () =>
Effect.gen(function* () {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
const unsafeCause = new Error("https://example.com/private?token=secret");
const failure = new PreviewManager.PreviewOperationError({
operation: "prepareForWindowTeardown",
cause: unsafeCause,
});
const loggedAnnotations: Array<Record<string, unknown>> = [];
const logger = Logger.make(({ fiber }) => {
const annotations = fiber.getRef(References.CurrentLogAnnotations);
if (annotations.errorTag === "DesktopLifecycleRelaunchError") {
loggedAnnotations.push({ ...annotations });
}
});
const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform: "darwin",
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]);
const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(makeElectronAppLayer(appListeners)),
Layer.provideMerge(electronThemeLayer),
Layer.provideMerge(
makeDesktopWindowLayer({ preparePreviewTeardown: Effect.fail(failure) }),
),
Layer.provideMerge(environmentLayer),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })),
);

yield* Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
yield* lifecycle.relaunch("update");
yield* Effect.promise(() => vi.waitFor(() => assert.equal(loggedAnnotations.length, 1)));

assert.deepEqual(loggedAnnotations[0], {
component: "desktop-lifecycle",
errorTag: "DesktopLifecycleRelaunchError",
reason: "update",
});
assert.notInclude(
Object.values(loggedAnnotations[0] ?? {})
.map(String)
.join(" "),
unsafeCause.message,
);
}).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
74 changes: 56 additions & 18 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as PreviewManager from "../preview/Manager.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";

Expand Down Expand Up @@ -84,10 +85,15 @@ function addScopedListener<Args extends ReadonlyArray<unknown>>(
const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(
function* (
afterBoundsFlush: Effect.Effect<void> = Effect.void,
): Effect.fn.Return<void, never, DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow> {
): Effect.fn.Return<
void,
PreviewManager.PreviewManagerError,
DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow
> {
const shutdown = yield* DesktopShutdown.DesktopShutdown;
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.flushMainWindowBounds;
yield* desktopWindow.preparePreviewTeardown;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
yield* afterBoundsFlush;
yield* shutdown.request;
yield* shutdown.awaitComplete;
Expand All @@ -114,7 +120,7 @@ function handleBeforeQuit(
}

event.preventDefault();
void runEffect(
const quit = runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
const electronWindow = yield* ElectronWindow.ElectronWindow;
Expand All @@ -127,16 +133,32 @@ function handleBeforeQuit(
),
),
);
}).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
).finally(() => {
markQuitAllowed();
void runEffect(
Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")),
);
});
}).pipe(
Effect.tapError((error) =>
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
yield* Ref.set(state.quitting, false);
yield* logLifecycleError(
"preview teardown blocked desktop shutdown",
DesktopWindow.previewTeardownErrorLogAnnotations(error),
);
}).pipe(Effect.withSpan("desktop.lifecycle.quitBlocked")),
),
Effect.withSpan("desktop.lifecycle.beforeQuit"),
),
);
void quit.then(
() => {
markQuitAllowed();
void runEffect(
Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")),
);
},
() => undefined,
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

function quitFromSignal(
Expand All @@ -145,7 +167,7 @@ function quitFromSignal(
effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
) => Promise<A>,
): void {
void runEffect(
const quit = runEffect(
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ signal });
const electronApp = yield* ElectronApp.ElectronApp;
Expand All @@ -155,8 +177,21 @@ function quitFromSignal(
yield* logLifecycleInfo("process signal received", { signal });
yield* requestDesktopShutdownAndWait();
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.processSignal")),
}).pipe(
Effect.tapError((error) =>
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
yield* Ref.set(state.quitting, false);
yield* logLifecycleError("preview teardown blocked process signal shutdown", {
...DesktopWindow.previewTeardownErrorLogAnnotations(error),
signal,
});
}).pipe(Effect.withSpan("desktop.lifecycle.processSignalBlocked")),
),
Effect.withSpan("desktop.lifecycle.processSignal"),
),
);
void quit.catch(() => undefined);
}

export const make = DesktopLifecycle.of({
Expand All @@ -179,10 +214,13 @@ export const make = DesktopLifecycle.of({
});
yield* electronApp.exit(0);
}).pipe(
Effect.catchCause((cause) => {
const error = new DesktopLifecycleRelaunchError({ reason, cause });
return logLifecycleError(error.message, { error });
}),
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Ref.set(state.quitting, false);
const error = new DesktopLifecycleRelaunchError({ reason, cause });
yield* logLifecycleError(error.message, { errorTag: error._tag, reason });
}),
),
Effect.forkDetach,
Effect.asVoid,
);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function makePoolLayer(
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
preparePreviewTeardown: Effect.void,
dispatchMenuAction: () => Effect.die("unexpected menu action"),
zoomMain: () => Effect.die("unexpected zoom"),
syncAppearance: Effect.void,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancell
export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";
export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview";
export const PREVIEW_PREPARE_WEBVIEW_REMOVAL_CHANNEL = "desktop:preview-prepare-webview-removal";
export const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate";
export const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back";
export const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward";
Expand Down Expand Up @@ -83,3 +84,4 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";
export const PREVIEW_CAPTURE_RECOVERY_CHANNEL = "desktop:preview-capture-recovery";
51 changes: 51 additions & 0 deletions apps/desktop/src/ipc/methods/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,57 @@ describe("preview IPC methods", () => {
}),
);

effectIt.effect("returns filtered native snapshot failure detail", () =>
Effect.gen(function* () {
const safeCause = new Error("software compositor copy failed");
safeCause.name = "UnknownVizError";
const unsafeCause = new Error("capture failed at https://preview.example/secret");
unsafeCause.name = "UnknownVizError";
const failures: PreviewManager.PreviewManagerError[] = [safeCause, unsafeCause].map(
(cause) =>
new PreviewManager.PreviewOperationError({
operation: "automationSnapshot.capturePage",
tabId: "tab-1",
webContentsId: 42,
cause,
}),
);
failures.push(
new PreviewManager.PreviewAutomationCaptureTimeoutError({
tabId: "tab-1",
webContentsId: 42,
stage: "capture-page",
timeoutMs: 2_500,
}),
);
let attempt = 0;
const manager = PreviewManager.PreviewManager.of({
automationSnapshot: () => Effect.fail(failures[attempt++]!),
} as unknown as PreviewManager.PreviewManager["Service"]);

const run = () =>
PreviewIpc.automationSnapshot
.handler({ tabId: "tab-1" })
.pipe(Effect.provideService(PreviewManager.PreviewManager, manager));
expect(yield* run()).toEqual({
_tag: "Failure",
error: { name: "UnknownVizError", message: safeCause.message },
});
expect(yield* run()).toEqual({
_tag: "Failure",
error: { name: "UnknownVizError", message: "Preview capture failed." },
});
expect(yield* run()).toEqual({
_tag: "Failure",
error: {
name: "PreviewAutomationCaptureTimeoutError",
message: "Desktop preview snapshot failed during capture-page.",
stage: "capture-page",
},
});
}),
);

it("keeps the public automation status tab id limit", () => {
const encode = Schema.encodeUnknownSync(PreviewAutomationStatus);
const tabId = "t".repeat(129);
Expand Down
Loading
Loading