From 0ec32d5b5b0adaedc24932b0873ea311d91b2f25 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 24 Sep 2026 11:07:29 +0200 Subject: [PATCH 1/3] fix(stack): anchor the native Functions bootstrap and report runtime crashes from readiness Edge Runtime's config discovery walked up from the native bootstrap directory and adopted an ancestor package.json (for example in $HOME) as its workspace, crashing the main worker on unreadable entries. Write a deno.json next to the bootstrap so discovery stops there. Readiness waiters now receive the runtime's exit error when the session crashes, instead of ServiceStaleLaunch, which is kept for requested stops and restarts. Co-Authored-By: Claude Opus 5.5 --- .../stack/src/Service.integration.test.ts | 18 ++++- packages/stack/src/Service.ts | 21 ++++-- .../stack/src/functions/FunctionsBootstrap.ts | 7 ++ .../services/Functions.integration.test.ts | 66 +++++++++++++++++++ 4 files changed, 102 insertions(+), 10 deletions(-) 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/Service.ts b/packages/stack/src/Service.ts index 7e64997c7e..4bbce8ee58 100644 --- a/packages/stack/src/Service.ts +++ b/packages/stack/src/Service.ts @@ -135,6 +135,8 @@ interface SessionRecord { readonly health: Deferred.Deferred>; readonly stopped: Ref.Ref; readonly removed: Ref.Ref; + /** Set when the runtime exits without a requested stop. */ + readonly crash: Ref.Ref; } const contextFor = ( @@ -338,6 +340,7 @@ export const makeService = ( health, stopped: yield* Ref.make(false), removed: yield* Ref.make(false), + crash: yield* Ref.make(undefined), }; yield* Ref.set(current, record); yield* update({ @@ -373,14 +376,16 @@ export const makeService = ( Effect.gen(function* () { if ((yield* Ref.get(current)) !== record) return; const observation = yield* SubscriptionRef.get(observations); - const error = + const crash = observation.lifecycle === "stopping" - ? observation.error + ? undefined : (exitError("exit", exit) ?? new ServiceError({ operation: "exit", message: "Runtime exited unexpectedly", })); + yield* Ref.set(record.crash, crash); + const error = crash ?? observation.error; yield* update({ lifecycle: "stopping", health: undefined, @@ -574,16 +579,18 @@ export const makeService = ( return yield* new ServiceStaleLaunch({ id: options.id, launchId }); if (expectedLaunchId !== undefined && record.launchId !== expectedLaunchId) return yield* new ServiceStaleLaunch({ id: options.id, launchId }); - if ((yield* SubscriptionRef.get(observations)).lifecycle === "stopping") { + const stale = Effect.gen(function* () { + const crash = yield* Ref.get(record.crash); + if (crash !== undefined) return yield* crash; return yield* new ServiceStaleLaunch({ id: options.id, launchId }); - } + }); + if ((yield* SubscriptionRef.get(observations)).lifecycle === "stopping") return yield* stale; const healthResult = yield* Deferred.await(record.health); if ( (yield* Ref.get(current)) !== record || (yield* SubscriptionRef.get(observations)).lifecycle !== "running" - ) { - return yield* new ServiceStaleLaunch({ id: options.id, launchId }); - } + ) + return yield* stale; yield* healthResult; }); diff --git a/packages/stack/src/functions/FunctionsBootstrap.ts b/packages/stack/src/functions/FunctionsBootstrap.ts index a6fad9206c..14d9e00b87 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,12 @@ 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)); + // Stops Deno config discovery from adopting an ancestor package.json as its workspace. + yield* mapFs( + configFile, + "write functions bootstrap config", + fs.writeFileString(configFile, "{}\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..847e31297d 100644 --- a/packages/stack/src/services/Functions.integration.test.ts +++ b/packages/stack/src/services/Functions.integration.test.ts @@ -92,6 +92,72 @@ describe("service catalog", () => { }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + + it.live( + "boots the native bootstrap below an ancestor package.json with unreadable siblings", + () => + 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* 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), + ); + const functionsRoot = `${root}/project/supabase/functions`; + yield* fs.makeDirectory(`${functionsRoot}/hello`, { recursive: true }); + yield* fs.writeFileString( + `${functionsRoot}/hello/index.ts`, + 'Deno.serve(() => new Response("hello"));', + ); + 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("hello"); + yield* instance.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); }); for (const runtime of ["native", "docker"] as const) { From 54ef86e2ba235d07b5d4a02c2bfc89f24659e6c8 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 24 Sep 2026 11:22:20 +0200 Subject: [PATCH 2/3] test(stack): cover shared functions deno.json imports below the anchored bootstrap Co-Authored-By: Claude Opus 5.5 --- .../src/services/Functions.integration.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/stack/src/services/Functions.integration.test.ts b/packages/stack/src/services/Functions.integration.test.ts index 847e31297d..f0c7630f08 100644 --- a/packages/stack/src/services/Functions.integration.test.ts +++ b/packages/stack/src/services/Functions.integration.test.ts @@ -94,7 +94,7 @@ describe("service catalog", () => { ); it.live( - "boots the native bootstrap below an ancestor package.json with unreadable siblings", + "boots below an ancestor package.json and keeps shared functions deno.json imports", () => Effect.scoped( Effect.gen(function* () { @@ -114,9 +114,18 @@ describe("service catalog", () => { ); 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`, - 'Deno.serve(() => new Response("hello"));', + 'import { message } from "shared-message"; Deno.serve(() => new Response(message));', ); const recipe = yield* makeServiceRecipe( { @@ -152,7 +161,7 @@ describe("service catalog", () => { 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("hello"); + expect(yield* response.text).toBe("shared"); yield* instance.stop; }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), From cb5a47937545381e30fe69472421e24a9cd7dec6 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 24 Sep 2026 11:40:29 +0200 Subject: [PATCH 3/3] fix(stack): make the Functions bootstrap its own empty Deno workspace root A plain deno.json still joins an ancestor Deno workspace and fails membership. An empty workspace root stops config discovery before any ancestor package.json or workspace. Co-Authored-By: Claude Opus 5.5 --- .../stack/src/functions/FunctionsBootstrap.ts | 5 +- .../services/Functions.integration.test.ts | 147 ++++++++++-------- 2 files changed, 84 insertions(+), 68 deletions(-) diff --git a/packages/stack/src/functions/FunctionsBootstrap.ts b/packages/stack/src/functions/FunctionsBootstrap.ts index 14d9e00b87..340deed73b 100644 --- a/packages/stack/src/functions/FunctionsBootstrap.ts +++ b/packages/stack/src/functions/FunctionsBootstrap.ts @@ -84,11 +84,12 @@ 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)); - // Stops Deno config discovery from adopting an ancestor package.json as its workspace. + // 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, "{}\n", { mode: 0o600 }), + fs.writeFileString(configFile, '{"workspace":[]}\n', { mode: 0o600 }), ); yield* Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/services/Functions.integration.test.ts b/packages/stack/src/services/Functions.integration.test.ts index f0c7630f08..d58275db2b 100644 --- a/packages/stack/src/services/Functions.integration.test.ts +++ b/packages/stack/src/services/Functions.integration.test.ts @@ -93,80 +93,95 @@ describe("service catalog", () => { ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); - it.live( - "boots below an ancestor package.json and keeps shared functions deno.json imports", - () => - Effect.scoped( + const ancestors = [ + { + name: "a package.json with an unreadable sibling", + setup: (fs: FileSystem.FileSystem, root: string) => 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* 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), ); - 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 }, - ); + }, + { + 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) {