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 f7e945933e..55aaad7494 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -340,6 +340,71 @@ 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* 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; + }), + ).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; @@ -912,6 +977,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 c300a013bd..8217ad3461 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -559,13 +559,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,