1import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
4import * as Option from "effect/Option";
5import { fileURLToPath } from "node:url";
6import { describe, expect, test } from "alchemy-test";
7import { importStack } from "@/Alchemist/Session.ts";
8import { devKeepAlive } from "../../src/Cli/exec";
9import { PlatformServices } from "../../src/Util/PlatformServices";
10
11// The dev loop interrupts a parked run when a stack dependency changes (Node
12// reloads in-process; Bun exits for the supervisor to respawn). In both cases a
13// failed generation must park instead of exiting, while interruption propagates.
14
15/** Resolves `true` when the effect is still running (parked) after the timeout. */
16const parks = <A, E>(effect: Effect.Effect<A, E>) =>
17 Effect.runPromise(
18 effect.pipe(Effect.timeoutOption("50 millis"), Effect.map(Option.isNone)),
19 );
20
21describe("devKeepAlive", { tags: ["unit", "local"] }, () => {
22 test("passes success through untouched", () =>
23 expect(Effect.runPromise(devKeepAlive(Effect.succeed(42)))).resolves.toBe(
24 42,
25 ));
26
27 test("parks on a typed failure instead of exiting", () =>
28 expect(
29 parks(devKeepAlive(Effect.fail(new Error("plan failed")))),
30 ).resolves.toBe(true));
31
32 test("parks on a defect instead of exiting", () =>
33 expect(
34 parks(
35 devKeepAlive(Effect.die(new TypeError("undefined is not an object"))),
36 ),
37 ).resolves.toBe(true));
38
39 // A run that ends by itself with nothing but interruptions in its cause was
40 // cancelled from the inside (a provider or engine race), not by the user.
41 // It must park like any other failure; letting it propagate exited dev
42 // with a bare "Interrupted." and no hint of what went wrong (#1461).
43 test("an internal interrupt-only cause parks instead of exiting", () =>
44 expect(
45 parks(devKeepAlive(Effect.failCause(Cause.interrupt()))),
46 ).resolves.toBe(true));
47
48 test("interruption tears down a run that is still working (Ctrl-C in dev)", () =>
49 expect(
50 Effect.runPromise(
51 Effect.gen(function* () {
52 const fiber = yield* Effect.forkChild(
53 devKeepAlive(Effect.sleep("1 second")),
54 );
55 yield* Effect.sleep("20 millis");
56 const exit = yield* Fiber.interrupt(fiber).pipe(
57 Effect.andThen(Fiber.await(fiber)),
58 );
59 return exit._tag === "Failure" && Cause.hasInterruptsOnly(exit.cause);
60 }),
61 ),
62 ).resolves.toBe(true));
63
64 test("interruption still tears down a parked run (Ctrl-C in dev)", () =>
65 expect(
66 Effect.runPromise(
67 Effect.gen(function* () {
68 const fiber = yield* Effect.forkChild(
69 devKeepAlive(Effect.die("apply failed")),
70 );
71 yield* Effect.sleep("20 millis");
72 yield* Fiber.interrupt(fiber);
73 return "shut down";
74 }),
75 ),
76 ).resolves.toBe("shut down"));
77
78 // The real-world crash: a mid-edit save makes the stack entrypoint throw at
79 // module evaluation, so `importStack`'s dynamic import rejects (a defect).
80 // Wrapped in `devKeepAlive`, it must park until the import watcher observes
81 // the next save and interrupts this generation.
82 test("a stack module that throws at evaluation parks instead of crashing", () =>
83 expect(
84 parks(
85 devKeepAlive(
86 importStack(
87 fileURLToPath(
88 import.meta.resolve("./fixtures/import-stack-throws.ts"),
89 ),
90 ).pipe(Effect.provide(PlatformServices)),
91 ),
92 ),
93 ).resolves.toBe(true));
94});