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
33 changes: 21 additions & 12 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Cause from "effect/Cause";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
Expand Down Expand Up @@ -142,6 +143,20 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr
const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) =>
handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause)));

export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")(
function* (): Effect.fn.Return<void, never, DesktopBackendPool.DesktopBackendPool> {
// Stop every backend in the pool with a timeout to guarantee the quit
// path makes progress even if a backend hangs during teardown.
const pool = yield* DesktopBackendPool.DesktopBackendPool;
const instances = yield* pool.list;
yield* Effect.forEach(
instances,
(instance) => instance.stop({ timeout: Duration.seconds(5) }),
{ concurrency: "unbounded" },
);
},
);

const bootstrap = Effect.gen(function* () {
const pool = yield* DesktopBackendPool.DesktopBackendPool;
const primaryBackend = yield* pool.primary;
Expand Down Expand Up @@ -313,18 +328,12 @@ const scopedProgram = Effect.scoped(
const shutdown = yield* DesktopShutdown.DesktopShutdown;

yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const pool = yield* DesktopBackendPool.DesktopBackendPool;
// Stop every backend in the pool, not just the primary. The
// electronApp.quit() path can race ahead of the layer-scope
// cascade, so leaving the WSL instance for its parent scope
// finalizer means it gets hard-killed by the OS instead of
// receiving SIGTERM + grace. Stops run concurrently.
const instances = yield* pool.list;
yield* Effect.forEach(instances, (instance) => instance.stop(), {
concurrency: "unbounded",
});
}).pipe(Effect.ensuring(shutdown.markComplete)),
// Stop every backend in the pool, not just the primary. The
// electronApp.quit() path can race ahead of the layer-scope
// cascade, so leaving the WSL instance for its parent scope
// finalizer means it gets hard-killed by the OS instead of
// receiving SIGTERM + grace.
stopAllPoolInstances().pipe(Effect.ensuring(shutdown.markComplete)),
);

yield* startup;
Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import * as DesktopBackendManager from "./DesktopBackendManager.ts";
import * as DesktopApp from "../app/DesktopApp.ts";
import * as DesktopBackendPool from "./DesktopBackendPool.ts";
import * as DesktopObservability from "../app/DesktopObservability.ts";
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
Expand Down Expand Up @@ -1503,4 +1505,76 @@ describe("DesktopBackendManager", () => {
}).pipe(Effect.provide(TestClock.layer())),
),
);

it.effect("stopAllPoolInstances bounds the quit finalizer when backends hang", () =>
Effect.scoped(
Effect.gen(function* () {
// Each backend's process-scope finalizer reports when it starts and
// when it finishes, keyed by instance name, so the test can prove
// both backends reached each milestone instead of inferring it from
// a shared flag or a clock advance.
const teardownStarted = yield* Queue.unbounded<string>();
const teardownFinished = yield* Queue.unbounded<string>();
const allowTeardown = yield* Deferred.make<void>();

const makeInstance = (name: string) =>
makeTestInstance({
spawnerLayer: Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.gen(function* () {
const scope = yield* Scope.Scope;
yield* Scope.addFinalizer(
scope,
Queue.offer(teardownStarted, name).pipe(
Effect.andThen(Deferred.await(allowTeardown)),
Effect.andThen(Queue.offer(teardownFinished, name)),
Effect.asVoid,
),
);
return makeProcess({ exitCode: Effect.never });
}),
),
),
httpClientLayer: httpClientLayer(() => Effect.never),
});

const instance1 = yield* makeInstance("instance1");
const instance2 = yield* makeInstance("instance2");

yield* instance1.start;
yield* instance2.start;

const mockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
list: Effect.succeed([instance1, instance2]),
get: () => Effect.succeed(Option.none()),
primary: Effect.die(new Error("primary not implemented")),
register: () => Effect.die(new Error("register not implemented")),
unregister: () => Effect.die(new Error("unregister not implemented")),
});

// Mirror the quit path: register stopAllPoolInstances as a scope
// finalizer and let the scope close run it, rather than calling it
// as an ordinary interruptible effect.
const quitFiber = yield* Effect.scoped(
Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()),
).pipe(Effect.provide(mockPool), Effect.forkChild);

const started = yield* Queue.takeN(teardownStarted, 2);
assert.deepEqual(started.toSorted(), ["instance1", "instance2"]);

// Both backends are now hung in teardown. Advancing past the 5s
// budget must let the quit finalizer return without them.
yield* TestClock.adjust(Duration.seconds(5));
yield* Fiber.join(quitFiber);
assert.equal(yield* Queue.size(teardownFinished), 0);

// The timed-out closes keep running in the background and finish
// once the backends unblock.
yield* Deferred.succeed(allowTeardown, undefined);
const finished = yield* Queue.takeN(teardownFinished, 2);
assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]);
}).pipe(Effect.provide(TestClock.layer())),
),
);
});
Loading