1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
4import * as Lambda from "@distilled.cloud/aws/lambda";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import ShutdownProbe from "./shutdown-probe.ts";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13const filterLogs = Effect.fn(function* (
14 logGroupName: string,
15 filterPattern: string,
16) {
17 const result = yield* logs
18 .filterLogEvents({ logGroupName, filterPattern })
19 .pipe(Effect.catch(() => Effect.succeed({ events: [] })));
20 return (result.events ?? []).length > 0;
21});
22
23/**
24 * The fast half of the Shutdown-phase contract: the generated entry
25 * registers an internal extension with the Extensions API at cold start.
26 * Registration is the documented precondition for Lambda's SIGTERM window —
27 * without any registered extension the sandbox is killed with 0 ms and no
28 * signal; with an internal extension the runtime gets SIGTERM + 500 ms.
29 *
30 * A successful invoke proves registration doesn't hang Init (a registered
31 * extension must signal readiness via the parked `/event/next` long-poll —
32 * getting that wrong wedges Init and every invoke 502s). Lambda logs the
33 * registration as an `EXTENSION` platform line, which pins that the
34 * registration was actually accepted.
35 *
36 * The slow half — Lambda delivering SIGTERM at idle spin-down and the
37 * instance finalizers running — takes many idle minutes and is gated below.
38 */
39test.provider(
40 "entry registers the graceful-shutdown internal extension",
41 (stack) =>
42 Effect.gen(function* () {
43 yield* stack.destroy();
44
45 const fn = yield* stack.deploy(
46 Effect.gen(function* () {
47 return yield* ShutdownProbe;
48 }),
49 );
50
51 const client = yield* HttpClient.HttpClient;
52 const res = yield* client.get(fn.functionUrl!).pipe(
53 Effect.retry({
54 schedule: Schedule.exponential("500 millis"),
55 times: 10,
56 }),
57 );
58 expect(res.status).toBe(200);
59 expect(yield* res.text).toBe("ok");
60
61 const logGroupName = `/aws/lambda/${fn.functionName}`;
62 const registered = yield* filterLogs(
63 logGroupName,
64 '"alchemy-graceful-shutdown"',
65 ).pipe(
66 Effect.repeat({
67 schedule: Schedule.spaced("3 seconds"),
68 until: (found) => found,
69 times: 20,
70 }),
71 );
72 expect(registered).toBe(true);
73
74 // Request-scope finalizers settle inline per invocation — the marker
75 // must reach the logs after a normal request.
76 const requestFinalized = yield* filterLogs(
77 logGroupName,
78 '"ALCHEMY_REQUEST_FINALIZED"',
79 ).pipe(
80 Effect.repeat({
81 schedule: Schedule.spaced("3 seconds"),
82 until: (found) => found,
83 times: 20,
84 }),
85 );
86 expect(requestFinalized).toBe(true);
87
88 yield* stack.destroy();
89
90 // Out-of-band proof the destroy removed the function from the cloud.
91 yield* Lambda.getFunction({ FunctionName: fn.functionName }).pipe(
92 Effect.flatMap(() =>
93 Effect.fail(new Error(`Function ${fn.functionName} still exists`)),
94 ),
95 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
96 Effect.retry({
97 schedule: Schedule.max([
98 Schedule.exponential(500),
99 Schedule.recurs(8),
100 ]),
101 }),
102 );
103 }),
104 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 600_000 },
105);
106
107/**
108 * The slow half: leave the sandbox idle until Lambda spins it down, then
109 * assert the SIGTERM handler closed the instance scope and the init-level
110 * finalizer's marker reached CloudWatch. Idle spin-down takes several
111 * minutes and is not under our control, so this only runs when explicitly
112 * requested:
113 *
114 * AWS_LAMBDA_TEST_SHUTDOWN=1 bun vitest run test/AWS/Lambda/Shutdown.test.ts
115 */
116test.provider.skipIf(!process.env.AWS_LAMBDA_TEST_SHUTDOWN)(
117 "instance-scope finalizers run at idle spin-down",
118 (stack) =>
119 Effect.gen(function* () {
120 const fn = yield* stack.deploy(
121 Effect.gen(function* () {
122 return yield* ShutdownProbe;
123 }),
124 );
125
126 const client = yield* HttpClient.HttpClient;
127 const res = yield* client.get(fn.functionUrl!).pipe(
128 Effect.retry({
129 schedule: Schedule.exponential("500 millis"),
130 times: 10,
131 }),
132 );
133 expect(res.status).toBe(200);
134
135 // Do NOT invoke again — every invoke resets the idle clock. Poll the
136 // log group until the shutdown marker lands. No stack.destroy: keep
137 // the deployment so a re-run continues observing the same function.
138 const logGroupName = `/aws/lambda/${fn.functionName}`;
139 const finalized = yield* filterLogs(
140 logGroupName,
141 '"ALCHEMY_INSTANCE_FINALIZED"',
142 ).pipe(
143 Effect.repeat({
144 schedule: Schedule.spaced("30 seconds"),
145 until: (found) => found,
146 times: 17,
147 }),
148 );
149 expect(finalized).toBe(true);
150
151 yield* stack.destroy();
152 }),
153 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 600_000 },
154);