diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 804f2440a9..3fce6af9c4 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -8,10 +8,33 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import { TestClock } from "effect/testing"; +import { vi } from "vitest"; import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap"; import { assertNone, assertSome } from "@effect/vitest/utils"; +const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openSync: (...args: Parameters) => { + const [filePath, flags] = args; + if ( + typeof filePath === "string" && + filePath === openSyncInterceptor.failPath && + flags === "r" + ) { + const error = new Error("no such device or address"); + Object.assign(error, { code: "ENXIO" }); + throw error; + } + return (actual.openSync as (...a: typeof args) => number)(...args); + }, + }; +}); + const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { @@ -47,6 +70,36 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); + it.effect("falls back to reading the inherited fd when path duplication fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + + yield* fs.writeFileString( + filePath, + `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({ + mode: "desktop", + })}\n`, + ); + + // Open without acquireRelease: the direct-stream fallback uses autoClose: true, + // so the stream owns the fd lifecycle and closes it asynchronously on end. + // Attempting to also close it synchronously in a finalizer races with the + // stream's async close and produces an uncaught EBADF. + const fd = NFS.openSync(filePath, "r"); + + openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; + try { + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); + assertSome(payload, { + mode: "desktop", + }); + } finally { + openSyncInterceptor.failPath = null; + } + }), + ); + it.effect("returns none when the fd is unavailable", () => Effect.gen(function* () { const fd = NFS.openSync("/dev/null", "r"); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index b837ac6c18..0fb1352268 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -108,21 +108,26 @@ const makeBootstrapInputStream = (fd: number) => try: () => { const fdPath = resolveFdPath(fd); if (fdPath === undefined) { - const stream = new Net.Socket({ - fd, - readable: true, - writable: false, - }); - stream.setEncoding("utf8"); - return stream; + return makeDirectBootstrapStream(fd); } - const streamFd = NFS.openSync(fdPath, "r"); - return NFS.createReadStream("", { - fd: streamFd, - encoding: "utf8", - autoClose: true, - }); + let streamFd: number | undefined; + try { + streamFd = NFS.openSync(fdPath, "r"); + return NFS.createReadStream("", { + fd: streamFd, + encoding: "utf8", + autoClose: true, + }); + } catch (error) { + if (isBootstrapFdPathDuplicationError(error)) { + if (streamFd !== undefined) { + NFS.closeSync(streamFd); + } + return makeDirectBootstrapStream(fd); + } + throw error; + } }, catch: (error) => new BootstrapError({ @@ -131,6 +136,29 @@ const makeBootstrapInputStream = (fd: number) => }), }); +const makeDirectBootstrapStream = (fd: number): Readable => { + try { + return NFS.createReadStream("", { + fd, + encoding: "utf8", + autoClose: true, + }); + } catch { + const stream = new Net.Socket({ + fd, + readable: true, + writable: false, + }); + stream.setEncoding("utf8"); + return stream; + } +}; + +const isBootstrapFdPathDuplicationError = Predicate.compose( + Predicate.hasProperty("code"), + (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", +); + export function resolveFdPath( fd: number, platform: NodeJS.Platform = process.platform,