authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 22:21:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 22:21:58-07:00
log2f8d3baa88cd137b11083942a4961ca79a9f99be
treed31dab932ae981bfbe6f192a5d62f61076c938a9
parent6b7447632292f54dc98244320a7f47d7d3858ad4
signature Signed by PGP key B5690EEEBB952194

[codex] finish server process and preview Effect cleanup (#3209)

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

5 files changed, 139 insertions(+), 29 deletions(-)

apps/server/src/preview/PortScanner.test.ts+2-2
...@@ -6,9 +6,9 @@ import * as Net from "@t3tools/shared/Net";...@@ -6,9 +6,9 @@ import * as Net from "@t3tools/shared/Net";
6import { Effect, Layer } from "effect";6import { Effect, Layer } from "effect";
7import { expect } from "vite-plus/test";7import { expect } from "vite-plus/test";
88
9import { ProcessRunner } from "../processRunner.ts";9import * as ProcessRunner from "../processRunner.ts";
10import * as PortScanner from "./PortScanner.ts";10import * as PortScanner from "./PortScanner.ts";
11const TestProcessRunner = Layer.succeed(ProcessRunner, {11const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
12 run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"),12 run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"),
13});13});
14const TestPortDiscoveryLive = PortScanner.layer.pipe(14const TestPortDiscoveryLive = PortScanner.layer.pipe(
apps/server/src/process/externalLauncher.test.ts+11-9
...@@ -11,7 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -11,7 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1111
12import { HostProcessPlatform } from "@t3tools/shared/hostProcess";12import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
13import { SpawnExecutableResolution } from "@t3tools/shared/shell";13import { SpawnExecutableResolution } from "@t3tools/shared/shell";
14import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts";14import * as ExternalLauncher from "./externalLauncher.ts";
1515
16function makeMockDetachedHandle(onUnref: () => void = () => undefined) {16function makeMockDetachedHandle(onUnref: () => void = () => undefined) {
17 return ChildProcessSpawner.makeHandle({17 return ChildProcessSpawner.makeHandle({
...@@ -54,7 +54,7 @@ const testLayer = (input: {...@@ -54,7 +54,7 @@ const testLayer = (input: {
54 );54 );
5555
56 return Layer.mergeAll(56 return Layer.mergeAll(
57 ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))),57 ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))),
58 Layer.succeed(HostProcessPlatform, input.platform),58 Layer.succeed(HostProcessPlatform, input.platform),
59 Layer.succeed(59 Layer.succeed(
60 SpawnExecutableResolution,60 SpawnExecutableResolution,
...@@ -68,7 +68,7 @@ it.effect("launches the default browser through the platform command", () => {...@@ -68,7 +68,7 @@ it.effect("launches the default browser through the platform command", () => {
68 let spawned: ChildProcess.StandardCommand | undefined;68 let spawned: ChildProcess.StandardCommand | undefined;
69 let didUnref = false;69 let didUnref = false;
70 return Effect.gen(function* () {70 return Effect.gen(function* () {
71 const launcher = yield* ExternalLauncher;71 const launcher = yield* ExternalLauncher.ExternalLauncher;
7272
73 yield* launcher.launchBrowser("https://example.com/some path");73 yield* launcher.launchBrowser("https://example.com/some path");
7474
...@@ -101,7 +101,7 @@ it.effect("launches an installed editor with platform-safe arguments", () =>...@@ -101,7 +101,7 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
101101
102 let spawned: ChildProcess.StandardCommand | undefined;102 let spawned: ChildProcess.StandardCommand | undefined;
103 yield* Effect.gen(function* () {103 yield* Effect.gen(function* () {
104 const launcher = yield* ExternalLauncher;104 const launcher = yield* ExternalLauncher.ExternalLauncher;
105 yield* launcher.launchEditor({105 yield* launcher.launchEditor({
106 editor: "vscode",106 editor: "vscode",
107 cwd: "C:\\workspace with spaces\\src\\index.ts:12:4",107 cwd: "C:\\workspace with spaces\\src\\index.ts:12:4",
...@@ -139,7 +139,7 @@ it.effect("discovers editors through the service API", () =>...@@ -139,7 +139,7 @@ it.effect("discovers editors through the service API", () =>
139 yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n");139 yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n");
140140
141 const editors = yield* Effect.gen(function* () {141 const editors = yield* Effect.gen(function* () {
142 const launcher = yield* ExternalLauncher;142 const launcher = yield* ExternalLauncher.ExternalLauncher;
143 return yield* launcher.resolveAvailableEditors();143 return yield* launcher.resolveAvailableEditors();
144 }).pipe(144 }).pipe(
145 Effect.provide(145 Effect.provide(
...@@ -157,10 +157,12 @@ it.effect("discovers editors through the service API", () =>...@@ -157,10 +157,12 @@ it.effect("discovers editors through the service API", () =>
157157
158it.effect("rejects unknown editors through the service API", () =>158it.effect("rejects unknown editors through the service API", () =>
159 Effect.gen(function* () {159 Effect.gen(function* () {
160 const launcher = yield* ExternalLauncher;160 const launcher = yield* ExternalLauncher.ExternalLauncher;
161 const result = yield* launcher161 const error = yield* launcher
162 .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" })162 .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" })
163 .pipe(Effect.result);163 .pipe(Effect.flip);
164 assert.equal(result._tag, "Failure");164 assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnknownEditorError);
165 assert.equal(error.editor, "missing-editor");
166 assert.equal(error.message, "Unknown editor: missing-editor");
165 }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))),167 }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))),
166);168);
apps/server/src/process/externalLauncher.ts+49-10
...@@ -9,6 +9,11 @@...@@ -9,6 +9,11 @@
9import {9import {
10 EDITORS,10 EDITORS,
11 ExternalLauncherError,11 ExternalLauncherError,
12 ExternalLauncherBrowserSpawnError,
13 ExternalLauncherCommandNotFoundError,
14 ExternalLauncherEditorSpawnError,
15 ExternalLauncherUnknownEditorError,
16 ExternalLauncherUnsupportedEditorError,
12 type EditorId,17 type EditorId,
13 type LaunchEditorInput,18 type LaunchEditorInput,
14} from "@t3tools/contracts";19} from "@t3tools/contracts";
...@@ -29,9 +34,19 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne...@@ -29,9 +34,19 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
29// Definitions34// Definitions
30// ==============================35// ==============================
3136
32export { ExternalLauncherError };37export {
38 ExternalLauncherError,
39 ExternalLauncherBrowserSpawnError,
40 ExternalLauncherCommandNotFoundError,
41 ExternalLauncherEditorSpawnError,
42 ExternalLauncherUnknownEditorError,
43 ExternalLauncherUnsupportedEditorError,
44 isExternalLauncherError,
45} from "@t3tools/contracts";
33export type { LaunchEditorInput };46export type { LaunchEditorInput };
34interface EditorLaunch {47interface EditorLaunch {
48 readonly editor: EditorId;
49 readonly target: string;
35 readonly command: string;50 readonly command: string;
36 readonly args: ReadonlyArray<string>;51 readonly args: ReadonlyArray<string>;
37}52}
...@@ -317,7 +332,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (...@@ -317,7 +332,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
317 });332 });
318 const editorDef = EDITORS.find((editor) => editor.id === input.editor);333 const editorDef = EDITORS.find((editor) => editor.id === input.editor);
319 if (!editorDef) {334 if (!editorDef) {
320 return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` });335 return yield* new ExternalLauncherUnknownEditorError({ editor: input.editor });
321 }336 }
322337
323 if (editorDef.commands) {338 if (editorDef.commands) {
...@@ -326,21 +341,28 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (...@@ -326,21 +341,28 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
326 () => editorDef.commands[0],341 () => editorDef.commands[0],
327 );342 );
328 return {343 return {
344 editor: editorDef.id,
345 target: input.cwd,
329 command,346 command,
330 args: resolveEditorArgs(editorDef, input.cwd),347 args: resolveEditorArgs(editorDef, input.cwd),
331 };348 };
332 }349 }
333350
334 if (editorDef.id !== "file-manager") {351 if (editorDef.id !== "file-manager") {
335 return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` });352 return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });
336 }353 }
337354
338 return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };355 return {
356 editor: editorDef.id,
357 target: input.cwd,
358 command: fileManagerCommandForPlatform(platform),
359 args: [input.cwd],
360 };
339});361});
340362
341const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (363const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
342 launch: ProcessLaunch,364 launch: ProcessLaunch,
343 errorMessage: string,365 onError: (cause: unknown) => ExternalLauncherError,
344): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {366): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
345 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;367 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
346 const command = ChildProcess.make(launch.command, launch.args, launch.options);368 const command = ChildProcess.make(launch.command, launch.args, launch.options);
...@@ -349,7 +371,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (...@@ -349,7 +371,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
349 Effect.flatMap((handle) => handle.unref),371 Effect.flatMap((handle) => handle.unref),
350 Effect.asVoid,372 Effect.asVoid,
351 Effect.scoped,373 Effect.scoped,
352 Effect.mapError((cause) => new ExternalLauncherError({ message: errorMessage, cause })),374 Effect.mapError(onError),
353 );375 );
354});376});
355377
...@@ -357,7 +379,16 @@ const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* (...@@ -357,7 +379,16 @@ const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* (
357 target: string,379 target: string,
358): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {380): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
359 const launch = yield* resolveBrowserLaunch(target);381 const launch = yield* resolveBrowserLaunch(target);
360 return yield* launchAndUnref(launch, "Browser auto-open failed");382 return yield* launchAndUnref(
383 launch,
384 (cause) =>
385 new ExternalLauncherBrowserSpawnError({
386 target,
387 command: launch.command,
388 args: launch.args,
389 cause,
390 }),
391 );
361});392});
362393
363const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* (394const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* (
...@@ -369,8 +400,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu...@@ -369,8 +400,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
369> {400> {
370 const env = yield* readCommandLookupEnv;401 const env = yield* readCommandLookupEnv;
371 if (!(yield* isCommandAvailable(launch.command, { env }))) {402 if (!(yield* isCommandAvailable(launch.command, { env }))) {
372 return yield* new ExternalLauncherError({403 return yield* new ExternalLauncherCommandNotFoundError({
373 message: `Editor command not found: ${launch.command}`,404 editor: launch.editor,
405 command: launch.command,
374 });406 });
375 }407 }
376408
...@@ -387,7 +419,14 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu...@@ -387,7 +419,14 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
387 stderr: "ignore",419 stderr: "ignore",
388 },420 },
389 },421 },
390 "failed to spawn detached process",422 (cause) =>
423 new ExternalLauncherEditorSpawnError({
424 editor: launch.editor,
425 target: launch.target,
426 command: spawnCommand.command,
427 args: spawnCommand.args,
428 cause,
429 }),
391 );430 );
392});431});
393432
apps/server/src/server.test.ts+4-3
...@@ -14,7 +14,7 @@ import {...@@ -14,7 +14,7 @@ import {
14 GitCommandError,14 GitCommandError,
15 KeybindingRule,15 KeybindingRule,
16 MessageId,16 MessageId,
17 ExternalLauncherError,17 ExternalLauncherCommandNotFoundError,
18 type OrchestrationThreadShell,18 type OrchestrationThreadShell,
19 TerminalNotRunningError,19 TerminalNotRunningError,
20 type OrchestrationCommand,20 type OrchestrationCommand,
...@@ -4570,8 +4570,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4570,8 +4570,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
45704570
4571 it.effect("routes websocket rpc shell.openInEditor errors", () =>4571 it.effect("routes websocket rpc shell.openInEditor errors", () =>
4572 Effect.gen(function* () {4572 Effect.gen(function* () {
4573 const externalLauncherError = new ExternalLauncherError({4573 const externalLauncherError = new ExternalLauncherCommandNotFoundError({
4574 message: "Editor command not found: cursor",4574 editor: "cursor",
4575 command: "cursor",
4575 });4576 });
4576 yield* buildAppUnderTest({4577 yield* buildAppUnderTest({
4577 layers: {4578 layers: {
packages/contracts/src/editor.ts+73-5
...@@ -50,10 +50,78 @@ export const LaunchEditorInput = Schema.Struct({...@@ -50,10 +50,78 @@ export const LaunchEditorInput = Schema.Struct({
50});50});
51export type LaunchEditorInput = typeof LaunchEditorInput.Type;51export type LaunchEditorInput = typeof LaunchEditorInput.Type;
5252
53export class ExternalLauncherError extends Schema.TaggedErrorClass<ExternalLauncherError>()(53export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnknownEditorError>()(
54 "ExternalLauncherError",54 "ExternalLauncherUnknownEditorError",
55 {55 {
56 message: Schema.String,56 editor: Schema.String,
57 cause: Schema.optional(Schema.Defect()),
58 },57 },
59) {}58) {
59 override get message(): string {
60 return `Unknown editor: ${this.editor}`;
61 }
62}
63
64export class ExternalLauncherUnsupportedEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnsupportedEditorError>()(
65 "ExternalLauncherUnsupportedEditorError",
66 {
67 editor: EditorId,
68 },
69) {
70 override get message(): string {
71 return `Unsupported editor: ${this.editor}`;
72 }
73}
74
75export class ExternalLauncherCommandNotFoundError extends Schema.TaggedErrorClass<ExternalLauncherCommandNotFoundError>()(
76 "ExternalLauncherCommandNotFoundError",
77 {
78 editor: EditorId,
79 command: Schema.String,
80 },
81) {
82 override get message(): string {
83 return `Editor command not found: ${this.command}`;
84 }
85}
86
87const ExternalLauncherSpawnFields = {
88 command: Schema.String,
89 args: Schema.Array(Schema.String),
90 cause: Schema.Defect(),
91};
92
93export class ExternalLauncherBrowserSpawnError extends Schema.TaggedErrorClass<ExternalLauncherBrowserSpawnError>()(
94 "ExternalLauncherBrowserSpawnError",
95 {
96 ...ExternalLauncherSpawnFields,
97 target: Schema.String,
98 },
99) {
100 override get message(): string {
101 return `Failed to launch browser target '${this.target}' with '${[this.command, ...this.args].join(" ")}'`;
102 }
103}
104
105export class ExternalLauncherEditorSpawnError extends Schema.TaggedErrorClass<ExternalLauncherEditorSpawnError>()(
106 "ExternalLauncherEditorSpawnError",
107 {
108 ...ExternalLauncherSpawnFields,
109 editor: EditorId,
110 target: Schema.String,
111 },
112) {
113 override get message(): string {
114 return `Failed to launch '${this.target}' in ${this.editor} with '${[this.command, ...this.args].join(" ")}'`;
115 }
116}
117
118export const ExternalLauncherError = Schema.Union([
119 ExternalLauncherUnknownEditorError,
120 ExternalLauncherUnsupportedEditorError,
121 ExternalLauncherCommandNotFoundError,
122 ExternalLauncherBrowserSpawnError,
123 ExternalLauncherEditorSpawnError,
124]);
125export type ExternalLauncherError = typeof ExternalLauncherError.Type;
126
127export const isExternalLauncherError = Schema.is(ExternalLauncherError);