1import * as Cloudflare from "@/Cloudflare";
2import * as Alchemy from "@/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as Option from "effect/Option";
12import * as Os from "node:os";
13import HandoffWorkerLive, {
14 HandoffWorker,
15 setDeployN,
16} from "./fixtures/handoff-worker.ts";
17
18/**
19 * `dev: true` runs the local providers in a persistent, file-scoped RPC
20 * sidecar by default, exactly like `alchemy dev` (see MakeOptions.sidecar in
21 * Test/Core.ts). This test DEPENDS on that topology: in-process (`sidecar:
22 * false`), each `deploy(Stack)` builds a fresh provider (fresh in-memory
23 * instance maps), so the second deploy never takes the "Changes detected,
24 * interrupting existing instance" replacement path this test exists to
25 * cover. The proxy caches its sidecar session per entry URL, so both
26 * deploys talk to the same provider process.
27 */
28const { test, deploy, destroy } = Test.make({
29 providers: Cloudflare.providers(),
30 state: Alchemy.localState(),
31 dev: true,
32});
33
34const logLevel = Effect.provideService(
35 MinimumLogLevel,
36 process.env.DEBUG ? "Debug" : "Info",
37);
38
39const Stack = Alchemy.Stack(
40 "LocalWorkerHandoffStack",
41 { providers: Cloudflare.providers(), state: Alchemy.localState() },
42 Effect.gen(function* () {
43 const worker = yield* HandoffWorker;
44 return {
45 url: worker.url.as<string>(),
46 workerName: worker.workerName.as<string>(),
47 };
48 }).pipe(Effect.provide(HandoffWorkerLive)),
49);
50
51const fetchDeploy = (url: string) =>
52 Effect.gen(function* () {
53 const client = yield* HttpClient.HttpClient;
54 const res = yield* client.get(url);
55 if (res.status !== 200) {
56 const text = yield* res.text;
57 return yield* Effect.fail(
58 new Error(`status ${res.status}: ${text.slice(0, 500)}`),
59 );
60 }
61 const body = (yield* res.json) as { deploy: string; pong: string };
62 expect(body.pong).toBe("pong");
63 return body.deploy;
64 }).pipe(Effect.timeout("10 seconds"));
65
66const registryEntryPath = (workerName: string) =>
67 Effect.gen(function* () {
68 const path = yield* Path.Path;
69 const stateHome =
70 process.env.XDG_STATE_HOME ?? path.join(Os.homedir(), ".local", "state");
71 return path.join(
72 stateHome,
73 "alchemy",
74 "registry",
75 `${encodeURIComponent(workerName)}.json`,
76 );
77 });
78
79const registryEntryExists = (workerName: string) =>
80 Effect.gen(function* () {
81 const fs = yield* FileSystem.FileSystem;
82 return yield* fs.exists(yield* registryEntryPath(workerName));
83 });
84
85/**
86 * The registry entry's inode. A make-before-break handoff only ever
87 * overwrites `{scriptName}.json` in place (the replacement re-registers,
88 * then the owner-aware unregister of the old instance no-ops), so the inode
89 * is stable across a redeploy. The broken path unlinks the entry when the
90 * old instance is torn down and recreates it when the replacement finally
91 * serves — observable as an inode change even when the dark window itself
92 * is too short to catch with a request.
93 */
94const registryEntryIno = (workerName: string) =>
95 Effect.gen(function* () {
96 const fs = yield* FileSystem.FileSystem;
97 const stat = yield* fs.stat(yield* registryEntryPath(workerName));
98 return Option.getOrUndefined(stat.ino);
99 });
100
101/**
102 * Regression test for alchemy-run/alchemy#859: replacing a local dev Worker
103 * instance (a reconcile with a changed structural signature logs "Changes
104 * detected, interrupting existing instance") must not tear down the running
105 * workerd before the replacement has started. The last good instance keeps
106 * serving — and keeps its dev-registry entry, which cross-script DO bindings
107 * resolve through — until the replacement's first serve completes.
108 */
109test(
110 "previous instance keeps serving and stays registered during redeploy",
111 Effect.gen(function* () {
112 // Clear any state a previous interrupted run left behind.
113 yield* destroy(Stack);
114
115 setDeployN("1");
116 const first = yield* deploy(Stack);
117
118 // The local provider serves from the dev proxy — proof no cloud call ran.
119 expect(first.url).toMatch(/^http:\/\/localhost:\d+$/);
120
121 // Wait for the first instance to actually serve (fresh workerd + DO).
122 const initial = yield* fetchDeploy(first.url).pipe(
123 Effect.retry({
124 schedule: Schedule.min([
125 Schedule.exponential("500 millis"),
126 Schedule.spaced("2 seconds"),
127 ]),
128 times: 20,
129 }),
130 );
131 expect(initial).toBe("1");
132 expect(yield* registryEntryExists(first.workerName)).toBe(true);
133 const inoBefore = yield* registryEntryIno(first.workerName);
134
135 // Redeploy with a changed env var: the Worker's structural signature
136 // changes, so the provider replaces the instance. The deploy returns as
137 // soon as reconcile completes — before the replacement workerd has
138 // started serving.
139 setDeployN("2");
140 const second = yield* deploy(Stack);
141
142 // The previous instance must still be serving (make-before-break): a
143 // single un-retried request must succeed, whether it is answered by the
144 // old instance ("1") or an already-cut-over replacement ("2").
145 const during = yield* fetchDeploy(second.url);
146 expect(["1", "2"]).toContain(during);
147
148 // ...and its dev-registry entry must still exist, so cross-script DO
149 // bindings from other workers keep resolving throughout the handoff.
150 expect(yield* registryEntryExists(second.workerName)).toBe(true);
151
152 // The replacement eventually takes over.
153 const final = yield* fetchDeploy(second.url).pipe(
154 Effect.flatMap((deploy) =>
155 deploy === "2"
156 ? Effect.succeed(deploy)
157 : Effect.fail(new Error(`still serving deploy ${deploy}`)),
158 ),
159 Effect.retry({ schedule: Schedule.spaced("1 second"), times: 90 }),
160 );
161 expect(final).toBe("2");
162 expect(yield* registryEntryExists(second.workerName)).toBe(true);
163 // The entry was overwritten in place by the replacement, never deleted —
164 // cross-script DO bindings resolving through it never saw it vanish.
165 expect(yield* registryEntryIno(second.workerName)).toBe(inoBefore);
166
167 yield* destroy(Stack);
168 }).pipe(logLevel),
169 {
170 tags: ["provider:cloudflare", "provider:cloudflare:worker", "local"],
171 timeout: 240_000,
172 },
173);