authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 10:54:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 10:54:12-07:00
log8c3755aeccd5fc90fe66f6ca4d776c0e370fe46e
treeb25d05b1919cf6fe97f455a482f04132fe62556f
parentb4fe8faa1d59aa602a04737aa4054ee8acb62193
signature Signed by PGP key B5690EEEBB952194

[codex] Structure bootstrap errors (#3256)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 180 insertions(+), 25 deletions(-)

apps/server/src/bootstrap.test.ts+102-5
...@@ -3,7 +3,7 @@ import * as NodeFS from "node:fs";...@@ -3,7 +3,7 @@ import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";3import * as NodePath from "node:path";
4import * as NodeChildProcess from "node:child_process";4import * as NodeChildProcess from "node:child_process";
5import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { it } from "@effect/vitest";6import { assert, it } from "@effect/vitest";
7import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import * as Duration from "effect/Duration";9import * as Duration from "effect/Duration";
...@@ -13,10 +13,19 @@ import * as TestClock from "effect/testing/TestClock";...@@ -13,10 +13,19 @@ import * as TestClock from "effect/testing/TestClock";
13import { vi } from "vite-plus/test";13import { vi } from "vite-plus/test";
14import { HostProcessPlatform } from "@t3tools/shared/hostProcess";14import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1515
16import { readBootstrapEnvelope } from "./bootstrap.ts";16import {
17 BootstrapEnvelopeDecodeError,
18 BootstrapFdStatError,
19 BootstrapInputStreamOpenError,
20 readBootstrapEnvelope,
21} from "./bootstrap.ts";
17import { assertNone, assertSome } from "@effect/vitest/utils";22import { assertNone, assertSome } from "@effect/vitest/utils";
1823
19const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null }));24const openSyncInterceptor = vi.hoisted(() => ({
25 failPath: null as string | null,
26 errorCode: "ENXIO",
27}));
28const fstatSyncInterceptor = vi.hoisted(() => ({ failFd: null as number | null }));
2029
21vi.mock("node:fs", async (importOriginal) => {30vi.mock("node:fs", async (importOriginal) => {
22 const actual = await importOriginal<typeof import("node:fs")>();31 const actual = await importOriginal<typeof import("node:fs")>();
...@@ -29,12 +38,20 @@ vi.mock("node:fs", async (importOriginal) => {...@@ -29,12 +38,20 @@ vi.mock("node:fs", async (importOriginal) => {
29 filePath === openSyncInterceptor.failPath &&38 filePath === openSyncInterceptor.failPath &&
30 flags === "r"39 flags === "r"
31 ) {40 ) {
32 const error = new Error("no such device or address");41 const error = new Error(`open failed with ${openSyncInterceptor.errorCode}`);
33 Object.assign(error, { code: "ENXIO" });42 Object.assign(error, { code: openSyncInterceptor.errorCode });
34 throw error;43 throw error;
35 }44 }
36 return (actual.openSync as (...a: typeof args) => number)(...args);45 return (actual.openSync as (...a: typeof args) => number)(...args);
37 },46 },
47 fstatSync: (...args: Parameters<typeof actual.fstatSync>) => {
48 if (args[0] === fstatSyncInterceptor.failFd) {
49 const error = new Error("permission denied");
50 Object.assign(error, { code: "EACCES" });
51 throw error;
52 }
53 return (actual.fstatSync as (...a: typeof args) => NodeFS.Stats)(...args);
54 },
38 };55 };
39});56});
4057
...@@ -94,6 +111,39 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {...@@ -94,6 +111,39 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {
94 }),111 }),
95 );112 );
96113
114 it.effect("preserves fd path, platform, and cause when opening the input stream fails", () =>
115 Effect.gen(function* () {
116 const fs = yield* FileSystem.FileSystem;
117 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
118 const fd = yield* Effect.acquireRelease(
119 Effect.sync(() => NodeFS.openSync(filePath, "r")),
120 (fd) => Effect.sync(() => NodeFS.closeSync(fd)),
121 );
122 const fdPath = `/proc/self/fd/${fd}`;
123
124 openSyncInterceptor.failPath = fdPath;
125 openSyncInterceptor.errorCode = "EIO";
126 try {
127 const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, {
128 timeoutMs: 100,
129 }).pipe(Effect.provideService(HostProcessPlatform, "linux"), Effect.flip);
130
131 assert.instanceOf(error, BootstrapInputStreamOpenError);
132 assert.equal(error.fd, fd);
133 assert.equal(error.platform, "linux");
134 assert.equal(error.fdPath, fdPath);
135 assert.equal((error.cause as NodeJS.ErrnoException).code, "EIO");
136 assert.equal(
137 error.message,
138 `Failed to open bootstrap input stream for file descriptor ${fd} via '${fdPath}' on 'linux'.`,
139 );
140 } finally {
141 openSyncInterceptor.failPath = null;
142 openSyncInterceptor.errorCode = "ENXIO";
143 }
144 }),
145 );
146
97 it.effect("returns none when the fd is unavailable", () =>147 it.effect("returns none when the fd is unavailable", () =>
98 Effect.gen(function* () {148 Effect.gen(function* () {
99 const fd = NodeFS.openSync("/dev/null", "r");149 const fd = NodeFS.openSync("/dev/null", "r");
...@@ -104,6 +154,53 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {...@@ -104,6 +154,53 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {
104 }),154 }),
105 );155 );
106156
157 it.effect("preserves fd and cause when stat fails for a non-availability reason", () =>
158 Effect.gen(function* () {
159 const fd = yield* Effect.acquireRelease(
160 Effect.sync(() => NodeFS.openSync("/dev/null", "r")),
161 (fd) => Effect.sync(() => NodeFS.closeSync(fd)),
162 );
163
164 fstatSyncInterceptor.failFd = fd;
165 try {
166 const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, {
167 timeoutMs: 100,
168 }).pipe(Effect.flip);
169
170 assert.instanceOf(error, BootstrapFdStatError);
171 assert.equal(error.fd, fd);
172 assert.equal((error.cause as NodeJS.ErrnoException).code, "EACCES");
173 assert.equal(error.message, `Failed to stat bootstrap file descriptor ${fd}.`);
174 } finally {
175 fstatSyncInterceptor.failFd = null;
176 }
177 }),
178 );
179
180 it.effect("preserves fd and schema cause when decoding the envelope fails", () =>
181 Effect.gen(function* () {
182 const fs = yield* FileSystem.FileSystem;
183 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
184 yield* fs.writeFileString(filePath, '{"mode":42}\n');
185
186 const fd = yield* Effect.acquireRelease(
187 Effect.sync(() => NodeFS.openSync(filePath, "r")),
188 (fd) => Effect.sync(() => NodeFS.closeSync(fd)),
189 );
190 const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, {
191 timeoutMs: 100,
192 }).pipe(Effect.flip);
193
194 assert.instanceOf(error, BootstrapEnvelopeDecodeError);
195 assert.equal(error.fd, fd);
196 assert.isDefined(error.cause);
197 assert.equal(
198 error.message,
199 `Failed to decode bootstrap envelope from file descriptor ${fd}.`,
200 );
201 }),
202 );
203
107 it.effect("returns none when the bootstrap read times out before any value arrives", () =>204 it.effect("returns none when the bootstrap read times out before any value arrives", () =>
108 Effect.gen(function* () {205 Effect.gen(function* () {
109 const fs = yield* FileSystem.FileSystem;206 const fs = yield* FileSystem.FileSystem;
apps/server/src/bootstrap.ts+78-20
...@@ -4,7 +4,6 @@ import * as NodeNet from "node:net";...@@ -4,7 +4,6 @@ import * as NodeNet from "node:net";
4import * as NodeReadline from "node:readline";4import * as NodeReadline from "node:readline";
5import type * as NodeStream from "node:stream";5import type * as NodeStream from "node:stream";
66
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";8import * as Option from "effect/Option";
10import * as Predicate from "effect/Predicate";9import * as Predicate from "effect/Predicate";
...@@ -13,10 +12,64 @@ import * as Schema from "effect/Schema";...@@ -13,10 +12,64 @@ import * as Schema from "effect/Schema";
13import { decodeJsonResult } from "@t3tools/shared/schemaJson";12import { decodeJsonResult } from "@t3tools/shared/schemaJson";
14import { HostProcessPlatform } from "@t3tools/shared/hostProcess";13import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1514
16class BootstrapError extends Data.TaggedError("BootstrapError")<{15export class BootstrapFdStatError extends Schema.TaggedErrorClass<BootstrapFdStatError>()(
17 readonly message: string;16 "BootstrapFdStatError",
18 readonly cause?: unknown;17 {
19}> {}18 fd: Schema.Number,
19 cause: Schema.Defect(),
20 },
21) {
22 override get message(): string {
23 return `Failed to stat bootstrap file descriptor ${this.fd}.`;
24 }
25}
26
27export class BootstrapInputStreamOpenError extends Schema.TaggedErrorClass<BootstrapInputStreamOpenError>()(
28 "BootstrapInputStreamOpenError",
29 {
30 fd: Schema.Number,
31 platform: Schema.String,
32 fdPath: Schema.optional(Schema.String),
33 cause: Schema.Defect(),
34 },
35) {
36 override get message(): string {
37 const path = this.fdPath === undefined ? "" : ` via '${this.fdPath}'`;
38 return `Failed to open bootstrap input stream for file descriptor ${this.fd}${path} on '${this.platform}'.`;
39 }
40}
41
42export class BootstrapEnvelopeReadError extends Schema.TaggedErrorClass<BootstrapEnvelopeReadError>()(
43 "BootstrapEnvelopeReadError",
44 {
45 fd: Schema.Number,
46 cause: Schema.Defect(),
47 },
48) {
49 override get message(): string {
50 return `Failed to read bootstrap envelope from file descriptor ${this.fd}.`;
51 }
52}
53
54export class BootstrapEnvelopeDecodeError extends Schema.TaggedErrorClass<BootstrapEnvelopeDecodeError>()(
55 "BootstrapEnvelopeDecodeError",
56 {
57 fd: Schema.Number,
58 cause: Schema.Defect(),
59 },
60) {
61 override get message(): string {
62 return `Failed to decode bootstrap envelope from file descriptor ${this.fd}.`;
63 }
64}
65
66export const BootstrapError = Schema.Union([
67 BootstrapFdStatError,
68 BootstrapInputStreamOpenError,
69 BootstrapEnvelopeReadError,
70 BootstrapEnvelopeDecodeError,
71]);
72export type BootstrapError = typeof BootstrapError.Type;
2073
21export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* <A, I>(74export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* <A, I>(
22 schema: Schema.Codec<A, I>,75 schema: Schema.Codec<A, I>,
...@@ -32,7 +85,10 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function...@@ -32,7 +85,10 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function
3285
33 const timeoutMs = options?.timeoutMs ?? 1000;86 const timeoutMs = options?.timeoutMs ?? 1000;
3487
35 return yield* Effect.callback<Option.Option<A>, BootstrapError>((resume) => {88 return yield* Effect.callback<
89 Option.Option<A>,
90 BootstrapEnvelopeReadError | BootstrapEnvelopeDecodeError
91 >((resume) => {
36 const input = NodeReadline.createInterface({92 const input = NodeReadline.createInterface({
37 input: stream,93 input: stream,
38 crlfDelay: Infinity,94 crlfDelay: Infinity,
...@@ -53,8 +109,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function...@@ -53,8 +109,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function
53 }109 }
54 resume(110 resume(
55 Effect.fail(111 Effect.fail(
56 new BootstrapError({112 new BootstrapEnvelopeReadError({
57 message: "Failed to read bootstrap envelope.",113 fd,
58 cause: error,114 cause: error,
59 }),115 }),
60 ),116 ),
...@@ -68,8 +124,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function...@@ -68,8 +124,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function
68 } else {124 } else {
69 resume(125 resume(
70 Effect.fail(126 Effect.fail(
71 new BootstrapError({127 new BootstrapEnvelopeDecodeError({
72 message: "Failed to decode bootstrap envelope.",128 fd,
73 cause: parsed.failure,129 cause: parsed.failure,
74 }),130 }),
75 ),131 ),
...@@ -98,24 +154,24 @@ const isFdReady = (fd: number) =>...@@ -98,24 +154,24 @@ const isFdReady = (fd: number) =>
98 Effect.try({154 Effect.try({
99 try: () => NodeFS.fstatSync(fd),155 try: () => NodeFS.fstatSync(fd),
100 catch: (error) =>156 catch: (error) =>
101 new BootstrapError({157 new BootstrapFdStatError({
102 message: "Failed to stat bootstrap fd.",158 fd,
103 cause: error,159 cause: error,
104 }),160 }),
105 }).pipe(161 }).pipe(
106 Effect.as(true),162 Effect.as(true),
107 Effect.catchIf(163 Effect.catchTags({
108 (error) => isUnavailableBootstrapFdError(error.cause),164 BootstrapFdStatError: (error) =>
109 () => Effect.succeed(false),165 isUnavailableBootstrapFdError(error.cause) ? Effect.succeed(false) : Effect.fail(error),
110 ),166 }),
111 );167 );
112168
113const makeBootstrapInputStream = (fd: number) =>169const makeBootstrapInputStream = (fd: number) =>
114 Effect.gen(function* () {170 Effect.gen(function* () {
115 const platform = yield* HostProcessPlatform;171 const platform = yield* HostProcessPlatform;
116 return yield* Effect.try<NodeStream.Readable, BootstrapError>({172 const fdPath = resolveFdPath(fd, platform);
173 return yield* Effect.try<NodeStream.Readable, BootstrapInputStreamOpenError>({
117 try: () => {174 try: () => {
118 const fdPath = resolveFdPath(fd, platform);
119 if (fdPath === undefined) {175 if (fdPath === undefined) {
120 return makeDirectBootstrapStream(fd);176 return makeDirectBootstrapStream(fd);
121 }177 }
...@@ -139,8 +195,10 @@ const makeBootstrapInputStream = (fd: number) =>...@@ -139,8 +195,10 @@ const makeBootstrapInputStream = (fd: number) =>
139 }195 }
140 },196 },
141 catch: (error) =>197 catch: (error) =>
142 new BootstrapError({198 new BootstrapInputStreamOpenError({
143 message: "Failed to duplicate bootstrap fd.",199 fd,
200 platform,
201 ...(fdPath === undefined ? {} : { fdPath }),
144 cause: error,202 cause: error,
145 }),203 }),
146 });204 });