From 369b2d90857f12dcd0356f12d9affa3ee653d5d6 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:45:35 +0530 Subject: [PATCH 1/2] fix(stack): release the log follower as soon as it exits --- .../functions/serve/serve.integration.test.ts | 16 ++++ .../src/runtime/Container.integration.test.ts | 78 +++++++++++++++++++ packages/stack/src/runtime/Container.ts | 28 +++++-- 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index 6b5215fb2a..7c176ed579 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -349,6 +349,8 @@ function mockFileWatcher(expectedPaths: ReadonlyArray = []) { function mockDockerLogSpawner(behaviors: ReadonlyArray) { const spawned: Array<{ command: string; args: ReadonlyArray }> = []; let index = 0; + let liveHandles = 0; + let maxLiveHandles = 0; return { layer: Layer.succeed( @@ -364,6 +366,16 @@ function mockDockerLogSpawner(behaviors: ReadonlyArray) { args: [...command.args], }; spawned.push(record); + yield* Effect.acquireRelease( + Effect.sync(() => { + liveHandles += 1; + maxLiveHandles = Math.max(maxLiveHandles, liveHandles); + }), + () => + Effect.sync(() => { + liveHandles -= 1; + }), + ); const behavior = behaviors[Math.min(index, behaviors.length - 1)] ?? {}; index += 1; if (behavior.onSpawn !== undefined) yield* behavior.onSpawn(); @@ -396,6 +408,9 @@ function mockDockerLogSpawner(behaviors: ReadonlyArray) { get spawned() { return spawned; }, + get maxLiveHandles() { + return maxLiveHandles; + }, }; } @@ -2629,6 +2644,7 @@ describe("functions serve integration", () => { expect(error.message).toContain("supabase_edge_runtime_test-project"); expect(error.message).toContain("5 times"); } + expect(childSpawner.maxLiveHandles).toBe(1); }); }, ); diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index d2fc743df5..fe42462fc8 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -282,6 +282,64 @@ describe("container process adapter", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("releases a log follower that exits while its container keeps running", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const released = yield* Deferred.make(); + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeLogFollowerSpawner( + delegate, + released, + "console.error('injected log stream failure'); process.exit(1)", + ), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launch({ + image, + stackId: "s".repeat(64), + instanceId: "dropped-log-follower", + env: {}, + args: ["-e", "setInterval(() => {}, 1000)"], + }); + yield* Deferred.await(released).pipe(Effect.timeout("10 seconds")); + expect(yield* running(process.id)).toBe(true); + yield* process.discard; + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("releases a still running log follower when its launch scope closes", () => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const released = yield* Deferred.make(); + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeLogFollowerSpawner(delegate, released, "setInterval(() => {}, 1000)"), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launch({ + image, + stackId: "t".repeat(64), + instanceId: "live-log-follower", + env: {}, + args: ["-e", "setInterval(() => {}, 1000)"], + }); + expect(yield* Deferred.isDone(released)).toBe(false); + yield* process.discard; + }), + ); + expect(yield* Deferred.isDone(released)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("treats an externally removed container as already stopped", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -854,6 +912,26 @@ const makeStopFailureSpawner = ( return delegate.spawn(command); }); +const makeLogFollowerSpawner = ( + delegate: ChildProcessSpawnerService["Service"], + released: Deferred.Deferred, + script: string, +) => + ChildProcessSpawner.make((command) => { + if ( + ChildProcess.isStandardCommand(command) && + command.command === "docker" && + command.args[0] === "logs" + ) + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => Deferred.succeed(released, undefined)); + return yield* delegate.spawn( + ChildProcess.make(process.execPath, ["-e", script], { stdin: "ignore" }), + ); + }); + return delegate.spawn(command); + }); + const makeLostRemoveResultSpawner = ( delegate: ChildProcessSpawnerService["Service"], lostResult: Ref.Ref, diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 441bdde76e..b07f000704 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -529,13 +529,27 @@ export const makeContainerRuntime = (options: { } const logProcess = attached ?? - (yield* spawner - .spawn( - ChildProcess.make(options.engine, ["logs", "--follow", name], { - stdin: "ignore", - }), - ) - .pipe(Effect.mapError((cause) => errorFor("logs", cause)))); + (yield* Effect.gen(function* () { + // Released on exit, since a release left for service stop can hit a reused pid. + const followerScope = yield* Scope.fork(owner); + const follower = yield* spawner + .spawn( + ChildProcess.make(options.engine, ["logs", "--follow", name], { + stdin: "ignore", + }), + ) + .pipe( + Scope.provide(followerScope), + Effect.mapError((cause) => errorFor("logs", cause)), + ); + yield* Effect.forkIn( + Effect.exit(follower.exitCode).pipe( + Effect.andThen(Scope.close(followerScope, Exit.void)), + ), + owner, + ); + return follower; + })); return { ...partial, ports, From 79c39d6e8551eb23905d90a30baf8696cc8370c1 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:23:44 +0530 Subject: [PATCH 2/2] test(stack): assert follower output survives its release --- packages/stack/src/runtime/Container.integration.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index fe42462fc8..5f5a684fce 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -306,6 +306,13 @@ describe("container process adapter", () => { args: ["-e", "setInterval(() => {}, 1000)"], }); yield* Deferred.await(released).pipe(Effect.timeout("10 seconds")); + expect( + yield* process.stderr.pipe( + Stream.decodeText, + Stream.mkString, + Effect.timeout("10 seconds"), + ), + ).toContain("injected log stream failure"); expect(yield* running(process.id)).toBe(true); yield* process.discard; }),