| 1 | import * as AWS from "@/AWS"; |
| 2 | import { createContainerRuntimeContext } from "@/AWS/ECS/Task.ts"; |
| 3 | import { isResolved } from "@/Diff.ts"; |
| 4 | import * as Plan from "@/Plan"; |
| 5 | import * as Provider from "@/Provider.ts"; |
| 6 | import * as Stack from "@/Stack"; |
| 7 | import { Stage } from "@/Stage"; |
| 8 | import { inMemoryState, State } from "@/State"; |
| 9 | import * as Test from "@/Test/Alchemy"; |
| 10 | import { expect } from "alchemy-test"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 14 | |
| 15 | // Regression for the container-platform serve OOM (fixed in c35217c2b): |
| 16 | // `createContainerRuntimeContext` wrapped the host `serve`, but the wrapper |
| 17 | // body read `base.serve` at CALL time — after `Object.assign(base, { serve })` |
| 18 | // had already replaced it with the wrapper itself. The moment any Task/ |
| 19 | // Service impl declared `{ fetch }` (any props form: plain object, Effect, |
| 20 | // inline class, or tagged `.make`), evaluating `serve` re-entered itself |
| 21 | // without bound, exhausting the heap (JSC MemoryExhaustion, ~8GB in ~60s) |
| 22 | // while the platform instance was being constructed at plan time. |
| 23 | // |
| 24 | // These tests run WITHOUT live AWS (stub provider + in-memory state) and are |
| 25 | // wrapped in `Effect.timeout`, so a reintroduced recursion fails fast with a |
| 26 | // TimeoutException instead of OOMing the whole test process. |
| 27 | |
| 28 | // ── unit: the serve wrapper must not re-enter itself ──────────────────────── |
| 29 | // Pre-fix this recursed unboundedly; the 2s timeout interrupts it long before |
| 30 | // memory becomes a problem. Post-fix it completes in microseconds (the base |
| 31 | // serve just registers a runner; `Http.serve` is a no-op without a server). |
| 32 | |
| 33 | const noopProvider = (platform: any) => |
| 34 | Provider.succeed(platform, { |
| 35 | list: () => Effect.succeed([]), |
| 36 | diff: Effect.fn(function* ({ news }) { |
| 37 | if (!isResolved(news)) return undefined; |
| 38 | }), |
| 39 | reconcile: Effect.fn(function* ({ output }) { |
| 40 | return output ?? ({ ok: true } as any); |
| 41 | }), |
| 42 | delete: Effect.fn(function* () {}), |
| 43 | }); |
| 44 | |
| 45 | const providers = () => |
| 46 | Layer.mergeAll(noopProvider(AWS.ECS.Service), noopProvider(AWS.ECS.Cluster)); |
| 47 | |
| 48 | const { test } = Test.make({ |
| 49 | providers: providers(), |
| 50 | state: inMemoryState(), |
| 51 | }); |
| 52 | |
| 53 | test( |
| 54 | "container runtime context serve terminates when the shape declares fetch", |
| 55 | Effect.gen(function* () { |
| 56 | const ctx = createContainerRuntimeContext("AWS.ECS.Service")("probe"); |
| 57 | const handler = Effect.succeed(HttpServerResponse.text("ok")); |
| 58 | yield* ctx |
| 59 | .serve(handler, { shape: { fetch: handler } }) |
| 60 | .pipe(Effect.timeout(2_000)); |
| 61 | // The handler was registered exactly once as a runner. |
| 62 | const { program } = yield* ctx.exports; |
| 63 | expect(program).toBeDefined(); |
| 64 | }), |
| 65 | { |
| 66 | tags: ["unit", "provider:aws", "provider:aws:ecs", "local"], |
| 67 | timeout: 10_000, |
| 68 | }, |
| 69 | ); |
| 70 | |
| 71 | // ── plan/apply-level: the inline class form with Effect-valued props ──────── |
| 72 | // The design-doc-promised form: `class X extends Service<X>()(id, propsEffect, |
| 73 | // impl) {}` where the props are an Effect yielding another resource. This is |
| 74 | // the exact shape that surfaced the serve recursion (the impl declares |
| 75 | // `fetch`, so plan-time construction evaluates the container serve wrapper). |
| 76 | // The stub provider keeps it entirely off the cloud; the shared in-memory |
| 77 | // state store lets us assert the resolved props that were persisted. |
| 78 | |
| 79 | const ProbeCluster = AWS.ECS.Cluster("ProbeCluster", { |
| 80 | clusterName: "inline-class-probe", |
| 81 | }); |
| 82 | |
| 83 | class InlineService extends AWS.ECS.Service<InlineService>()( |
| 84 | "InlineService", |
| 85 | // Props as an Effect — resolved through `transformProps` inside the |
| 86 | // platform's Self layer, unlike plain-object props. |
| 87 | Effect.gen(function* () { |
| 88 | const cluster = yield* ProbeCluster; |
| 89 | return { cluster, image: "nginx:1.27", port: 80 }; |
| 90 | }), |
| 91 | Effect.gen(function* () { |
| 92 | return { |
| 93 | fetch: Effect.succeed(HttpServerResponse.text("ok")), |
| 94 | }; |
| 95 | }), |
| 96 | ) {} |
| 97 | |
| 98 | test.provider( |
| 99 | "inline class form with Effect props constructs, plans, and applies", |
| 100 | (stack) => |
| 101 | Effect.gen(function* () { |
| 102 | // Class construction itself is eager (`cls.make(props, impl)` runs at |
| 103 | // class-definition time); reaching this line proves it didn't blow up. |
| 104 | // `LogicalId` is stamped on the class at runtime (see Platform.ts's |
| 105 | // eager inline branch) but not declared on the inline-form class type. |
| 106 | expect( |
| 107 | (InlineService as unknown as { LogicalId: string }).LogicalId, |
| 108 | ).toBe("InlineService"); |
| 109 | |
| 110 | const deployed = yield* stack |
| 111 | .deploy( |
| 112 | Effect.gen(function* () { |
| 113 | yield* ProbeCluster; |
| 114 | return yield* InlineService; |
| 115 | }), |
| 116 | ) |
| 117 | // Pre-fix, plan-time construction recursed inside serve; interrupt |
| 118 | // fast instead of exhausting the heap. |
| 119 | .pipe(Effect.timeout(30_000)); |
| 120 | |
| 121 | expect(deployed).toEqual({ ok: true }); |
| 122 | |
| 123 | // The Effect-valued props resolved through the cluster yield and were |
| 124 | // persisted: the service's stored props reference the cluster attrs. |
| 125 | const state = yield* yield* State; |
| 126 | const row = yield* state.get({ |
| 127 | stack: stack.name, |
| 128 | stage: stack.stage, |
| 129 | fqn: "InlineService", |
| 130 | }); |
| 131 | const props = (row as { props?: Record<string, unknown> } | undefined) |
| 132 | ?.props as |
| 133 | | { cluster?: { clusterName?: string }; image?: string; port?: number } |
| 134 | | undefined; |
| 135 | expect(props?.image).toBe("nginx:1.27"); |
| 136 | expect(props?.port).toBe(80); |
| 137 | }), |
| 138 | { |
| 139 | tags: ["unit", "provider:aws", "provider:aws:ecs", "local"], |
| 140 | timeout: 60_000, |
| 141 | }, |
| 142 | ); |
| 143 | |
| 144 | // ── plan-only: multiple yields of the inline class stay stable ────────────── |
| 145 | // Each `yield*` of an inline-form class rebuilds its Self layer in a transient |
| 146 | // region; the resource is memoized by logical id, so repeated yields must |
| 147 | // converge on one resource and one plan entry (no growth, no divergence). |
| 148 | const makePlan = <A, Err, Req>( |
| 149 | effect: Effect.Effect<A, Err, Req>, |
| 150 | ): Effect.Effect<Plan.Plan<A>, Err, State> => |
| 151 | effect.pipe( |
| 152 | // @ts-expect-error - Stack.make's typing erases R unsoundly here |
| 153 | Stack.make({ |
| 154 | name: "inline-class-plan", |
| 155 | providers: providers(), |
| 156 | state: inMemoryState(), |
| 157 | }), |
| 158 | Effect.provideService(Stage, "test"), |
| 159 | Effect.flatMap((compiled: any) => |
| 160 | Plan.make(compiled).pipe(Effect.provide(compiled.services)), |
| 161 | ), |
| 162 | ); |
| 163 | |
| 164 | test( |
| 165 | "repeated yields of the inline class produce a single plan entry", |
| 166 | Effect.gen(function* () { |
| 167 | const plan = yield* Effect.gen(function* () { |
| 168 | yield* ProbeCluster; |
| 169 | yield* InlineService; |
| 170 | yield* InlineService; |
| 171 | return yield* InlineService; |
| 172 | }).pipe(makePlan, Effect.timeout(30_000)); |
| 173 | |
| 174 | expect(plan.resources["InlineService"]?.action).toBe("create"); |
| 175 | expect( |
| 176 | Object.keys(plan.resources).filter((fqn) => |
| 177 | fqn.includes("InlineService"), |
| 178 | ), |
| 179 | ).toEqual(["InlineService"]); |
| 180 | }), |
| 181 | { |
| 182 | tags: ["unit", "provider:aws", "provider:aws:ecs", "local"], |
| 183 | timeout: 60_000, |
| 184 | }, |
| 185 | ); |