diff --git a/packages/stack/src/Service.integration.test.ts b/packages/stack/src/Service.integration.test.ts index 10d881b5f1..16b104c005 100644 --- a/packages/stack/src/Service.integration.test.ts +++ b/packages/stack/src/Service.integration.test.ts @@ -5,6 +5,7 @@ import { ServiceDestroyed, ServiceError, ServiceLaunchError, + ServiceStaleLaunch, type RuntimeSession, type ServiceDefinition, type ServiceInstance, @@ -890,7 +891,7 @@ describe("service kernel", () => { ), ); - it.live("cleans an exact runtime after an unexpected exit during health", () => + it.live("fails readiness with the crash error and cleans the exact runtime", () => Effect.scoped( Effect.gen(function* () { const fixture = yield* makeFixture; @@ -918,7 +919,13 @@ describe("service kernel", () => { yield* open(plan.removeGate); yield* Fiber.join(stopping); yield* Fiber.join(stopped); - expect(Exit.isFailure(yield* Fiber.await(ready))).toBe(true); + const result = yield* Fiber.await(ready); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const failure = result.cause.reasons.find(Cause.isFailReason); + expect(failure?.error).toBeInstanceOf(ServiceError); + expect(failure?.error).toMatchObject({ operation: "process", message: "crashed" }); + } expect((yield* fixture.service.get).lifecycle).toBe("stopped"); expect((yield* Ref.get(plan.state)).removed).toBe(true); expect((yield* fixture.service.get).wakeEnabled).toBe(false); @@ -1001,7 +1008,12 @@ describe("service kernel", () => { yield* Deferred.await(first.removeStarted); yield* open(first.removeGate); yield* Fiber.join(stopping); - expect(Exit.isFailure(yield* Fiber.await(oldReady))).toBe(true); + const stale = yield* Fiber.await(oldReady); + expect(Exit.isFailure(stale)).toBe(true); + if (Exit.isFailure(stale)) + expect(stale.cause.reasons.find(Cause.isFailReason)?.error).toBeInstanceOf( + ServiceStaleLaunch, + ); yield* open(second.launchGate); yield* fixture.service.start; diff --git a/packages/stack/src/functions/FunctionsBootstrap.ts b/packages/stack/src/functions/FunctionsBootstrap.ts index a6fad9206c..340deed73b 100644 --- a/packages/stack/src/functions/FunctionsBootstrap.ts +++ b/packages/stack/src/functions/FunctionsBootstrap.ts @@ -69,6 +69,7 @@ export const makeFunctionsBootstrapOwner = Effect.fn("FunctionsBootstrap.makeOwn if (input.content.includes("\u0000")) return yield* failure("Functions bootstrap contains an invalid character"); const target = path.join(root, "index.ts"); + const configFile = path.join(root, "deno.json"); return yield* Effect.gen(function* () { const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError((cause) => @@ -83,6 +84,13 @@ export const makeFunctionsBootstrapOwner = Effect.fn("FunctionsBootstrap.makeOwn fs.makeDirectory(root, { recursive: true, mode: 0o700 }), ); yield* mapFs(root, "secure functions bootstrap directory", fs.chmod(root, 0o700)); + // An empty workspace root stops Deno config discovery before any ancestor package.json or + // workspace; a plain `{}` still joins an ancestor Deno workspace and fails membership. + yield* mapFs( + configFile, + "write functions bootstrap config", + fs.writeFileString(configFile, '{"workspace":[]}\n', { mode: 0o600 }), + ); yield* Effect.scoped( Effect.gen(function* () { const file = yield* mapFs( diff --git a/packages/stack/src/services/Functions.integration.test.ts b/packages/stack/src/services/Functions.integration.test.ts index 70d12dc7d9..d58275db2b 100644 --- a/packages/stack/src/services/Functions.integration.test.ts +++ b/packages/stack/src/services/Functions.integration.test.ts @@ -92,6 +92,96 @@ describe("service catalog", () => { }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + + const ancestors = [ + { + name: "a package.json with an unreadable sibling", + setup: (fs: FileSystem.FileSystem, root: string) => + Effect.gen(function* () { + yield* fs.writeFileString(`${root}/package.json`, '{"name":"home","private":true}'); + yield* fs.makeDirectory(`${root}/unreadable`); + yield* fs.chmod(`${root}/unreadable`, 0o000); + yield* Effect.addFinalizer(() => + fs.chmod(`${root}/unreadable`, 0o755).pipe(Effect.ignore), + ); + }), + }, + { + name: "a Deno workspace that includes the project functions", + setup: (fs: FileSystem.FileSystem, root: string) => + fs.writeFileString(`${root}/deno.json`, '{"workspace":["./project/supabase/functions"]}'), + }, + ]; + for (const ancestor of ancestors) + it.live( + `boots below ${ancestor.name} and keeps shared functions deno.json imports`, + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; + const temporaryRoot = `${process.cwd()}/tmp`; + yield* fs.makeDirectory(temporaryRoot, { recursive: true }); + const root = yield* fs.makeTempDirectoryScoped({ + directory: temporaryRoot, + prefix: "functions-ancestor-", + }); + yield* ancestor.setup(fs, root); + const functionsRoot = `${root}/project/supabase/functions`; + yield* fs.makeDirectory(`${functionsRoot}/hello`, { recursive: true }); + yield* fs.makeDirectory(`${functionsRoot}/_shared`, { recursive: true }); + yield* fs.writeFileString( + `${functionsRoot}/deno.json`, + '{"imports":{"shared-message":"./_shared/message.ts"}}', + ); + yield* fs.writeFileString( + `${functionsRoot}/_shared/message.ts`, + 'export const message = "shared";', + ); + yield* fs.writeFileString( + `${functionsRoot}/hello/index.ts`, + 'import { message } from "shared-message"; Deno.serve(() => new Response(message));', + ); + const recipe = yield* makeServiceRecipe( + { + service: "functions", + config: { + functionsRoot, + bootstrap: yield* bundleServeMainTemplate, + verifyJwt: false, + }, + }, + { + ...options(root), + stackId: "d".repeat(64), + instanceId: "ancestor", + cacheRoot: "/tmp/supabase-stack-artifacts", + }, + ); + const logs = yield* Ref.make(""); + yield* recipe.logs.pipe( + Stream.runForEach(({ bytes }) => + Ref.update(logs, (text) => text + new TextDecoder().decode(bytes)), + ), + Effect.forkScoped({ startImmediately: true }), + ); + const instance = yield* makeService(recipe.definition, { + id: "ancestor", + config: recipe.creation, + }); + yield* instance.start; + yield* instance.ready.pipe( + Effect.tapError(() => Ref.get(logs).pipe(Effect.flatMap(Effect.logError))), + ); + const endpoint = yield* recipe.endpoint("http"); + const response = yield* client.get(`http://${endpoint.host}:${endpoint.port}/hello`); + expect(response.status, yield* Ref.get(logs)).toBe(200); + expect(yield* response.text).toBe("shared"); + yield* instance.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); }); for (const runtime of ["native", "docker"] as const) {