1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { describe, expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import { MinimumLogLevel } from "effect/References";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientRequest from "effect/http/HttpClientRequest";
9import RestartStack from "./fixtures/restart/stack.ts";
10
11const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
12 providers: Cloudflare.providers(),
13 state: Cloudflare.state(),
14});
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21// Image build + push + worker/DO deploy comfortably exceeds the default hook
22// budget, and a restart adds another cold start on top, so be generous.
23const HOOK_TIMEOUT = 600_000;
24const TEST_TIMEOUT = 300_000;
25
26const DEPLOY_PLACEHOLDER = "Alchemy worker is being deployed...";
27
28// Force `Connection: close` so each attempt opens a fresh connection and can
29// land on an edge that already has the new deploy / restarted container.
30const freshConn = HttpClient.mapRequest(
31 HttpClientRequest.setHeader("connection", "close"),
32);
33
34const readinessSchedule = Schedule.min([
35 Schedule.exponential("500 millis"),
36 Schedule.spaced("3 seconds"),
37]);
38
39// Retry a route until it answers 200 with a body containing `expected`,
40// rejecting transient non-200s and the pre-create deploy stub. This is how we
41// assert "the container is up and serving" both initially and after a restart.
42const fetchReady = (url: string, expected: string) =>
43 Effect.gen(function* () {
44 const client = freshConn(yield* HttpClient.HttpClient);
45 return yield* client.get(url).pipe(
46 Effect.flatMap((r) =>
47 r.status !== 200
48 ? Effect.fail(new Error(`not ready: ${r.status}`))
49 : Effect.flatMap(r.text, (body) =>
50 body.includes(DEPLOY_PLACEHOLDER) || !body.includes(expected)
51 ? Effect.fail(new Error(`not ready: got ${body}`))
52 : Effect.succeed(body),
53 ),
54 ),
55 Effect.timeout("30 seconds"),
56 Effect.retry({ schedule: readinessSchedule, times: 40 }),
57 );
58 });
59
60// Fire a single GET and return its body (used for /stop, /crash).
61const hit = (url: string) =>
62 Effect.gen(function* () {
63 const client = freshConn(yield* HttpClient.HttpClient);
64 const res = yield* client.get(url);
65 return yield* res.text;
66 });
67
68// Poll `/running` until the reported state matches `want`. Used to confirm the
69// container actually went down before we assert the next request restarts it,
70// so the test exercises the real restart path (not a still-warm container).
71const waitRunning = (baseUrl: string, name: string, want: boolean) =>
72 Effect.gen(function* () {
73 const client = freshConn(yield* HttpClient.HttpClient);
74 return yield* client
75 .get(`${baseUrl}/running?name=${encodeURIComponent(name)}`)
76 .pipe(
77 Effect.flatMap((r) =>
78 r.status !== 200
79 ? Effect.fail(new Error(`running not ready: ${r.status}`))
80 : Effect.flatMap(r.json, (body) => {
81 const running = (body as { running?: boolean }).running;
82 return running === want
83 ? Effect.succeed(running)
84 : Effect.fail(new Error(`running=${running}, want ${want}`));
85 }),
86 ),
87 Effect.timeout("15 seconds"),
88 Effect.retry({ schedule: readinessSchedule, times: 40 }),
89 );
90 });
91
92/**
93 * Auto-restart: a Durable Object's container that has stopped or crashed must
94 * be transparently restarted on the next request. Regression coverage for the
95 * `readyPorts` cache going stale — previously a port confirmed ready once was
96 * never re-probed and `ensureRunning` was skipped, so a stopped container was
97 * never restarted.
98 */
99describe(
100 "container auto-restart",
101 {
102 tags: [
103 "provider:cloudflare",
104 "provider:cloudflare:container",
105 "provider:cloudflare:worker",
106 "live",
107 ],
108 },
109 () => {
110 const stack = beforeAll(deploy(RestartStack), { timeout: HOOK_TIMEOUT });
111 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(RestartStack), {
112 timeout: HOOK_TIMEOUT,
113 });
114
115 test(
116 "restarts the container after it is stopped (destroy)",
117 Effect.gen(function* () {
118 const { url } = yield* stack;
119 const name = "stop";
120
121 // Start + confirm up.
122 expect(yield* fetchReady(`${url}/ping?name=${name}`, "pong")).toContain(
123 "pong",
124 );
125
126 // Hard-stop it, then confirm it is actually down before re-pinging — so
127 // the next ping must go through the restart path.
128 yield* hit(`${url}/stop?name=${name}`);
129 yield* waitRunning(url, name, false);
130
131 // Next request transparently restarts it.
132 expect(yield* fetchReady(`${url}/ping?name=${name}`, "pong")).toContain(
133 "pong",
134 );
135 expect(yield* waitRunning(url, name, true)).toBe(true);
136 }).pipe(logLevel),
137 { timeout: TEST_TIMEOUT },
138 );
139
140 test(
141 "restarts the container after it crashes (non-zero exit)",
142 Effect.gen(function* () {
143 const { url } = yield* stack;
144 const name = "crash";
145
146 expect(yield* fetchReady(`${url}/ping?name=${name}`, "pong")).toContain(
147 "pong",
148 );
149
150 // Make the container process exit on its own, then wait for the monitor
151 // to observe the exit (running === false).
152 yield* hit(`${url}/crash?name=${name}`);
153 yield* waitRunning(url, name, false);
154
155 // Next request transparently restarts it.
156 expect(yield* fetchReady(`${url}/ping?name=${name}`, "pong")).toContain(
157 "pong",
158 );
159 expect(yield* waitRunning(url, name, true)).toBe(true);
160 }).pipe(logLevel),
161 { timeout: TEST_TIMEOUT },
162 );
163 },
164);