Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 102 additions & 5 deletions apps/server/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<typeof import("node:fs")>();
Expand All @@ -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<typeof actual.fstatSync>) => {
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);
},
};
});

Expand Down Expand Up @@ -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");
Expand All @@ -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;
Expand Down
98 changes: 78 additions & 20 deletions apps/server/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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>()(
"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>()(
"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>()(
"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>()(
"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* <A, I>(
schema: Schema.Codec<A, I>,
Expand All @@ -32,7 +85,10 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function

const timeoutMs = options?.timeoutMs ?? 1000;

return yield* Effect.callback<Option.Option<A>, BootstrapError>((resume) => {
return yield* Effect.callback<
Option.Option<A>,
BootstrapEnvelopeReadError | BootstrapEnvelopeDecodeError
>((resume) => {
const input = NodeReadline.createInterface({
input: stream,
crlfDelay: Infinity,
Expand All @@ -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,
}),
),
Expand All @@ -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,
}),
),
Expand Down Expand Up @@ -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<NodeStream.Readable, BootstrapError>({
const fdPath = resolveFdPath(fd, platform);
return yield* Effect.try<NodeStream.Readable, BootstrapInputStreamOpenError>({
try: () => {
const fdPath = resolveFdPath(fd, platform);
if (fdPath === undefined) {
return makeDirectBootstrapStream(fd);
}
Expand All @@ -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,
}),
});
Expand Down
Loading