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 { isHttpClientError } from "effect/http/HttpClientError";
9import * as HttpClientResponse from "effect/http/HttpClientResponse";
10import Stack from "./fixtures/do-abort/stack.ts";
11
12const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
13 providers: Cloudflare.providers(),
14 state: Cloudflare.state(),
15});
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22const stack = beforeAll(deploy(Stack));
23afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
24
25let bust = 0;
26const getJson = <T>(
27 client: HttpClient.HttpClient,
28 url: string,
29 phase: "readiness" | "before abort" | "after abort",
30): Effect.Effect<T, unknown> =>
31 Effect.sync(() => `${url}?cb=${Date.now()}-${bust++}`).pipe(
32 Effect.flatMap((url) =>
33 client.get(url, { headers: { "cache-control": "no-cache" } }),
34 ),
35 Effect.flatMap(HttpClientResponse.filterStatusOk),
36 Effect.tapError((error) =>
37 Effect.gen(function* () {
38 if (error.reason._tag === "StatusCodeError") {
39 const response = error.reason.response;
40 const body = yield* response.text.pipe(
41 Effect.catch(() => Effect.succeed("<unreadable response body>")),
42 );
43 yield* (response.status === 404 ? Effect.logDebug : Effect.logError)(
44 `${phase}: GET ${response.request.url} returned ${response.status}`,
45 body,
46 );
47 } else {
48 yield* Effect.logError(`${phase}: GET ${url} failed`, error);
49 }
50 }),
51 ),
52 Effect.retry({
53 while: (error) =>
54 Effect.gen(function* () {
55 if (phase !== "readiness" || new URL(url).pathname !== "/ping")
56 return false;
57 if (error.reason._tag !== "StatusCodeError") return false;
58 const response = error.reason.response;
59 const html = (response.headers["content-type"] ?? "").includes(
60 "text/html",
61 );
62 if (response.status === 404 && html) return true;
63 if (response.status !== 500) return false;
64 if (html) {
65 const body = yield* response.text.pipe(
66 Effect.orElseSucceed(() => ""),
67 );
68 return (
69 body.includes('<span class="cf-error-code">1104</span>') &&
70 body.includes("Script not found")
71 );
72 }
73 // The opaque startup error is unexplained; only read-only readiness tolerates it.
74 return response.headers["x-do-readiness-retry"] === "true";
75 }),
76 schedule: Schedule.spaced("3 seconds"),
77 times: 8,
78 }),
79 Effect.flatMap((res) => res.json as Effect.Effect<T>),
80 );
81
82describe.skipIf(!!process.env.FAST)(
83 "DurableObjectState.abort resets the isolate",
84 { tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"] },
85 () => {
86 test(
87 "abort resets the Durable Object so the constructor re-runs",
88 Effect.gen(function* () {
89 const { url } = yield* stack;
90 const client = yield* HttpClient.HttpClient;
91
92 yield* getJson(client, `${url}/ping`, "readiness").pipe(
93 Effect.timeout("30 seconds"),
94 );
95 const before = yield* getJson<{ boots: number; ok: true }>(
96 client,
97 `${url}/ping`,
98 "before abort",
99 );
100 yield* Effect.logInfo("before abort", before);
101 expect(before.ok).toBe(true);
102 expect(before.boots).toBeGreaterThanOrEqual(1);
103
104 const failure = yield* getJson(
105 client,
106 `${url}/fail-ping`,
107 "before abort",
108 ).pipe(Effect.flip);
109 if (
110 !isHttpClientError(failure) ||
111 failure.reason._tag !== "StatusCodeError"
112 ) {
113 return yield* Effect.die(failure);
114 }
115 expect(failure.reason.response.status).toBe(500);
116 expect(failure.reason.response.headers["x-do-readiness-retry"]).toBe(
117 "false",
118 );
119 expect(yield* failure.reason.response.text).toContain(
120 "application-ping-failure",
121 );
122 const unchanged = yield* getJson<{
123 boots: number;
124 failedPings: number;
125 }>(client, `${url}/ping`, "before abort");
126 expect(unchanged.boots).toBe(before.boots);
127 expect(unchanged.failedPings).toBe(1);
128
129 const aborted = yield* Effect.sync(
130 () => `${url}/abort?cb=${Date.now()}-${bust++}`,
131 ).pipe(
132 Effect.flatMap((url) =>
133 client.get(url, { headers: { "cache-control": "no-cache" } }),
134 ),
135 Effect.flatMap(HttpClientResponse.filterStatusOk),
136 Effect.flatMap((response) => response.text),
137 );
138 yield* Effect.logInfo("abort RPC", aborted);
139 expect(aborted).toContain("test abort");
140
141 const after = yield* getJson<{ boots: number; ok: true }>(
142 client,
143 `${url}/ping`,
144 "after abort",
145 );
146 yield* Effect.logInfo("after abort", after);
147 expect(after.ok).toBe(true);
148 expect(after.boots).toBe(before.boots + 1);
149 }).pipe(logLevel),
150 { timeout: 120_000 },
151 );
152 },
153);