History

[codex] Structure dev-runner failures (#3283)

Julius Marminge committed 4 months ago

Commit f50a7cd1 parent 97487d92 files · +348 −51Browse files

Unified · Split

2 files
  1. scripts/dev-runner.test.ts +206 −11
  2. scripts/dev-runner.ts +142 −40
scripts/dev-runner.test.ts+206−11
@@ -1,8 +1,16 @@
11 import * as NodeServices from "@effect/platform-node/NodeServices";
22 import * as NodeOS from "node:os";
3+import * as NetService from "@t3tools/shared/Net";
4+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
35 import { assert, describe, it } from "@effect/vitest";
6+import * as ConfigProvider from "effect/ConfigProvider";
47 import * as Effect from "effect/Effect";
8+import * as Layer from "effect/Layer";
59 import * as Path from "effect/Path";
10+import * as PlatformError from "effect/PlatformError";
11+import * as Sink from "effect/Sink";
12+import * as Stream from "effect/Stream";
13+import { ChildProcessSpawner } from "effect/unstable/process";
614
715 import {
816 checkPortAvailabilityOnHosts,
@@ -11,8 +19,49 @@ import { · 2 unmodified lines
1119 getDevRunnerModeArgs,
1220 resolveModePortOffsets,
1321 resolveOffset,
22+ runDevRunnerWithInput,
1423 } from "./dev-runner.ts";
1524
25+const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }));
26+const netServiceLayer = Layer.succeed(NetService.NetService, {
27+ canListenOnHost: () => Effect.succeed(true),
28+ isPortAvailableOnLoopback: () => Effect.succeed(true),
29+ reserveLoopbackPort: () => Effect.succeed(49_152),
30+ findAvailablePort: (port) => Effect.succeed(port),
31+});
32+
33+function mockProcess(exit: number | PlatformError.PlatformError) {
34+ return ChildProcessSpawner.makeHandle({
35+ pid: ChildProcessSpawner.ProcessId(1),
36+ exitCode:
37+ typeof exit === "number"
38+ ? Effect.succeed(ChildProcessSpawner.ExitCode(exit))
39+ : Effect.fail(exit),
40+ isRunning: Effect.succeed(false),
41+ kill: () => Effect.void,
42+ unref: Effect.succeed(Effect.void),
43+ stdin: Sink.drain,
44+ stdout: Stream.empty,
45+ stderr: Stream.empty,
46+ all: Stream.empty,
47+ getInputFd: () => Sink.drain,
48+ getOutputFd: () => Stream.empty,
49+ });
50+}
51+
52+const devServerInput = {
53+ mode: "dev:server",
54+ t3Home: "/tmp/t3code-dev-runner",
55+ noBrowser: undefined,
56+ autoBootstrapProjectFromCwd: undefined,
57+ logWebSocketEvents: undefined,
58+ host: undefined,
59+ port: 13_773,
60+ devUrl: undefined,
61+ dryRun: false,
62+ runArgs: ["--inspect", "secret-token-value"],
63+} as const;
64+
1665 it.layer(NodeServices.layer)("dev-runner", (it) => {
1766 describe("getDevRunnerModeArgs", () => {
1867 it.effect("lets Vite+ honor the desktop dev task graph", () =>
@@ -42,8 +91,8 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 23 unmodified lines
4291
4392 describe("resolveOffset", () => {
4493 it.effect("uses explicit T3CODE_PORT_OFFSET when provided", () =>
45− Effect.sync(() => {
46− const result = resolveOffset({ portOffset: 12, devInstance: undefined });
94+ Effect.gen(function* () {
95+ const result = yield* resolveOffset({ portOffset: 12, devInstance: undefined });
4796 assert.deepStrictEqual(result, {
4897 offset: 12,
4998 source: "T3CODE_PORT_OFFSET=12",
@@ -52,23 +101,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 2 unmodified lines
52101 );
53102
54103 it.effect("hashes non-numeric instance values", () =>
55− Effect.sync(() => {
56− const result = resolveOffset({ portOffset: undefined, devInstance: "feature-branch" });
104+ Effect.gen(function* () {
105+ const result = yield* resolveOffset({
106+ portOffset: undefined,
107+ devInstance: "feature-branch",
108+ });
57109 assert.ok(result.offset >= 1);
58110 assert.ok(result.offset <= 3000);
59111 }),
60112 );
61113
62− it.effect("throws for negative port offset", () =>
114+ it.effect("returns structured context for a negative port offset", () =>
63115 Effect.gen(function* () {
64− const error = yield* Effect.flip(
65− Effect.try({
66− try: () => resolveOffset({ portOffset: -1, devInstance: undefined }),
67− catch: (cause) => String(cause),
68− }),
116+ const error = yield* resolveOffset({ portOffset: -1, devInstance: undefined }).pipe(
117+ Effect.flip,
69118 );
70119
71− assert.ok(error.includes("Invalid T3CODE_PORT_OFFSET"));
120+ assert.equal(error._tag, "DevRunnerInvalidPortOffsetError");
121+ assert.equal(error.configKey, "T3CODE_PORT_OFFSET");
122+ assert.equal(error.portOffset, -1);
123+ assert.equal(error.minimum, 0);
124+ assert.ok(!("cause" in error));
72125 }),
73126 );
74127 });
@@ -289,6 +342,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 214 unmodified lines
289342 assert.equal(offset, 59_802);
290343 }),
291344 );
345+
346+ it.effect("reports the exhausted range and required port set", () =>
347+ Effect.gen(function* () {
348+ const error = yield* findFirstAvailableOffset({
349+ startOffset: 51_763,
350+ requireServerPort: true,
351+ requireWebPort: false,
352+ checkPortAvailability: () => Effect.succeed(true),
353+ }).pipe(Effect.flip);
354+
355+ if (error._tag !== "DevRunnerPortExhaustedError") {
356+ assert.fail(`Unexpected error: ${error._tag}`);
357+ }
358+ assert.equal(error.startOffset, 51_763);
359+ assert.equal(error.requireServerPort, true);
360+ assert.equal(error.requireWebPort, false);
361+ assert.equal(error.baseServerPort, 13_773);
362+ assert.equal(error.baseWebPort, 5_733);
363+ assert.equal(error.maximumPort, 65_535);
364+ assert.ok(!("cause" in error));
365+ }),
366+ );
292367 });
293368
294369 describe("checkPortAvailabilityOnHosts", () => {
@@ -395,4 +470,124 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 100 unmodified lines
395470 }),
396471 );
397472 });
473+
474+ describe("runDevRunnerWithInput", () => {
475+ it.effect("preserves invalid configuration as the exact cause", () =>
476+ Effect.gen(function* () {
477+ const error = yield* runDevRunnerWithInput({ ...devServerInput, dryRun: true }).pipe(
478+ Effect.provide(
479+ Layer.merge(
480+ netServiceLayer,
481+ ConfigProvider.layer(
482+ ConfigProvider.fromEnv({ env: { T3CODE_PORT_OFFSET: "not-an-integer" } }),
483+ ),
484+ ),
485+ ),
486+ Effect.flip,
487+ );
488+
489+ if (error._tag !== "DevRunnerConfigurationError") {
490+ assert.fail(`Unexpected error: ${error._tag}`);
491+ }
492+ assert.deepStrictEqual(error.configKeys, ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"]);
493+ assert.ok(error.cause !== undefined);
494+ assert.ok(!error.message.includes(String((error.cause as Error).message)));
495+ }),
496+ );
497+
498+ it.effect("preserves process spawn context and the exact platform cause", () => {
499+ const cause = PlatformError.systemError({
500+ _tag: "NotFound",
501+ module: "ChildProcess",
502+ method: "spawn",
503+ description: "vp was not found",
504+ });
505+ const spawnerLayer = Layer.succeed(
506+ ChildProcessSpawner.ChildProcessSpawner,
507+ ChildProcessSpawner.make(() => Effect.fail(cause)),
508+ );
509+
510+ return Effect.gen(function* () {
511+ const error = yield* runDevRunnerWithInput(devServerInput).pipe(
512+ Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
513+ Effect.provideService(HostProcessPlatform, "linux"),
514+ Effect.flip,
515+ );
516+
517+ if (error._tag !== "DevRunnerProcessError") {
518+ assert.fail(`Unexpected error: ${error._tag}`);
519+ }
520+ assert.equal(error.operation, "spawn");
521+ assert.equal(error.mode, "dev:server");
522+ assert.equal(error.executable, "vp");
523+ assert.equal(error.argumentCount, 5);
524+ assert.equal(error.shell, false);
525+ assert.equal(error.cause, cause);
526+ assert.ok(!error.message.includes(cause.message));
527+ assert.notProperty(error, "args");
528+ assert.notInclude(error.message, "secret-token-value");
529+ });
530+ });
531+
532+ it.effect("reports non-zero exits without manufacturing a cause", () => {
533+ const spawnerLayer = Layer.succeed(
534+ ChildProcessSpawner.ChildProcessSpawner,
535+ ChildProcessSpawner.make(() => Effect.succeed(mockProcess(17))),
536+ );
537+
538+ return Effect.gen(function* () {
539+ const error = yield* runDevRunnerWithInput(devServerInput).pipe(
540+ Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
541+ Effect.provideService(HostProcessPlatform, "linux"),
542+ Effect.flip,
543+ );
544+
545+ if (error._tag !== "DevRunnerProcessExitError") {
546+ assert.fail(`Unexpected error: ${error._tag}`);
547+ }
548+ assert.equal(error.mode, "dev:server");
549+ assert.equal(error.executable, "vp");
550+ assert.equal(error.argumentCount, 5);
551+ assert.equal(error.shell, false);
552+ assert.equal(error.exitCode, 17);
553+ assert.ok(!("cause" in error));
554+ assert.notProperty(error, "args");
555+ assert.notInclude(error.message, "secret-token-value");
556+ });
557+ });
558+
559+ it.effect("preserves wait-for-exit failures as the exact cause", () => {
560+ const cause = PlatformError.systemError({
561+ _tag: "Unknown",
562+ module: "ChildProcess",
563+ method: "exitCode",
564+ description: "process status became unavailable",
565+ });
566+ const spawnerLayer = Layer.succeed(
567+ ChildProcessSpawner.ChildProcessSpawner,
568+ ChildProcessSpawner.make(() => Effect.succeed(mockProcess(cause))),
569+ );
570+
571+ return Effect.gen(function* () {
572+ const error = yield* runDevRunnerWithInput(devServerInput).pipe(
573+ Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
574+ Effect.provideService(HostProcessPlatform, "linux"),
575+ Effect.flip,
576+ );
577+
578+ if (error._tag !== "DevRunnerProcessError") {
579+ assert.fail(`Unexpected error: ${error._tag}`);
580+ }
581+ assert.equal(error.operation, "wait-for-exit");
582+ assert.equal(error.mode, "dev:server");
583+ assert.equal(error.executable, "vp");
584+ assert.equal(error.argumentCount, 5);
585+ assert.equal(error.shell, false);
586+ assert.equal(error.cause, cause);
587+ assert.ok(!error.message.includes(cause.message));
588+ assert.notProperty(error, "args");
589+ assert.notInclude(error.message, "secret-token-value");
590+ });
591+ });
592+ });
398593 });
scripts/dev-runner.ts+142−40
@@ -8,7 +8,6 @@ import * as NetService from "@t3tools/shared/Net"; · 7 unmodified lines
88 import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
99 import { resolveSpawnCommand } from "@t3tools/shared/shell";
1010 import * as Config from "effect/Config";
11−import * as Data from "effect/Data";
1211 import * as Effect from "effect/Effect";
1312 import * as Hash from "effect/Hash";
1413 import * as Layer from "effect/Layer";
@@ -57,10 +56,87 @@ export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray<string> { · 42 unmodified lines
5756 return MODE_ARGS[mode];
5857 }
5958
60−class DevRunnerError extends Data.TaggedError("DevRunnerError")<{
61− readonly message: string;
62− readonly cause?: unknown;
63−}> {}
59+export class DevRunnerConfigurationError extends Schema.TaggedErrorClass<DevRunnerConfigurationError>()(
60+ "DevRunnerConfigurationError",
61+ {
62+ configKeys: Schema.Array(Schema.String),
63+ cause: Schema.Defect(),
64+ },
65+) {
66+ override get message(): string {
67+ return `Failed to read dev-runner configuration: ${this.configKeys.join(", ")}.`;
68+ }
69+}
70+
71+export class DevRunnerInvalidPortOffsetError extends Schema.TaggedErrorClass<DevRunnerInvalidPortOffsetError>()(
72+ "DevRunnerInvalidPortOffsetError",
73+ {
74+ configKey: Schema.Literal("T3CODE_PORT_OFFSET"),
75+ portOffset: Schema.Number,
76+ minimum: Schema.Number,
77+ },
78+) {
79+ override get message(): string {
80+ return `${this.configKey} must be at least ${this.minimum}; received ${this.portOffset}.`;
81+ }
82+}
83+
84+export class DevRunnerPortExhaustedError extends Schema.TaggedErrorClass<DevRunnerPortExhaustedError>()(
85+ "DevRunnerPortExhaustedError",
86+ {
87+ startOffset: Schema.Number,
88+ requireServerPort: Schema.Boolean,
89+ requireWebPort: Schema.Boolean,
90+ baseServerPort: Schema.Number,
91+ baseWebPort: Schema.Number,
92+ maximumPort: Schema.Number,
93+ },
94+) {
95+ override get message(): string {
96+ return `No required dev ports were available from offset ${this.startOffset} through maximum port ${this.maximumPort}.`;
97+ }
98+}
99+
100+export class DevRunnerProcessError extends Schema.TaggedErrorClass<DevRunnerProcessError>()(
101+ "DevRunnerProcessError",
102+ {
103+ operation: Schema.Literals(["spawn", "wait-for-exit"]),
104+ mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]),
105+ executable: Schema.Literal("vp"),
106+ argumentCount: Schema.Number,
107+ shell: Schema.Boolean,
108+ cause: Schema.Defect(),
109+ },
110+) {
111+ override get message(): string {
112+ return `Dev-runner process operation "${this.operation}" failed for mode "${this.mode}".`;
113+ }
114+}
115+
116+export class DevRunnerProcessExitError extends Schema.TaggedErrorClass<DevRunnerProcessExitError>()(
117+ "DevRunnerProcessExitError",
118+ {
119+ mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]),
120+ executable: Schema.Literal("vp"),
121+ argumentCount: Schema.Number,
122+ shell: Schema.Boolean,
123+ exitCode: Schema.Number,
124+ },
125+) {
126+ override get message(): string {
127+ return `Dev-runner process exited with code ${this.exitCode} in mode "${this.mode}".`;
128+ }
129+}
130+
131+export const DevRunnerError = Schema.Union([
132+ DevRunnerConfigurationError,
133+ DevRunnerInvalidPortOffsetError,
134+ DevRunnerPortExhaustedError,
135+ DevRunnerProcessError,
136+ DevRunnerProcessExitError,
137+]);
138+export type DevRunnerError = typeof DevRunnerError.Type;
139+export const isDevRunnerError = Schema.is(DevRunnerError);
64140
65141 const optionalStringConfig = (name: string): Config.Config<string | undefined> =>
66142 Config.string(name).pipe(
@@ -96,28 +172,40 @@ const OffsetConfig = Config.all({ · 29 unmodified lines
96172 export function resolveOffset(config: {
97173 readonly portOffset: number | undefined;
98174 readonly devInstance: string | undefined;
99−}): { readonly offset: number; readonly source: string } {
175+}): Effect.Effect<
176+ { readonly offset: number; readonly source: string },
177+ DevRunnerInvalidPortOffsetError
178+> {
100179 if (config.portOffset !== undefined) {
101180 if (config.portOffset < 0) {
102− throw new Error(`Invalid T3CODE_PORT_OFFSET: ${config.portOffset}`);
181+ return Effect.fail(
182+ new DevRunnerInvalidPortOffsetError({
183+ configKey: "T3CODE_PORT_OFFSET",
184+ portOffset: config.portOffset,
185+ minimum: 0,
186+ }),
187+ );
103188 }
104− return {
189+ return Effect.succeed({
105190 offset: config.portOffset,
106191 source: `T3CODE_PORT_OFFSET=${config.portOffset}`,
107− };
192+ });
108193 }
109194
110195 const seed = config.devInstance?.trim();
111196 if (!seed) {
112− return { offset: 0, source: "default ports" };
197+ return Effect.succeed({ offset: 0, source: "default ports" });
113198 }
114199
115200 if (/^\d+$/.test(seed)) {
116− return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` };
201+ return Effect.succeed({
202+ offset: Number(seed),
203+ source: `numeric T3CODE_DEV_INSTANCE=${seed}`,
204+ });
117205 }
118206
119207 const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1;
120− return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` };
208+ return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` });
121209 }
122210
123211 function resolveBaseDir(baseDir: string | undefined): Effect.Effect<string, never, Path.Path> {
@@ -275,7 +363,7 @@ export function findFirstAvailableOffset<R = NetService.NetService>({ · 151 unmodified lines
275363 requireServerPort,
276364 requireWebPort,
277365 checkPortAvailability,
278−}: FindFirstAvailableOffsetInput<R>): Effect.Effect<number, DevRunnerError, R> {
366+}: FindFirstAvailableOffsetInput<R>): Effect.Effect<number, DevRunnerPortExhaustedError, R> {
279367 return Effect.gen(function* () {
280368 const checkPort = (checkPortAvailability ??
281369 defaultCheckPortAvailability) as PortAvailabilityCheck<R>;
@@ -311,8 +399,13 @@ export function findFirstAvailableOffset<R = NetService.NetService>({ · 29 unmodified lines
311399 }
312400 }
313401
314− return yield* new DevRunnerError({
315− message: `No available dev ports found from offset ${startOffset}. Tried server=${BASE_SERVER_PORT}+n web=${BASE_WEB_PORT}+n up to port ${MAX_PORT}.`,
402+ return yield* new DevRunnerPortExhaustedError({
403+ startOffset,
404+ requireServerPort,
405+ requireWebPort,
406+ baseServerPort: BASE_SERVER_PORT,
407+ baseWebPort: BASE_WEB_PORT,
408+ maximumPort: MAX_PORT,
316409 });
317410 });
318411 }
@@ -333,7 +426,7 @@ export function resolveModePortOffsets<R = NetService.NetService>({ · 14 unmodified lines
333426 checkPortAvailability,
334427 }: ResolveModePortOffsetsInput<R>): Effect.Effect<
335428 { readonly serverOffset: number; readonly webOffset: number },
336− DevRunnerError,
429+ DevRunnerPortExhaustedError,
337430 R
338431 > {
339432 return Effect.gen(function* () {
@@ -397,21 +490,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { · 57 unmodified lines
397490 const { portOffset, devInstance } = yield* OffsetConfig.pipe(
398491 Effect.mapError(
399492 (cause) =>
400− new DevRunnerError({
401− message: "Failed to read T3CODE_PORT_OFFSET/T3CODE_DEV_INSTANCE configuration.",
493+ new DevRunnerConfigurationError({
494+ configKeys: ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"],
402495 cause,
403496 }),
404497 ),
405498 );
406499
407− const { offset, source } = yield* Effect.try({
408− try: () => resolveOffset({ portOffset, devInstance }),
409− catch: (cause) =>
410− new DevRunnerError({
411− message: cause instanceof Error ? cause.message : String(cause),
412− cause,
413− }),
414− });
500+ const { offset, source } = yield* resolveOffset({ portOffset, devInstance });
415501
416502 const { serverOffset, webOffset } = yield* resolveModePortOffsets({
417503 mode: input.mode,
@@ -453,6 +539,12 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { · 35 unmodified lines
453539 [...MODE_ARGS[input.mode], ...input.runArgs],
454540 { env },
455541 );
542+ const processContext = {
543+ mode: input.mode,
544+ executable: "vp" as const,
545+ argumentCount: spawnCommand.args.length,
546+ shell: spawnCommand.shell,
547+ } as const;
456548 const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, {
457549 stdin: "inherit",
458550 stdout: "inherit",
@@ -465,24 +557,34 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { · 6 unmodified lines
465557 // which would put the runner in a new group and require manual forwarding.
466558 detached: false,
467559 forceKillAfter: "1500 millis",
468− });
560+ }).pipe(
561+ Effect.mapError(
562+ (cause) =>
563+ new DevRunnerProcessError({
564+ ...processContext,
565+ operation: "spawn",
566+ cause,
567+ }),
568+ ),
569+ );
469570
470− const exitCode = yield* child.exitCode;
571+ const exitCode = yield* child.exitCode.pipe(
572+ Effect.mapError(
573+ (cause) =>
574+ new DevRunnerProcessError({
575+ ...processContext,
576+ operation: "wait-for-exit",
577+ cause,
578+ }),
579+ ),
580+ );
471581 if (exitCode !== 0) {
472− return yield* new DevRunnerError({
473− message: `vp run exited with code ${exitCode}`,
582+ return yield* new DevRunnerProcessExitError({
583+ ...processContext,
584+ exitCode,
474585 });
475586 }
476− }).pipe(
477− Effect.mapError((cause) =>
478− cause instanceof DevRunnerError
479− ? cause
480− : new DevRunnerError({
481− message: cause instanceof Error ? cause.message : "dev-runner failed",
482− cause,
483− }),
484− ),
485− );
587+ });
486588 }
487589
488590 const devRunnerCli = Command.make("dev-runner", {