From 77e762319ab4b7c4e68dc862204816c2dd00acfe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 03:54:26 -0700 Subject: [PATCH 1/2] refactor(scripts): structure dev runner failures Co-authored-by: codex --- scripts/dev-runner.test.ts | 211 +++++++++++++++++++++++++++++++++++-- scripts/dev-runner.ts | 182 +++++++++++++++++++++++++------- 2 files changed, 342 insertions(+), 51 deletions(-) diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index f6df387ee22..bc3ba10afbd 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,8 +1,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; +import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { assert, describe, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { checkPortAvailabilityOnHosts, @@ -11,8 +19,49 @@ import { getDevRunnerModeArgs, resolveModePortOffsets, resolveOffset, + runDevRunnerWithInput, } from "./dev-runner.ts"; +const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); +const netServiceLayer = Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(49_152), + findAvailablePort: (port) => Effect.succeed(port), +}); + +function mockProcess(exit: number | PlatformError.PlatformError) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: + typeof exit === "number" + ? Effect.succeed(ChildProcessSpawner.ExitCode(exit)) + : Effect.fail(exit), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +const devServerInput = { + mode: "dev:server", + t3Home: "/tmp/t3code-dev-runner", + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: 13_773, + devUrl: undefined, + dryRun: false, + runArgs: ["--inspect"], +} as const; + it.layer(NodeServices.layer)("dev-runner", (it) => { describe("getDevRunnerModeArgs", () => { it.effect("lets Vite+ honor the desktop dev task graph", () => @@ -42,8 +91,8 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { describe("resolveOffset", () => { it.effect("uses explicit T3CODE_PORT_OFFSET when provided", () => - Effect.sync(() => { - const result = resolveOffset({ portOffset: 12, devInstance: undefined }); + Effect.gen(function* () { + const result = yield* resolveOffset({ portOffset: 12, devInstance: undefined }); assert.deepStrictEqual(result, { offset: 12, source: "T3CODE_PORT_OFFSET=12", @@ -52,23 +101,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); it.effect("hashes non-numeric instance values", () => - Effect.sync(() => { - const result = resolveOffset({ portOffset: undefined, devInstance: "feature-branch" }); + Effect.gen(function* () { + const result = yield* resolveOffset({ + portOffset: undefined, + devInstance: "feature-branch", + }); assert.ok(result.offset >= 1); assert.ok(result.offset <= 3000); }), ); - it.effect("throws for negative port offset", () => + it.effect("returns structured context for a negative port offset", () => Effect.gen(function* () { - const error = yield* Effect.flip( - Effect.try({ - try: () => resolveOffset({ portOffset: -1, devInstance: undefined }), - catch: (cause) => String(cause), - }), + const error = yield* resolveOffset({ portOffset: -1, devInstance: undefined }).pipe( + Effect.flip, ); - assert.ok(error.includes("Invalid T3CODE_PORT_OFFSET")); + assert.equal(error._tag, "DevRunnerInvalidPortOffsetError"); + assert.equal(error.configKey, "T3CODE_PORT_OFFSET"); + assert.equal(error.portOffset, -1); + assert.equal(error.minimum, 0); + assert.ok(!("cause" in error)); }), ); }); @@ -289,6 +342,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(offset, 59_802); }), ); + + it.effect("reports the exhausted range and required port set", () => + Effect.gen(function* () { + const error = yield* findFirstAvailableOffset({ + startOffset: 51_763, + requireServerPort: true, + requireWebPort: false, + checkPortAvailability: () => Effect.succeed(true), + }).pipe(Effect.flip); + + if (error._tag !== "DevRunnerPortExhaustedError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.startOffset, 51_763); + assert.equal(error.requireServerPort, true); + assert.equal(error.requireWebPort, false); + assert.equal(error.baseServerPort, 13_773); + assert.equal(error.baseWebPort, 5_733); + assert.equal(error.maximumPort, 65_535); + assert.ok(!("cause" in error)); + }), + ); }); describe("checkPortAvailabilityOnHosts", () => { @@ -395,4 +470,118 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); }); + + describe("runDevRunnerWithInput", () => { + it.effect("preserves invalid configuration as the exact cause", () => + Effect.gen(function* () { + const error = yield* runDevRunnerWithInput({ ...devServerInput, dryRun: true }).pipe( + Effect.provide( + Layer.merge( + netServiceLayer, + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { T3CODE_PORT_OFFSET: "not-an-integer" } }), + ), + ), + ), + Effect.flip, + ); + + if (error._tag !== "DevRunnerConfigurationError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.deepStrictEqual(error.configKeys, ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"]); + assert.ok(error.cause !== undefined); + assert.ok(!error.message.includes(String((error.cause as Error).message))); + }), + ); + + it.effect("preserves process spawn context and the exact platform cause", () => { + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "vp was not found", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ); + + return Effect.gen(function* () { + const error = yield* runDevRunnerWithInput(devServerInput).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + if (error._tag !== "DevRunnerProcessError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "spawn"); + assert.equal(error.mode, "dev:server"); + assert.equal(error.command, "vp"); + assert.deepStrictEqual(error.args, ["run", "--filter=t3", "dev", "--inspect"]); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.ok(!error.message.includes(cause.message)); + }); + }); + + it.effect("reports non-zero exits without manufacturing a cause", () => { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(mockProcess(17))), + ); + + return Effect.gen(function* () { + const error = yield* runDevRunnerWithInput(devServerInput).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + if (error._tag !== "DevRunnerProcessExitError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.mode, "dev:server"); + assert.equal(error.command, "vp"); + assert.deepStrictEqual(error.args, ["run", "--filter=t3", "dev", "--inspect"]); + assert.equal(error.shell, false); + assert.equal(error.exitCode, 17); + assert.ok(!("cause" in error)); + }); + }); + + it.effect("preserves wait-for-exit failures as the exact cause", () => { + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + description: "process status became unavailable", + }); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(mockProcess(cause))), + ); + + return Effect.gen(function* () { + const error = yield* runDevRunnerWithInput(devServerInput).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + if (error._tag !== "DevRunnerProcessError") { + assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, "wait-for-exit"); + assert.equal(error.mode, "dev:server"); + assert.equal(error.command, "vp"); + assert.deepStrictEqual(error.args, ["run", "--filter=t3", "dev", "--inspect"]); + assert.equal(error.shell, false); + assert.equal(error.cause, cause); + assert.ok(!error.message.includes(cause.message)); + }); + }); + }); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 36c5aa41852..e27b1bfd7f5 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -8,7 +8,6 @@ import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Hash from "effect/Hash"; import * as Layer from "effect/Layer"; @@ -57,10 +56,87 @@ export function getDevRunnerModeArgs(mode: DevMode): ReadonlyArray { return MODE_ARGS[mode]; } -class DevRunnerError extends Data.TaggedError("DevRunnerError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class DevRunnerConfigurationError extends Schema.TaggedErrorClass()( + "DevRunnerConfigurationError", + { + configKeys: Schema.Array(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read dev-runner configuration: ${this.configKeys.join(", ")}.`; + } +} + +export class DevRunnerInvalidPortOffsetError extends Schema.TaggedErrorClass()( + "DevRunnerInvalidPortOffsetError", + { + configKey: Schema.Literal("T3CODE_PORT_OFFSET"), + portOffset: Schema.Number, + minimum: Schema.Number, + }, +) { + override get message(): string { + return `${this.configKey} must be at least ${this.minimum}; received ${this.portOffset}.`; + } +} + +export class DevRunnerPortExhaustedError extends Schema.TaggedErrorClass()( + "DevRunnerPortExhaustedError", + { + startOffset: Schema.Number, + requireServerPort: Schema.Boolean, + requireWebPort: Schema.Boolean, + baseServerPort: Schema.Number, + baseWebPort: Schema.Number, + maximumPort: Schema.Number, + }, +) { + override get message(): string { + return `No required dev ports were available from offset ${this.startOffset} through maximum port ${this.maximumPort}.`; + } +} + +export class DevRunnerProcessError extends Schema.TaggedErrorClass()( + "DevRunnerProcessError", + { + operation: Schema.Literals(["spawn", "wait-for-exit"]), + mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), + command: Schema.String, + args: Schema.Array(Schema.String), + shell: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Dev-runner process operation "${this.operation}" failed for mode "${this.mode}".`; + } +} + +export class DevRunnerProcessExitError extends Schema.TaggedErrorClass()( + "DevRunnerProcessExitError", + { + mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), + command: Schema.String, + args: Schema.Array(Schema.String), + shell: Schema.Boolean, + exitCode: Schema.Number, + }, +) { + override get message(): string { + return `Dev-runner command "${this.command}" exited with code ${this.exitCode} in mode "${this.mode}".`; + } +} + +export const DevRunnerError = Schema.Union([ + DevRunnerConfigurationError, + DevRunnerInvalidPortOffsetError, + DevRunnerPortExhaustedError, + DevRunnerProcessError, + DevRunnerProcessExitError, +]); +export type DevRunnerError = typeof DevRunnerError.Type; +export const isDevRunnerError = Schema.is(DevRunnerError); const optionalStringConfig = (name: string): Config.Config => Config.string(name).pipe( @@ -96,28 +172,40 @@ const OffsetConfig = Config.all({ export function resolveOffset(config: { readonly portOffset: number | undefined; readonly devInstance: string | undefined; -}): { readonly offset: number; readonly source: string } { +}): Effect.Effect< + { readonly offset: number; readonly source: string }, + DevRunnerInvalidPortOffsetError +> { if (config.portOffset !== undefined) { if (config.portOffset < 0) { - throw new Error(`Invalid T3CODE_PORT_OFFSET: ${config.portOffset}`); + return Effect.fail( + new DevRunnerInvalidPortOffsetError({ + configKey: "T3CODE_PORT_OFFSET", + portOffset: config.portOffset, + minimum: 0, + }), + ); } - return { + return Effect.succeed({ offset: config.portOffset, source: `T3CODE_PORT_OFFSET=${config.portOffset}`, - }; + }); } const seed = config.devInstance?.trim(); if (!seed) { - return { offset: 0, source: "default ports" }; + return Effect.succeed({ offset: 0, source: "default ports" }); } if (/^\d+$/.test(seed)) { - return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` }; + return Effect.succeed({ + offset: Number(seed), + source: `numeric T3CODE_DEV_INSTANCE=${seed}`, + }); } const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; - return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }; + return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); } function resolveBaseDir(baseDir: string | undefined): Effect.Effect { @@ -275,7 +363,7 @@ export function findFirstAvailableOffset({ requireServerPort, requireWebPort, checkPortAvailability, -}: FindFirstAvailableOffsetInput): Effect.Effect { +}: FindFirstAvailableOffsetInput): Effect.Effect { return Effect.gen(function* () { const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck; @@ -311,8 +399,13 @@ export function findFirstAvailableOffset({ } } - return yield* new DevRunnerError({ - 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}.`, + return yield* new DevRunnerPortExhaustedError({ + startOffset, + requireServerPort, + requireWebPort, + baseServerPort: BASE_SERVER_PORT, + baseWebPort: BASE_WEB_PORT, + maximumPort: MAX_PORT, }); }); } @@ -333,7 +426,7 @@ export function resolveModePortOffsets({ checkPortAvailability, }: ResolveModePortOffsetsInput): Effect.Effect< { readonly serverOffset: number; readonly webOffset: number }, - DevRunnerError, + DevRunnerPortExhaustedError, R > { return Effect.gen(function* () { @@ -397,21 +490,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { const { portOffset, devInstance } = yield* OffsetConfig.pipe( Effect.mapError( (cause) => - new DevRunnerError({ - message: "Failed to read T3CODE_PORT_OFFSET/T3CODE_DEV_INSTANCE configuration.", + new DevRunnerConfigurationError({ + configKeys: ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"], cause, }), ), ); - const { offset, source } = yield* Effect.try({ - try: () => resolveOffset({ portOffset, devInstance }), - catch: (cause) => - new DevRunnerError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }); + const { offset, source } = yield* resolveOffset({ portOffset, devInstance }); const { serverOffset, webOffset } = yield* resolveModePortOffsets({ mode: input.mode, @@ -453,6 +539,12 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { [...MODE_ARGS[input.mode], ...input.runArgs], { env }, ); + const processContext = { + mode: input.mode, + command: spawnCommand.command, + args: spawnCommand.args, + shell: spawnCommand.shell, + } as const; const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, { stdin: "inherit", stdout: "inherit", @@ -465,24 +557,34 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { // which would put the runner in a new group and require manual forwarding. detached: false, forceKillAfter: "1500 millis", - }); + }).pipe( + Effect.mapError( + (cause) => + new DevRunnerProcessError({ + ...processContext, + operation: "spawn", + cause, + }), + ), + ); - const exitCode = yield* child.exitCode; + const exitCode = yield* child.exitCode.pipe( + Effect.mapError( + (cause) => + new DevRunnerProcessError({ + ...processContext, + operation: "wait-for-exit", + cause, + }), + ), + ); if (exitCode !== 0) { - return yield* new DevRunnerError({ - message: `vp run exited with code ${exitCode}`, + return yield* new DevRunnerProcessExitError({ + ...processContext, + exitCode, }); } - }).pipe( - Effect.mapError((cause) => - cause instanceof DevRunnerError - ? cause - : new DevRunnerError({ - message: cause instanceof Error ? cause.message : "dev-runner failed", - cause, - }), - ), - ); + }); } const devRunnerCli = Command.make("dev-runner", { From c1295f1d3c0296c653aac05869ebe5a98470cbdf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:24:11 -0700 Subject: [PATCH 2/2] fix(scripts): redact dev runner process arguments Co-authored-by: codex --- scripts/dev-runner.test.ts | 20 +++++++++++++------- scripts/dev-runner.ts | 14 +++++++------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index bc3ba10afbd..85d57c4181f 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -59,7 +59,7 @@ const devServerInput = { port: 13_773, devUrl: undefined, dryRun: false, - runArgs: ["--inspect"], + runArgs: ["--inspect", "secret-token-value"], } as const; it.layer(NodeServices.layer)("dev-runner", (it) => { @@ -519,11 +519,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { } assert.equal(error.operation, "spawn"); assert.equal(error.mode, "dev:server"); - assert.equal(error.command, "vp"); - assert.deepStrictEqual(error.args, ["run", "--filter=t3", "dev", "--inspect"]); + assert.equal(error.executable, "vp"); + assert.equal(error.argumentCount, 5); assert.equal(error.shell, false); assert.equal(error.cause, cause); assert.ok(!error.message.includes(cause.message)); + assert.notProperty(error, "args"); + assert.notInclude(error.message, "secret-token-value"); }); }); @@ -544,11 +546,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.fail(`Unexpected error: ${error._tag}`); } assert.equal(error.mode, "dev:server"); - assert.equal(error.command, "vp"); - assert.deepStrictEqual(error.args, ["run", "--filter=t3", "dev", "--inspect"]); + assert.equal(error.executable, "vp"); + assert.equal(error.argumentCount, 5); assert.equal(error.shell, false); assert.equal(error.exitCode, 17); assert.ok(!("cause" in error)); + assert.notProperty(error, "args"); + assert.notInclude(error.message, "secret-token-value"); }); }); @@ -576,11 +580,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { } assert.equal(error.operation, "wait-for-exit"); assert.equal(error.mode, "dev:server"); - assert.equal(error.command, "vp"); - assert.deepStrictEqual(error.args, ["run", "--filter=t3", "dev", "--inspect"]); + assert.equal(error.executable, "vp"); + assert.equal(error.argumentCount, 5); assert.equal(error.shell, false); assert.equal(error.cause, cause); assert.ok(!error.message.includes(cause.message)); + assert.notProperty(error, "args"); + assert.notInclude(error.message, "secret-token-value"); }); }); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index e27b1bfd7f5..fb82310bbd3 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -102,8 +102,8 @@ export class DevRunnerProcessError extends Schema.TaggedErrorClass