authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:28:06-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:28:06-07:00
log5b1b35c773e8ad4a85ea2f3dd277b698bf329f21
tree15bfb0603bddca9c6cd95a62a512a6f59f4253fa
parentee3e2dae7880c2f4f0d43e04305438e083f13650
signature Signed by PGP key B5690EEEBB952194

[codex] Preserve desktop shell environment probe failures (#3383)

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

2 files changed, 136 insertions(+), 5 deletions(-)

apps/desktop/src/shell/DesktopShellEnvironment.test.ts+55-2
......@@ -1,15 +1,23 @@
11import { assert, describe, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
4import * as Logger from "effect/Logger";
5import * as PlatformError from "effect/PlatformError";
6import * as Schema from "effect/Schema";
47import * as Sink from "effect/Sink";
58import * as Stream from "effect/Stream";
6import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
9import * as ChildProcess from "effect/unstable/process/ChildProcess";
10import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
711
812import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
913import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";
1014
1115const textEncoder = new TextEncoder();
1216
17const isDesktopShellEnvironmentCommandError = Schema.is(
18 DesktopShellEnvironment.DesktopShellEnvironmentCommandError,
19);
20
1321function envOutput(values: Readonly<Record<string, string>>): string {
1422 return Object.entries(values)
1523 .flatMap(([name, value]) => [
......@@ -59,6 +67,7 @@ function runShellEnvironment(input: {
5967 readonly env: NodeJS.ProcessEnv;
6068 readonly platform: NodeJS.Platform;
6169 readonly handler: (command: ChildProcess.Command) => string;
70 readonly failure?: PlatformError.PlatformError;
6271}) {
6372 const environmentLayer = Layer.succeed(
6473 DesktopEnvironment.DesktopEnvironment,
......@@ -68,7 +77,11 @@ function runShellEnvironment(input: {
6877 );
6978 const spawnerLayer = Layer.succeed(
7079 ChildProcessSpawner.ChildProcessSpawner,
71 ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))),
80 ChildProcessSpawner.make((command) =>
81 input.failure === undefined
82 ? Effect.succeed(makeProcess(input.handler(command)))
83 : Effect.fail(input.failure),
84 ),
7285 );
7386
7487 const program = Effect.gen(function* () {
......@@ -229,4 +242,44 @@ describe("DesktopShellEnvironment", () => {
229242 );
230243 }),
231244 );
245
246 it.effect("logs command failures with safe probe context and the exact cause", () => {
247 const env: NodeJS.ProcessEnv = {
248 SHELL: "/bin/bash",
249 PATH: "/usr/bin",
250 };
251 const cause = PlatformError.systemError({
252 _tag: "PermissionDenied",
253 module: "ChildProcess",
254 method: "spawn",
255 pathOrDescriptor: "/bin/bash",
256 });
257 const messages: Array<unknown> = [];
258 const logger = Logger.make(({ message }) => {
259 messages.push(message);
260 });
261
262 return runShellEnvironment({
263 env,
264 platform: "linux",
265 handler: () => "",
266 failure: cause,
267 }).pipe(
268 Effect.andThen(
269 Effect.sync(() => {
270 const errors = messages
271 .flatMap((message) => (Array.isArray(message) ? message : [message]))
272 .filter(isDesktopShellEnvironmentCommandError);
273 assert.lengthOf(errors, 1);
274 assert.equal(errors[0]?.probe, "login-shell");
275 assert.equal(errors[0]?.executable, "bash");
276 assert.equal(errors[0]?.argumentCount, 2);
277 assert.notProperty(errors[0] ?? {}, "args");
278 assert.equal(errors[0]?.cause, cause);
279 assert.notInclude(errors[0]?.message ?? "", cause.message);
280 }),
281 ),
282 Effect.provide(Logger.layer([logger], { mergeWithExisting: false })),
283 );
284 });
232285});
apps/desktop/src/shell/DesktopShellEnvironment.ts+81-3
......@@ -3,6 +3,7 @@ import * as Duration from "effect/Duration";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
67import * as ChildProcess from "effect/unstable/process/ChildProcess";
78import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
89
......@@ -20,6 +21,44 @@ interface WindowsProbeOptions {
2021 readonly loadProfile: boolean;
2122}
2223
24const DesktopShellEnvironmentProbe = Schema.Literals([
25 "login-shell",
26 "launchctl-path",
27 "powershell-profile",
28 "powershell-no-profile",
29]);
30type DesktopShellEnvironmentProbe = typeof DesktopShellEnvironmentProbe.Type;
31
32const desktopShellEnvironmentCommandFields = {
33 probe: DesktopShellEnvironmentProbe,
34 executable: Schema.String,
35 argumentCount: Schema.Number,
36};
37
38export class DesktopShellEnvironmentCommandError extends Schema.TaggedErrorClass<DesktopShellEnvironmentCommandError>()(
39 "DesktopShellEnvironmentCommandError",
40 {
41 ...desktopShellEnvironmentCommandFields,
42 cause: Schema.Defect(),
43 },
44) {
45 override get message(): string {
46 return `Desktop shell environment ${this.probe} probe (${this.executable}) failed.`;
47 }
48}
49
50export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErrorClass<DesktopShellEnvironmentCommandTimeoutError>()(
51 "DesktopShellEnvironmentCommandTimeoutError",
52 {
53 ...desktopShellEnvironmentCommandFields,
54 timeoutMs: Schema.Number,
55 },
56) {
57 override get message(): string {
58 return `Desktop shell environment ${this.probe} probe (${this.executable}) timed out after ${this.timeoutMs}ms.`;
59 }
60}
61
2362export class DesktopShellEnvironment extends Context.Service<
2463 DesktopShellEnvironment,
2564 {
......@@ -127,6 +166,18 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [
127166const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`;
128167const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`;
129168
169const executableName = (command: string): string => command.split(/[\\/]/u).at(-1) ?? command;
170
171const logShellEnvironmentCommandError = (
172 error: DesktopShellEnvironmentCommandError | DesktopShellEnvironmentCommandTimeoutError,
173) =>
174 Effect.logWarning(error).pipe(
175 Effect.annotateLogs({
176 component: "desktop-shell-environment",
177 error,
178 }),
179 );
180
130181const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) =>
131182 names
132183 .map((name) => {
......@@ -175,13 +226,14 @@ const extractEnvironment = (output: string, names: ReadonlyArray<string>): Envir
175226};
176227
177228const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: {
229 readonly probe: DesktopShellEnvironmentProbe;
178230 readonly command: string;
179231 readonly args: ReadonlyArray<string>;
180232 readonly timeout: Duration.Duration;
181233 readonly shell?: boolean;
182234}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> {
183235 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
184 return yield* spawner
236 const output = yield* spawner
185237 .string(
186238 ChildProcess.make(input.command, input.args, {
187239 shell: input.shell ?? false,
......@@ -193,10 +245,33 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(
193245 }),
194246 )
195247 .pipe(
248 Effect.mapError(
249 (cause) =>
250 new DesktopShellEnvironmentCommandError({
251 probe: input.probe,
252 executable: executableName(input.command),
253 argumentCount: input.args.length,
254 cause,
255 }),
256 ),
257 Effect.catchTags({
258 DesktopShellEnvironmentCommandError: (error) =>
259 logShellEnvironmentCommandError(error).pipe(Effect.as("")),
260 }),
196261 Effect.timeoutOption(input.timeout),
197 Effect.map(Option.getOrElse(() => "")),
198 Effect.orElseSucceed(() => ""),
199262 );
263 if (Option.isSome(output)) {
264 return output.value;
265 }
266
267 const error = new DesktopShellEnvironmentCommandTimeoutError({
268 probe: input.probe,
269 executable: executableName(input.command),
270 argumentCount: input.args.length,
271 timeoutMs: Duration.toMillis(input.timeout),
272 });
273 yield* logShellEnvironmentCommandError(error);
274 return "";
200275});
201276
202277const readLoginShellEnvironment = (
......@@ -206,12 +281,14 @@ const readLoginShellEnvironment = (
206281 names.length === 0
207282 ? Effect.succeed({})
208283 : runCommandOutput({
284 probe: "login-shell",
209285 command: shell,
210286 args: ["-ilc", capturePosixEnvironmentCommand(names)],
211287 timeout: LOGIN_SHELL_TIMEOUT,
212288 }).pipe(Effect.map((output) => extractEnvironment(output, names)));
213289
214290const readLaunchctlPath = runCommandOutput({
291 probe: "launchctl-path",
215292 command: "/bin/launchctl",
216293 args: ["getenv", "PATH"],
217294 timeout: LAUNCHCTL_TIMEOUT,
......@@ -234,6 +311,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn
234311
235312 for (const command of WINDOWS_SHELL_CANDIDATES) {
236313 const output = yield* runCommandOutput({
314 probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile",
237315 command,
238316 args,
239317 timeout: LOGIN_SHELL_TIMEOUT,