authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:58:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:58:24-07:00
logf50a7cddc19336e3f4de820e198564f7e7f75455
treed3abbe4e48a3428621f6facb48e6a80d9e5b1d45
parent97487d9959554854698bf92e672b758e2c3fdeab
signature Signed by PGP key B5690EEEBB952194

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

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

2 files changed, 348 insertions(+), 51 deletions(-)

scripts/dev-runner.test.ts+206-11
......@@ -1,8 +1,16 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import * as NodeOS from "node:os";
3import * as NetService from "@t3tools/shared/Net";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
35import { assert, describe, it } from "@effect/vitest";
6import * as ConfigProvider from "effect/ConfigProvider";
47import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
59import * as Path from "effect/Path";
10import * as PlatformError from "effect/PlatformError";
11import * as Sink from "effect/Sink";
12import * as Stream from "effect/Stream";
13import { ChildProcessSpawner } from "effect/unstable/process";
614
715import {
816 checkPortAvailabilityOnHosts,
......@@ -11,8 +19,49 @@ import {
1119 getDevRunnerModeArgs,
1220 resolveModePortOffsets,
1321 resolveOffset,
22 runDevRunnerWithInput,
1423} from "./dev-runner.ts";
1524
25const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }));
26const 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
33function 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
52const 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
1665it.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) => {
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) => {
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) => {
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) => {
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";
88import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
99import { resolveSpawnCommand } from "@t3tools/shared/shell";
1010import * as Config from "effect/Config";
11import * as Data from "effect/Data";
1211import * as Effect from "effect/Effect";
1312import * as Hash from "effect/Hash";
1413import * as Layer from "effect/Layer";
......@@ -57,10 +56,87 @@ export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray<string> {
5756 return MODE_ARGS[mode];
5857}
5958
60class DevRunnerError extends Data.TaggedError("DevRunnerError")<{
61 readonly message: string;
62 readonly cause?: unknown;
63}> {}
59export 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
71export 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
84export 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
100export 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
116export 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
131export const DevRunnerError = Schema.Union([
132 DevRunnerConfigurationError,
133 DevRunnerInvalidPortOffsetError,
134 DevRunnerPortExhaustedError,
135 DevRunnerProcessError,
136 DevRunnerProcessExitError,
137]);
138export type DevRunnerError = typeof DevRunnerError.Type;
139export const isDevRunnerError = Schema.is(DevRunnerError);
64140
65141const optionalStringConfig = (name: string): Config.Config<string | undefined> =>
66142 Config.string(name).pipe(
......@@ -96,28 +172,40 @@ const OffsetConfig = Config.all({
96172export 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
123211function resolveBaseDir(baseDir: string | undefined): Effect.Effect<string, never, Path.Path> {
......@@ -275,7 +363,7 @@ export function findFirstAvailableOffset<R = NetService.NetService>({
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>({
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>({
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) {
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) {
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) {
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
488590const devRunnerCli = Command.make("dev-runner", {