Skip to content
Open
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
4 changes: 2 additions & 2 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
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 All @@ -18,6 +17,7 @@ import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as PreviewPasskeys from "../preview/Passkeys.ts";
import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";
Expand Down Expand Up @@ -153,7 +153,7 @@ const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")(
const instances = yield* pool.list;
yield* Effect.forEach(
instances,
(instance) => instance.stop({ timeout: Duration.seconds(5) }),
(instance) => instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }),
{ concurrency: "unbounded" },
);
},
Expand Down
54 changes: 54 additions & 0 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,4 +629,58 @@ describe("DesktopBackendManager", () => {
}).pipe(Effect.provide(TestClock.layer())),
),
);

it.effect("the quit stop deadline waits for a backend that only exits at the force kill", () =>
Effect.scoped(
Effect.gen(function* () {
const spawned = yield* Deferred.make<void>();
const teardownStarted = yield* Deferred.make<void>();
const teardownFinished = yield* Deferred.make<void>();
// The real spawner sends SIGTERM, waits the grace, then SIGKILLs. A backend that
// ignores SIGTERM therefore finishes tearing down just after the grace.
const instance = yield* makeTestInstance({
spawnerLayer: Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.gen(function* () {
const scope = yield* Scope.Scope;
yield* Deferred.succeed(spawned, undefined);
yield* Scope.addFinalizer(
scope,
Deferred.succeed(teardownStarted, undefined).pipe(
Effect.andThen(Effect.sleep(Duration.seconds(6))),
Effect.andThen(Deferred.succeed(teardownFinished, undefined)),
Effect.asVoid,
),
);
return makeProcess({
exitCode: Deferred.await(teardownFinished).pipe(
Effect.as(ChildProcessSpawner.ExitCode(0)),
),
});
}),
),
),
httpClientLayer: layerHttpClient(() => Effect.never),
});
yield* instance.start;
yield* Deferred.await(spawned);

// App quit and the updater stop every backend with this deadline.
const quitDone = yield* Deferred.make<void>();
const quitFiber = yield* instance
.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT })
.pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild);

yield* Deferred.await(teardownStarted);
// Past the SIGTERM grace but before the force kill lands: quit must still be waiting.
yield* TestClock.adjust(Duration.millis(5_500));
assert.isFalse(yield* Deferred.isDone(quitDone));

yield* TestClock.adjust(Duration.millis(500));
yield* Fiber.join(quitFiber);
assert.isTrue(yield* Deferred.isDone(teardownFinished));
}).pipe(Effect.provide(TestClock.layer())),
),
);
});
10 changes: 10 additions & 0 deletions apps/desktop/src/backend/DesktopBackendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);
// Same as the service launcher. A killed backend leaves its agents running,
// and a busy one needs a few seconds to stop every provider session.
const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5);
/**
* How long app quit waits for each backend to stop. Covers the SIGTERM grace,
* the SIGKILL after it, and the spawner's one-second wait for the group to exit.
* Waiting only the grace lets the app exit before the SIGKILL, orphaning a
* backend whose shutdown hangs, along with the agents it started.
*/
export const BACKEND_QUIT_STOP_TIMEOUT = Duration.sum(
DEFAULT_BACKEND_TERMINATE_GRACE,
Duration.seconds(2),
);
const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5);
const BACKEND_READINESS_PATH = "/.well-known/t3/environment";
const { logWarning: logBackendProcessWarning } =
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/updates/DesktopUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as Scope from "effect/Scope";
import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";

import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
Expand Down Expand Up @@ -755,7 +756,8 @@ export const make = Effect.gen(function* () {
yield* Ref.set(installRunningBackendsRef, runningInstances);
yield* Effect.forEach(
instances,
(instance) => instance.stop({ timeout: Duration.seconds(5) }),
(instance) =>
instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }),
{ concurrency: "unbounded" },
);
yield* electronUpdater.quitAndInstall({
Expand Down
Loading