diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 84cf85c3213..05155f32ec4 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -3,7 +3,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Duration from "effect/Duration"; @@ -13,10 +13,19 @@ import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { readBootstrapEnvelope } from "./bootstrap.ts"; +import { + BootstrapEnvelopeDecodeError, + BootstrapFdStatError, + BootstrapInputStreamOpenError, + readBootstrapEnvelope, +} from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; -const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); +const openSyncInterceptor = vi.hoisted(() => ({ + failPath: null as string | null, + errorCode: "ENXIO", +})); +const fstatSyncInterceptor = vi.hoisted(() => ({ failFd: null as number | null })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); @@ -29,12 +38,20 @@ vi.mock("node:fs", async (importOriginal) => { filePath === openSyncInterceptor.failPath && flags === "r" ) { - const error = new Error("no such device or address"); - Object.assign(error, { code: "ENXIO" }); + const error = new Error(`open failed with ${openSyncInterceptor.errorCode}`); + Object.assign(error, { code: openSyncInterceptor.errorCode }); throw error; } return (actual.openSync as (...a: typeof args) => number)(...args); }, + fstatSync: (...args: Parameters) => { + if (args[0] === fstatSyncInterceptor.failFd) { + const error = new Error("permission denied"); + Object.assign(error, { code: "EACCES" }); + throw error; + } + return (actual.fstatSync as (...a: typeof args) => NodeFS.Stats)(...args); + }, }; }); @@ -94,6 +111,39 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); + it.effect("preserves fd path, platform, and cause when opening the input stream fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + const fdPath = `/proc/self/fd/${fd}`; + + openSyncInterceptor.failPath = fdPath; + openSyncInterceptor.errorCode = "EIO"; + try { + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.provideService(HostProcessPlatform, "linux"), Effect.flip); + + assert.instanceOf(error, BootstrapInputStreamOpenError); + assert.equal(error.fd, fd); + assert.equal(error.platform, "linux"); + assert.equal(error.fdPath, fdPath); + assert.equal((error.cause as NodeJS.ErrnoException).code, "EIO"); + assert.equal( + error.message, + `Failed to open bootstrap input stream for file descriptor ${fd} via '${fdPath}' on 'linux'.`, + ); + } finally { + openSyncInterceptor.failPath = null; + openSyncInterceptor.errorCode = "ENXIO"; + } + }), + ); + it.effect("returns none when the fd is unavailable", () => Effect.gen(function* () { const fd = NodeFS.openSync("/dev/null", "r"); @@ -104,6 +154,53 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); + it.effect("preserves fd and cause when stat fails for a non-availability reason", () => + Effect.gen(function* () { + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync("/dev/null", "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + + fstatSyncInterceptor.failFd = fd; + try { + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.flip); + + assert.instanceOf(error, BootstrapFdStatError); + assert.equal(error.fd, fd); + assert.equal((error.cause as NodeJS.ErrnoException).code, "EACCES"); + assert.equal(error.message, `Failed to stat bootstrap file descriptor ${fd}.`); + } finally { + fstatSyncInterceptor.failFd = null; + } + }), + ); + + it.effect("preserves fd and schema cause when decoding the envelope fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + yield* fs.writeFileString(filePath, '{"mode":42}\n'); + + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.flip); + + assert.instanceOf(error, BootstrapEnvelopeDecodeError); + assert.equal(error.fd, fd); + assert.isDefined(error.cause); + assert.equal( + error.message, + `Failed to decode bootstrap envelope from file descriptor ${fd}.`, + ); + }), + ); + it.effect("returns none when the bootstrap read times out before any value arrives", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 1114ad8af90..0f2a5a436a3 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -4,7 +4,6 @@ import * as NodeNet from "node:net"; import * as NodeReadline from "node:readline"; import type * as NodeStream from "node:stream"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; @@ -13,10 +12,64 @@ import * as Schema from "effect/Schema"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -class BootstrapError extends Data.TaggedError("BootstrapError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class BootstrapFdStatError extends Schema.TaggedErrorClass()( + "BootstrapFdStatError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to stat bootstrap file descriptor ${this.fd}.`; + } +} + +export class BootstrapInputStreamOpenError extends Schema.TaggedErrorClass()( + "BootstrapInputStreamOpenError", + { + fd: Schema.Number, + platform: Schema.String, + fdPath: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const path = this.fdPath === undefined ? "" : ` via '${this.fdPath}'`; + return `Failed to open bootstrap input stream for file descriptor ${this.fd}${path} on '${this.platform}'.`; + } +} + +export class BootstrapEnvelopeReadError extends Schema.TaggedErrorClass()( + "BootstrapEnvelopeReadError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read bootstrap envelope from file descriptor ${this.fd}.`; + } +} + +export class BootstrapEnvelopeDecodeError extends Schema.TaggedErrorClass()( + "BootstrapEnvelopeDecodeError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode bootstrap envelope from file descriptor ${this.fd}.`; + } +} + +export const BootstrapError = Schema.Union([ + BootstrapFdStatError, + BootstrapInputStreamOpenError, + BootstrapEnvelopeReadError, + BootstrapEnvelopeDecodeError, +]); +export type BootstrapError = typeof BootstrapError.Type; export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* ( schema: Schema.Codec, @@ -32,7 +85,10 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function const timeoutMs = options?.timeoutMs ?? 1000; - return yield* Effect.callback, BootstrapError>((resume) => { + return yield* Effect.callback< + Option.Option, + BootstrapEnvelopeReadError | BootstrapEnvelopeDecodeError + >((resume) => { const input = NodeReadline.createInterface({ input: stream, crlfDelay: Infinity, @@ -53,8 +109,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function } resume( Effect.fail( - new BootstrapError({ - message: "Failed to read bootstrap envelope.", + new BootstrapEnvelopeReadError({ + fd, cause: error, }), ), @@ -68,8 +124,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function } else { resume( Effect.fail( - new BootstrapError({ - message: "Failed to decode bootstrap envelope.", + new BootstrapEnvelopeDecodeError({ + fd, cause: parsed.failure, }), ), @@ -98,24 +154,24 @@ const isFdReady = (fd: number) => Effect.try({ try: () => NodeFS.fstatSync(fd), catch: (error) => - new BootstrapError({ - message: "Failed to stat bootstrap fd.", + new BootstrapFdStatError({ + fd, cause: error, }), }).pipe( Effect.as(true), - Effect.catchIf( - (error) => isUnavailableBootstrapFdError(error.cause), - () => Effect.succeed(false), - ), + Effect.catchTags({ + BootstrapFdStatError: (error) => + isUnavailableBootstrapFdError(error.cause) ? Effect.succeed(false) : Effect.fail(error), + }), ); const makeBootstrapInputStream = (fd: number) => Effect.gen(function* () { const platform = yield* HostProcessPlatform; - return yield* Effect.try({ + const fdPath = resolveFdPath(fd, platform); + return yield* Effect.try({ try: () => { - const fdPath = resolveFdPath(fd, platform); if (fdPath === undefined) { return makeDirectBootstrapStream(fd); } @@ -139,8 +195,10 @@ const makeBootstrapInputStream = (fd: number) => } }, catch: (error) => - new BootstrapError({ - message: "Failed to duplicate bootstrap fd.", + new BootstrapInputStreamOpenError({ + fd, + platform, + ...(fdPath === undefined ? {} : { fdPath }), cause: error, }), });