authorgravatar for ishaanko.mail@gmail.comIshaan Kothari <ishaanko.mail@gmail.com> 2026-09-12 16:43:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 20:43:52-03:00
log8ddd9f7efcdfb6e32b614595242b5986942d37b0
treee2e4255da24d7441200a167b9a9f5d49a7f3747d
parentfcbe45796aea99e9e2a9ce19dd9409fbd7e720e4
signature Signed by PGP key B5690EEEBB952194

fix(desktop): bound backend shutdown wait during quit (#7599)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

2 files changed, 95 insertions(+), 12 deletions(-)

apps/desktop/src/app/DesktopApp.ts+21-12
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as Cause from "effect/Cause";1import * as Cause from "effect/Cause";
2import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";4import * as Option from "effect/Option";
4import * as Ref from "effect/Ref";5import * as Ref from "effect/Ref";
...@@ -142,6 +143,20 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr...@@ -142,6 +143,20 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr
142const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) =>143const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) =>
143 handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause)));144 handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause)));
144145
146export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")(
147 function* (): Effect.fn.Return<void, never, DesktopBackendPool.DesktopBackendPool> {
148 // Stop every backend in the pool with a timeout to guarantee the quit
149 // path makes progress even if a backend hangs during teardown.
150 const pool = yield* DesktopBackendPool.DesktopBackendPool;
151 const instances = yield* pool.list;
152 yield* Effect.forEach(
153 instances,
154 (instance) => instance.stop({ timeout: Duration.seconds(5) }),
155 { concurrency: "unbounded" },
156 );
157 },
158);
159
145const bootstrap = Effect.gen(function* () {160const bootstrap = Effect.gen(function* () {
146 const pool = yield* DesktopBackendPool.DesktopBackendPool;161 const pool = yield* DesktopBackendPool.DesktopBackendPool;
147 const primaryBackend = yield* pool.primary;162 const primaryBackend = yield* pool.primary;
...@@ -313,18 +328,12 @@ const scopedProgram = Effect.scoped(...@@ -313,18 +328,12 @@ const scopedProgram = Effect.scoped(
313 const shutdown = yield* DesktopShutdown.DesktopShutdown;328 const shutdown = yield* DesktopShutdown.DesktopShutdown;
314329
315 yield* Effect.addFinalizer(() =>330 yield* Effect.addFinalizer(() =>
316 Effect.gen(function* () {331 // Stop every backend in the pool, not just the primary. The
317 const pool = yield* DesktopBackendPool.DesktopBackendPool;332 // electronApp.quit() path can race ahead of the layer-scope
318 // Stop every backend in the pool, not just the primary. The333 // cascade, so leaving the WSL instance for its parent scope
319 // electronApp.quit() path can race ahead of the layer-scope334 // finalizer means it gets hard-killed by the OS instead of
320 // cascade, so leaving the WSL instance for its parent scope335 // receiving SIGTERM + grace.
321 // finalizer means it gets hard-killed by the OS instead of336 stopAllPoolInstances().pipe(Effect.ensuring(shutdown.markComplete)),
322 // receiving SIGTERM + grace. Stops run concurrently.
323 const instances = yield* pool.list;
324 yield* Effect.forEach(instances, (instance) => instance.stop(), {
325 concurrency: "unbounded",
326 });
327 }).pipe(Effect.ensuring(shutdown.markComplete)),
328 );337 );
329338
330 yield* startup;339 yield* startup;
apps/desktop/src/backend/DesktopBackendManager.test.ts+74
...@@ -23,6 +23,8 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab...@@ -23,6 +23,8 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
23import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";23import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2424
25import * as DesktopBackendManager from "./DesktopBackendManager.ts";25import * as DesktopBackendManager from "./DesktopBackendManager.ts";
26import * as DesktopApp from "../app/DesktopApp.ts";
27import * as DesktopBackendPool from "./DesktopBackendPool.ts";
26import * as DesktopObservability from "../app/DesktopObservability.ts";28import * as DesktopObservability from "../app/DesktopObservability.ts";
27import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";29import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
28import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";30import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
...@@ -1503,4 +1505,76 @@ describe("DesktopBackendManager", () => {...@@ -1503,4 +1505,76 @@ describe("DesktopBackendManager", () => {
1503 }).pipe(Effect.provide(TestClock.layer())),1505 }).pipe(Effect.provide(TestClock.layer())),
1504 ),1506 ),
1505 );1507 );
1508
1509 it.effect("stopAllPoolInstances bounds the quit finalizer when backends hang", () =>
1510 Effect.scoped(
1511 Effect.gen(function* () {
1512 // Each backend's process-scope finalizer reports when it starts and
1513 // when it finishes, keyed by instance name, so the test can prove
1514 // both backends reached each milestone instead of inferring it from
1515 // a shared flag or a clock advance.
1516 const teardownStarted = yield* Queue.unbounded<string>();
1517 const teardownFinished = yield* Queue.unbounded<string>();
1518 const allowTeardown = yield* Deferred.make<void>();
1519
1520 const makeInstance = (name: string) =>
1521 makeTestInstance({
1522 spawnerLayer: Layer.succeed(
1523 ChildProcessSpawner.ChildProcessSpawner,
1524 ChildProcessSpawner.make(() =>
1525 Effect.gen(function* () {
1526 const scope = yield* Scope.Scope;
1527 yield* Scope.addFinalizer(
1528 scope,
1529 Queue.offer(teardownStarted, name).pipe(
1530 Effect.andThen(Deferred.await(allowTeardown)),
1531 Effect.andThen(Queue.offer(teardownFinished, name)),
1532 Effect.asVoid,
1533 ),
1534 );
1535 return makeProcess({ exitCode: Effect.never });
1536 }),
1537 ),
1538 ),
1539 httpClientLayer: httpClientLayer(() => Effect.never),
1540 });
1541
1542 const instance1 = yield* makeInstance("instance1");
1543 const instance2 = yield* makeInstance("instance2");
1544
1545 yield* instance1.start;
1546 yield* instance2.start;
1547
1548 const mockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
1549 list: Effect.succeed([instance1, instance2]),
1550 get: () => Effect.succeed(Option.none()),
1551 primary: Effect.die(new Error("primary not implemented")),
1552 register: () => Effect.die(new Error("register not implemented")),
1553 unregister: () => Effect.die(new Error("unregister not implemented")),
1554 });
1555
1556 // Mirror the quit path: register stopAllPoolInstances as a scope
1557 // finalizer and let the scope close run it, rather than calling it
1558 // as an ordinary interruptible effect.
1559 const quitFiber = yield* Effect.scoped(
1560 Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()),
1561 ).pipe(Effect.provide(mockPool), Effect.forkChild);
1562
1563 const started = yield* Queue.takeN(teardownStarted, 2);
1564 assert.deepEqual(started.toSorted(), ["instance1", "instance2"]);
1565
1566 // Both backends are now hung in teardown. Advancing past the 5s
1567 // budget must let the quit finalizer return without them.
1568 yield* TestClock.adjust(Duration.seconds(5));
1569 yield* Fiber.join(quitFiber);
1570 assert.equal(yield* Queue.size(teardownFinished), 0);
1571
1572 // The timed-out closes keep running in the background and finish
1573 // once the backends unblock.
1574 yield* Deferred.succeed(allowTeardown, undefined);
1575 const finished = yield* Queue.takeN(teardownFinished, 2);
1576 assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]);
1577 }).pipe(Effect.provide(TestClock.layer())),
1578 ),
1579 );
1506});1580});