Skip to content

Commit 8c3755a

Browse files
[codex] Structure bootstrap errors (#3256)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent b4fe8fa commit 8c3755a

2 files changed

Lines changed: 180 additions & 25 deletions

File tree

apps/server/src/bootstrap.test.ts

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as NodeFS from "node:fs";
33
import * as NodePath from "node:path";
44
import * as NodeChildProcess from "node:child_process";
55
import * as NodeServices from "@effect/platform-node/NodeServices";
6-
import { it } from "@effect/vitest";
6+
import { assert, it } from "@effect/vitest";
77
import * as FileSystem from "effect/FileSystem";
88
import * as Schema from "effect/Schema";
99
import * as Duration from "effect/Duration";
@@ -13,10 +13,19 @@ import * as TestClock from "effect/testing/TestClock";
1313
import { vi } from "vite-plus/test";
1414
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1515

16-
import { readBootstrapEnvelope } from "./bootstrap.ts";
16+
import {
17+
BootstrapEnvelopeDecodeError,
18+
BootstrapFdStatError,
19+
BootstrapInputStreamOpenError,
20+
readBootstrapEnvelope,
21+
} from "./bootstrap.ts";
1722
import { assertNone, assertSome } from "@effect/vitest/utils";
1823

19-
const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null }));
24+
const openSyncInterceptor = vi.hoisted(() => ({
25+
failPath: null as string | null,
26+
errorCode: "ENXIO",
27+
}));
28+
const fstatSyncInterceptor = vi.hoisted(() => ({ failFd: null as number | null }));
2029

2130
vi.mock("node:fs", async (importOriginal) => {
2231
const actual = await importOriginal<typeof import("node:fs")>();
@@ -29,12 +38,20 @@ vi.mock("node:fs", async (importOriginal) => {
2938
filePath === openSyncInterceptor.failPath &&
3039
flags === "r"
3140
) {
32-
const error = new Error("no such device or address");
33-
Object.assign(error, { code: "ENXIO" });
41+
const error = new Error(`open failed with ${openSyncInterceptor.errorCode}`);
42+
Object.assign(error, { code: openSyncInterceptor.errorCode });
3443
throw error;
3544
}
3645
return (actual.openSync as (...a: typeof args) => number)(...args);
3746
},
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+
},
3855
};
3956
});
4057

@@ -94,6 +111,39 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {
94111
}),
95112
);
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+
97147
it.effect("returns none when the fd is unavailable", () =>
98148
Effect.gen(function* () {
99149
const fd = NodeFS.openSync("/dev/null", "r");
@@ -104,6 +154,53 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {
104154
}),
105155
);
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+
107204
it.effect("returns none when the bootstrap read times out before any value arrives", () =>
108205
Effect.gen(function* () {
109206
const fs = yield* FileSystem.FileSystem;

apps/server/src/bootstrap.ts

Lines changed: 78 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import * as NodeNet from "node:net";
44
import * as NodeReadline from "node:readline";
55
import type * as NodeStream from "node:stream";
66

7-
import * as Data from "effect/Data";
87
import * as Effect from "effect/Effect";
98
import * as Option from "effect/Option";
109
import * as Predicate from "effect/Predicate";
@@ -13,10 +12,64 @@ import * as Schema from "effect/Schema";
1312
import { decodeJsonResult } from "@t3tools/shared/schemaJson";
1413
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1514

16-
class BootstrapError extends Data.TaggedError("BootstrapError")<{
17-
readonly message: string;
18-
readonly cause?: unknown;
19-
}> {}
15+
export class BootstrapFdStatError extends Schema.TaggedErrorClass<BootstrapFdStatError>()(
16+
"BootstrapFdStatError",
17+
{
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+
27+
export 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+
42+
export 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+
54+
export 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+
66+
export const BootstrapError = Schema.Union([
67+
BootstrapFdStatError,
68+
BootstrapInputStreamOpenError,
69+
BootstrapEnvelopeReadError,
70+
BootstrapEnvelopeDecodeError,
71+
]);
72+
export type BootstrapError = typeof BootstrapError.Type;
2073

2174
export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* <A, I>(
2275
schema: Schema.Codec<A, I>,
@@ -32,7 +85,10 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function
3285

3386
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) => {
3692
const input = NodeReadline.createInterface({
3793
input: stream,
3894
crlfDelay: Infinity,
@@ -53,8 +109,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function
53109
}
54110
resume(
55111
Effect.fail(
56-
new BootstrapError({
57-
message: "Failed to read bootstrap envelope.",
112+
new BootstrapEnvelopeReadError({
113+
fd,
58114
cause: error,
59115
}),
60116
),
@@ -68,8 +124,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function
68124
} else {
69125
resume(
70126
Effect.fail(
71-
new BootstrapError({
72-
message: "Failed to decode bootstrap envelope.",
127+
new BootstrapEnvelopeDecodeError({
128+
fd,
73129
cause: parsed.failure,
74130
}),
75131
),
@@ -98,24 +154,24 @@ const isFdReady = (fd: number) =>
98154
Effect.try({
99155
try: () => NodeFS.fstatSync(fd),
100156
catch: (error) =>
101-
new BootstrapError({
102-
message: "Failed to stat bootstrap fd.",
157+
new BootstrapFdStatError({
158+
fd,
103159
cause: error,
104160
}),
105161
}).pipe(
106162
Effect.as(true),
107-
Effect.catchIf(
108-
(error) => isUnavailableBootstrapFdError(error.cause),
109-
() => Effect.succeed(false),
110-
),
163+
Effect.catchTags({
164+
BootstrapFdStatError: (error) =>
165+
isUnavailableBootstrapFdError(error.cause) ? Effect.succeed(false) : Effect.fail(error),
166+
}),
111167
);
112168

113169
const makeBootstrapInputStream = (fd: number) =>
114170
Effect.gen(function* () {
115171
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>({
117174
try: () => {
118-
const fdPath = resolveFdPath(fd, platform);
119175
if (fdPath === undefined) {
120176
return makeDirectBootstrapStream(fd);
121177
}
@@ -139,8 +195,10 @@ const makeBootstrapInputStream = (fd: number) =>
139195
}
140196
},
141197
catch: (error) =>
142-
new BootstrapError({
143-
message: "Failed to duplicate bootstrap fd.",
198+
new BootstrapInputStreamOpenError({
199+
fd,
200+
platform,
201+
...(fdPath === undefined ? {} : { fdPath }),
144202
cause: error,
145203
}),
146204
});

0 commit comments

Comments
 (0)