1import { expect, layer } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Schedule from "effect/Schedule";
5import * as Scope from "effect/Scope";
6import { afterEach, vi } from "vitest";
7import * as Service from "../bindings/Service.ts";
8import * as Runtime from "../Runtime.ts";
9import * as Workerd from "../workerd/Workerd.ts";
10import { localRuntimeLayer } from "./helpers/runtime.ts";
11
12/**
13 * `/crash` exhausts the isolate's heap. With a small `--max-old-space-size`
14 * V8 aborts the process the way it does in a long dev session, only faster.
15 */
16const CRASHING_SCRIPT = `
17const hog = [];
18export default {
19 fetch(request, env) {
20 const url = new URL(request.url);
21 if (url.pathname === "/crash") {
22 for (;;) hog.push(new Array(1_000_000).fill(url.pathname));
23 }
24 return env.SERVICE ? env.SERVICE.fetch(request) : new Response("hello");
25 },
26};
27`;
28
29const fetchText = (url: URL) =>
30 Effect.tryPromise(async () => {
31 const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
32 return await response.text();
33 });
34
35layer(localRuntimeLayer, { excludeTestServices: true })(
36 "Runtime restart",
37 (it) => {
38 afterEach(() => {
39 vi.unstubAllEnvs();
40 vi.restoreAllMocks();
41 });
42 it.effect(
43 "releases each process generation and bounds repeated crash recovery",
44 () =>
45 Effect.gen(function* () {
46 vi.stubEnv(Workerd.V8_FLAGS_ENV, "--max-old-space-size=32");
47 const runtime = yield* Runtime.Runtime;
48 const workerd = yield* Workerd.Workerd;
49 const serve = workerd.serve;
50 let active = 0;
51 let started = 0;
52 vi.spyOn(workerd, "serve").mockImplementation((...args) =>
53 Effect.gen(function* () {
54 expect(active).toBe(0);
55 const ports = yield* serve(...args);
56 active++;
57 started++;
58 yield* Effect.addFinalizer(() =>
59 Effect.sync(() => {
60 active--;
61 }),
62 );
63 return ports;
64 }),
65 );
66 const exits: Array<Workerd.WorkerdExit> = [];
67 const url = yield* runtime.start({
68 name: "restarting",
69 compatibilityDate: "2026-03-10",
70 compatibilityFlags: [],
71 bindings: [],
72 modules: [
73 { name: "main.js", type: "ESModule", content: CRASHING_SCRIPT },
74 ],
75 onRestart: (exit) => {
76 exits.push(exit);
77 },
78 });
79 expect(yield* fetchText(new URL("/hello", url))).toBe("hello");
80
81 for (let crash = 1; crash <= 3; crash++) {
82 yield* fetchText(new URL("/crash", url)).pipe(Effect.ignore);
83 const answer = yield* fetchText(new URL("/hello", url)).pipe(
84 Effect.retry({
85 schedule: Schedule.spaced("250 millis"),
86 times: 10,
87 }),
88 );
89 expect(answer).toBe("hello");
90 expect(exits).toHaveLength(crash);
91 expect(exits[crash - 1].stderr).toContain(
92 "JavaScript heap out of memory",
93 );
94 expect(active).toBe(1);
95 }
96 // The fourth crash exhausts the rolling restart budget.
97 yield* fetchText(new URL("/crash", url)).pipe(Effect.ignore);
98 yield* Effect.sleep(1_500);
99 expect(started).toBe(4);
100 expect(active).toBe(0);
101 expect(exits).toHaveLength(3);
102 expect(
103 yield* fetchText(new URL("/hello", url)).pipe(Effect.exit),
104 ).toMatchObject({ _tag: "Failure" });
105 }),
106 { timeout: 60_000 },
107 );
108
109 it.effect(
110 "restores current service targets and keeps receiving registry updates",
111 () =>
112 Effect.gen(function* () {
113 vi.stubEnv(Workerd.V8_FLAGS_ENV, "--max-old-space-size=32");
114 const runtime = yield* Runtime.Runtime;
115 const parent = yield* Effect.scope;
116 let providerScope = yield* Scope.fork(parent);
117 const startProvider = (version: string) =>
118 runtime
119 .start({
120 name: "restart-dependency",
121 compatibilityDate: "2026-03-10",
122 compatibilityFlags: [],
123 bindings: [],
124 modules: [
125 {
126 name: "main.js",
127 type: "ESModule",
128 content: `export default { fetch: () => new Response("${version}") };`,
129 },
130 ],
131 })
132 .pipe(Scope.provide(providerScope));
133 yield* startProvider("one");
134 const url = yield* runtime.start({
135 name: "restart-consumer",
136 compatibilityDate: "2026-03-10",
137 compatibilityFlags: [],
138 bindings: [
139 Service.local({
140 binding: "SERVICE",
141 scriptName: "restart-dependency",
142 }),
143 ],
144 modules: [
145 {
146 name: "main.js",
147 type: "ESModule",
148 content: CRASHING_SCRIPT,
149 },
150 ],
151 });
152 const expectVersion = (version: string) =>
153 fetchText(url).pipe(
154 Effect.filterOrFail(
155 (text) => text === version,
156 () => new Error("stale service target"),
157 ),
158 Effect.retry({
159 schedule: Schedule.spaced("250 millis"),
160 times: 10,
161 }),
162 Effect.tap((text) =>
163 Effect.sync(() => expect(text).toBe(version)),
164 ),
165 );
166 yield* expectVersion("one");
167 yield* Scope.close(providerScope, Exit.void);
168 providerScope = yield* Scope.fork(parent);
169 yield* startProvider("two");
170 yield* expectVersion("two");
171
172 yield* fetchText(new URL("/crash", url)).pipe(Effect.ignore);
173 yield* expectVersion("two");
174
175 yield* Scope.close(providerScope, Exit.void);
176 providerScope = yield* Scope.fork(parent);
177 yield* startProvider("three");
178 yield* expectVersion("three");
179 }),
180 { timeout: 60_000 },
181 );
182
183 for (const crashBeforeClose of [false, true]) {
184 it.effect(
185 `does not restart after scope closure (crash: ${crashBeforeClose})`,
186 () =>
187 Effect.gen(function* () {
188 vi.stubEnv(Workerd.V8_FLAGS_ENV, "--max-old-space-size=32");
189 const workerd = yield* Workerd.Workerd;
190 const serve = vi.spyOn(workerd, "serve");
191 const exits: Array<Workerd.WorkerdExit> = [];
192 yield* Effect.gen(function* () {
193 const runtime = yield* Runtime.Runtime;
194 const url = yield* runtime.start({
195 name: "closing",
196 compatibilityDate: "2026-03-10",
197 compatibilityFlags: [],
198 bindings: [],
199 modules: [
200 {
201 name: "main.js",
202 type: "ESModule",
203 content: CRASHING_SCRIPT,
204 },
205 ],
206 onRestart: (exit) => {
207 exits.push(exit);
208 },
209 });
210 expect(yield* fetchText(new URL("/hello", url))).toBe("hello");
211 if (crashBeforeClose) {
212 yield* fetchText(new URL("/crash", url)).pipe(Effect.ignore);
213 }
214 }).pipe(Effect.scoped);
215 // Give a wrongly reported exit time to arrive.
216 yield* Effect.sleep(1_000);
217 expect(serve).toHaveBeenCalledTimes(1);
218 expect(exits).toHaveLength(0);
219 }),
220 { timeout: 30_000 },
221 );
222 }
223 },
224);