| ... | @@ -23,6 +23,8 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab | ... | @@ -23,6 +23,8 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab |
| 23 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 23 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 24 | | 24 | |
| 25 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | 25 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| | 26 | import * as DesktopApp from "../app/DesktopApp.ts"; |
| | 27 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; |
| 26 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 28 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 27 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | 29 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; |
| 28 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; | 30 | import * 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 | }); |