Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 4 additions & 3 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,7 @@ export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances"
const instances = yield* pool.list;
yield* Effect.forEach(
instances,
// Exceeds the backend's 30s graceful termination and output drain budget.
(instance) => instance.stop({ timeout: Duration.seconds(45) }),
(instance) => instance.stop({ timeout: Duration.seconds(5) }),
{ concurrency: "unbounded" },
);
},
Expand Down Expand Up @@ -240,7 +239,9 @@ const bootstrap = Effect.gen(function* () {
}

if (!(yield* Ref.get(state.quitting))) {
yield* desktopWindow.showConnectingSplash;
if (settings.wslOnly === true && settings.wslBackendEnabled === true) {
yield* desktopWindow.showConnectingSplash;
}
yield* primaryBackend.start;
yield* logBootstrapInfo("bootstrap backend start requested");
yield* appActivation.start.pipe(
Expand Down
70 changes: 2 additions & 68 deletions apps/desktop/src/app/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account.
import * as NodeHttp from "node:http";
import * as NodePath from "node:path";
import * as PlatformNodePath from "@effect/platform-node/NodePath";
import * as NodePath from "@effect/platform-node/NodePath";
import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff";
import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
Expand Down Expand Up @@ -60,15 +59,11 @@ const layerDesktopClerk = (
copyText: () => Effect.void,
},
listeners = new Map<string, (...args: unknown[]) => void>(),
isPackaged = false,
) => {
const environment = DesktopEnvironment.DesktopEnvironment.of({
stateDir: "/tmp/t3-state",
isDevelopment,
appDataDirectory: "/tmp/app-data",
isPackaged,
dirname: platform === "win32" ? String.raw`C:\T3 Code\dist-electron` : "/tmp/dist-electron",
path: platform === "win32" ? NodePath.win32 : NodePath.posix,
platform,
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);

Expand Down Expand Up @@ -100,7 +95,7 @@ const layerDesktopClerk = (
return DesktopClerk.layer.pipe(
Layer.provide(
Layer.mergeAll(
PlatformNodePath.layerPosix,
NodePath.layerPosix,
Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment),
Layer.succeed(ElectronApp.ElectronApp, electronApp),
Layer.succeed(ElectronShell.ElectronShell, shell),
Expand All @@ -117,67 +112,6 @@ describe("DesktopClerk", () => {
storageMock.mockReset();
});

it.effect.each([
{ platform: "win32", isPackaged: false, isDevelopment: false, registers: true },
{ platform: "win32", isPackaged: false, isDevelopment: true, registers: false },
{ platform: "win32", isPackaged: true, isDevelopment: false, registers: false },
{ platform: "darwin", isPackaged: false, isDevelopment: false, registers: false },
{ platform: "linux", isPackaged: false, isDevelopment: false, registers: false },
] as const)(
"registers an entry point only for unpackaged non-development Windows ($platform, packaged=$isPackaged, development=$isDevelopment)",
({ platform, isPackaged, isDevelopment, registers }) => {
const events: string[] = [];
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockImplementation(() => {
events.push("bridge");
return { cleanup: vi.fn() };
});
const setAsDefaultProtocolClient = vi.fn(() =>
Effect.sync(() => {
events.push("register");
return true;
}),
);
const electronApp = {
on: () => Effect.void,
setAsDefaultProtocolClient,
} as unknown as ElectronApp.ElectronApp["Service"];

return Effect.gen(function* () {
const clerk = yield* DesktopClerk.DesktopClerk;
yield* clerk.configure;

assert.deepEqual(
setAsDefaultProtocolClient.mock.calls,
registers
? [["t3code", process.execPath, [String.raw`C:\T3 Code\dist-electron\main.cjs`]]]
: [],
);
assert.deepEqual(events, registers ? ["bridge", "register"] : ["bridge"]);
}).pipe(
Effect.scoped,
Effect.provide(
layerDesktopClerk(
isDevelopment,
[],
platform,
undefined,
undefined,
undefined,
isPackaged,
),
),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Effect.provideService(
ElectronWindow.ElectronWindow,
{} as ElectronWindow.ElectronWindow["Service"],
),
Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
Effect.provideService(HostProcess.Arguments, ["electron"]),
);
},
);

it.effect("acquires and releases the SDK bridge with the layer", () => {
const cleanup = vi.fn();
const events: string[] = [];
Expand Down
14 changes: 0 additions & 14 deletions apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,20 +151,6 @@ export const make = Effect.gen(function* () {
const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
const runPromise = Effect.runPromiseWith(context);

// Clerk's bare scheme registration omits the app entry on unpackaged Windows.
// Development callbacks also need dev server environment this command cannot restore.
if (
!environment.isPackaged &&
!environment.isDevelopment &&
environment.platform === "win32"
) {
yield* electronApp.setAsDefaultProtocolClient(
ElectronProtocol.getDesktopScheme(environment.isDevelopment),
process.execPath,
[environment.path.join(environment.dirname, "main.cjs")],
);
}

const startProviderAuthHandoff = (value: string | undefined) => {
if (!value) return false;
const request = readCodexAuthHandoff(value, environment.isDevelopment);
Expand Down
119 changes: 9 additions & 110 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
// @effect-diagnostics-next-line nodeBuiltinImport:off - A real Node process verifies OS signal ownership outside Effect's process runtime.
import * as NodeChildProcess from "node:child_process";
import * as NodeReadline from "node:readline";
import * as NodeURL from "node:url";

import { assert, describe, it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";

Expand Down Expand Up @@ -110,67 +103,6 @@ function layerDesktopWindow(
}

describe("DesktopLifecycle", () => {
it.skipIf(HostProcess.Platform.defaultValue() === "win32")(
"leaves SIGTERM to the lifecycle until its graceful shutdown completes",
async ({ onTestFinished }) => {
const runner = new URL("./DesktopRuntime.ts", import.meta.url).href;
const child = NodeChildProcess.spawn(
process.execPath,
[
"--experimental-strip-types",
"--input-type=module",
"-e",
`
import * as Effect from 'effect/Effect';
import * as Deferred from 'effect/Deferred';
import { runMain } from ${JSON.stringify(runner)};
process.stdin.resume();
let released = false;
runMain(Effect.scoped(Effect.gen(function* () {
const done = yield* Deferred.make();
yield* Effect.addFinalizer(() => Effect.sync(() => console.log(released ? 'CLEANED' : 'PREMATURE')));
process.once('SIGTERM', () => {
console.log('STOPPING');
process.stdin.once('data', () => {
released = true;
void Effect.runPromise(Deferred.succeed(done, undefined)).then(() => process.stdin.destroy());
});
});
console.log('READY');
yield* Deferred.await(done);
})));
`,
],
{
cwd: NodeURL.fileURLToPath(new URL("../../", import.meta.url)),
stdio: ["pipe", "pipe", "pipe"],
},
);
const exited = new Promise<readonly [number | null, string | null]>((resolve, reject) => {
child.once("exit", (code, signal) => resolve([code, signal]));
child.once("error", reject);
});
const lines: string[] = [];
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
onTestFinished(async () => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
await exited;
}
});
for await (const line of NodeReadline.createInterface({ input: child.stdout })) {
lines.push(line);
if (line === "READY") child.kill("SIGTERM");
if (line === "STOPPING") child.stdin.write("finish");
}
assert.deepEqual(await exited, [0, null], stderr);
assert.deepEqual(lines, ["READY", "STOPPING", "CLEANED"]);
},
);

it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>)(
"lets the updater's quit event proceed on %s",
(platform) => {
Expand Down Expand Up @@ -226,18 +158,12 @@ describe("DesktopLifecycle", () => {
},
);

it.effect("destroys windows and retains signal ownership until backend shutdown completes", () =>
it.effect("destroys windows before waiting for backend shutdown", () =>
Effect.gen(function* () {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
const shutdownRequested = yield* Deferred.make<void>();
const allowShutdown = yield* Deferred.make<void>();
const quitRequested = yield* Deferred.make<void>();
const backendStopping = yield* Deferred.make<void>();
const finishBackendShutdown = yield* Deferred.make<void>();
const originalSignals = {
SIGINT: process.listeners("SIGINT"),
SIGTERM: process.listeners("SIGTERM"),
};
const events: string[] = [];

const quit = Effect.sync(() => {
Expand Down Expand Up @@ -278,47 +204,20 @@ describe("DesktopLifecycle", () => {
yield* Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
const shutdown = yield* DesktopShutdown.DesktopShutdown;
const program = yield* Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Deferred.succeed(backendStopping, undefined).pipe(
Effect.andThen(Deferred.await(finishBackendShutdown)),
Effect.andThen(shutdown.markComplete),
),
);
yield* lifecycle.register;
const event = { preventDefault: () => undefined } as Electron.Event;
appListeners.get("before-quit")?.(event);
yield* shutdown.awaitRequest;
}),
).pipe(Effect.forkScoped);

yield* Deferred.await(backendStopping);
yield* Effect.addFinalizer(() =>
Deferred.succeed(finishBackendShutdown, undefined).pipe(Effect.asVoid),
);
yield* lifecycle.register;

for (const signal of ["SIGINT", "SIGTERM"] as const) {
const listeners = process
.listeners(signal)
.filter((listener) => !originalSignals[signal].includes(listener));
assert.lengthOf(listeners, 1, `${signal} must remain owned during backend cleanup`);
listeners[0]?.(signal);
}
assert.deepEqual(events, ["flush", "destroy", "request"]);
assert.equal(appListeners.size, 0, "Electron listeners still follow the program scope");
assert.isFalse(yield* shutdown.isComplete);
const event = { preventDefault: () => undefined } as Electron.Event;
appListeners.get("before-quit")?.(event);

yield* Deferred.succeed(finishBackendShutdown, undefined);
yield* Fiber.join(program);
yield* Deferred.await(shutdownRequested);
const eventsBeforeCleanup = [...events];
yield* Deferred.succeed(allowShutdown, undefined);
yield* Deferred.await(quitRequested);

assert.deepEqual(eventsBeforeCleanup, ["flush", "destroy", "request"]);
assert.deepEqual(events, ["flush", "destroy", "request", "quit"]);
}),
).pipe(Effect.provide(layer));

assert.deepEqual(process.listeners("SIGINT"), originalSignals.SIGINT);
assert.deepEqual(process.listeners("SIGTERM"), originalSignals.SIGTERM);
}),
);

Expand Down
Loading
Loading