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 @@...@@ -1,15 +1,23 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Logger from "effect/Logger";
5import * as PlatformError from "effect/PlatformError";
6import * as Schema from "effect/Schema";
4import * as Sink from "effect/Sink";7import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";8import * 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
8import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";12import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
9import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";13import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";
1014
11const textEncoder = new TextEncoder();15const textEncoder = new TextEncoder();
1216
17const isDesktopShellEnvironmentCommandError = Schema.is(
18 DesktopShellEnvironment.DesktopShellEnvironmentCommandError,
19);
20
13function envOutput(values: Readonly<Record<string, string>>): string {21function envOutput(values: Readonly<Record<string, string>>): string {
14 return Object.entries(values)22 return Object.entries(values)
15 .flatMap(([name, value]) => [23 .flatMap(([name, value]) => [
...@@ -59,6 +67,7 @@ function runShellEnvironment(input: {...@@ -59,6 +67,7 @@ function runShellEnvironment(input: {
59 readonly env: NodeJS.ProcessEnv;67 readonly env: NodeJS.ProcessEnv;
60 readonly platform: NodeJS.Platform;68 readonly platform: NodeJS.Platform;
61 readonly handler: (command: ChildProcess.Command) => string;69 readonly handler: (command: ChildProcess.Command) => string;
70 readonly failure?: PlatformError.PlatformError;
62}) {71}) {
63 const environmentLayer = Layer.succeed(72 const environmentLayer = Layer.succeed(
64 DesktopEnvironment.DesktopEnvironment,73 DesktopEnvironment.DesktopEnvironment,
...@@ -68,7 +77,11 @@ function runShellEnvironment(input: {...@@ -68,7 +77,11 @@ function runShellEnvironment(input: {
68 );77 );
69 const spawnerLayer = Layer.succeed(78 const spawnerLayer = Layer.succeed(
70 ChildProcessSpawner.ChildProcessSpawner,79 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 ),
72 );85 );
7386
74 const program = Effect.gen(function* () {87 const program = Effect.gen(function* () {
...@@ -229,4 +242,44 @@ describe("DesktopShellEnvironment", () => {...@@ -229,4 +242,44 @@ describe("DesktopShellEnvironment", () => {
229 );242 );
230 }),243 }),
231 );244 );
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 });
232});285});
apps/desktop/src/shell/DesktopShellEnvironment.ts+81-3
...@@ -3,6 +3,7 @@ import * as Duration from "effect/Duration";...@@ -3,6 +3,7 @@ import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
6import * as ChildProcess from "effect/unstable/process/ChildProcess";7import * as ChildProcess from "effect/unstable/process/ChildProcess";
7import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";8import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
89
...@@ -20,6 +21,44 @@ interface WindowsProbeOptions {...@@ -20,6 +21,44 @@ interface WindowsProbeOptions {
20 readonly loadProfile: boolean;21 readonly loadProfile: boolean;
21}22}
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
23export class DesktopShellEnvironment extends Context.Service<62export class DesktopShellEnvironment extends Context.Service<
24 DesktopShellEnvironment,63 DesktopShellEnvironment,
25 {64 {
...@@ -127,6 +166,18 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [...@@ -127,6 +166,18 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [
127const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`;166const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`;
128const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`;167const 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
130const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) =>181const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) =>
131 names182 names
132 .map((name) => {183 .map((name) => {
...@@ -175,13 +226,14 @@ const extractEnvironment = (output: string, names: ReadonlyArray<string>): Envir...@@ -175,13 +226,14 @@ const extractEnvironment = (output: string, names: ReadonlyArray<string>): Envir
175};226};
176227
177const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: {228const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: {
229 readonly probe: DesktopShellEnvironmentProbe;
178 readonly command: string;230 readonly command: string;
179 readonly args: ReadonlyArray<string>;231 readonly args: ReadonlyArray<string>;
180 readonly timeout: Duration.Duration;232 readonly timeout: Duration.Duration;
181 readonly shell?: boolean;233 readonly shell?: boolean;
182}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> {234}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> {
183 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;235 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
184 return yield* spawner236 const output = yield* spawner
185 .string(237 .string(
186 ChildProcess.make(input.command, input.args, {238 ChildProcess.make(input.command, input.args, {
187 shell: input.shell ?? false,239 shell: input.shell ?? false,
...@@ -193,10 +245,33 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(...@@ -193,10 +245,33 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(
193 }),245 }),
194 )246 )
195 .pipe(247 .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 }),
196 Effect.timeoutOption(input.timeout),261 Effect.timeoutOption(input.timeout),
197 Effect.map(Option.getOrElse(() => "")),
198 Effect.orElseSucceed(() => ""),
199 );262 );
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 "";
200});275});
201276
202const readLoginShellEnvironment = (277const readLoginShellEnvironment = (
...@@ -206,12 +281,14 @@ const readLoginShellEnvironment = (...@@ -206,12 +281,14 @@ const readLoginShellEnvironment = (
206 names.length === 0281 names.length === 0
207 ? Effect.succeed({})282 ? Effect.succeed({})
208 : runCommandOutput({283 : runCommandOutput({
284 probe: "login-shell",
209 command: shell,285 command: shell,
210 args: ["-ilc", capturePosixEnvironmentCommand(names)],286 args: ["-ilc", capturePosixEnvironmentCommand(names)],
211 timeout: LOGIN_SHELL_TIMEOUT,287 timeout: LOGIN_SHELL_TIMEOUT,
212 }).pipe(Effect.map((output) => extractEnvironment(output, names)));288 }).pipe(Effect.map((output) => extractEnvironment(output, names)));
213289
214const readLaunchctlPath = runCommandOutput({290const readLaunchctlPath = runCommandOutput({
291 probe: "launchctl-path",
215 command: "/bin/launchctl",292 command: "/bin/launchctl",
216 args: ["getenv", "PATH"],293 args: ["getenv", "PATH"],
217 timeout: LAUNCHCTL_TIMEOUT,294 timeout: LAUNCHCTL_TIMEOUT,
...@@ -234,6 +311,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn...@@ -234,6 +311,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn
234311
235 for (const command of WINDOWS_SHELL_CANDIDATES) {312 for (const command of WINDOWS_SHELL_CANDIDATES) {
236 const output = yield* runCommandOutput({313 const output = yield* runCommandOutput({
314 probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile",
237 command,315 command,
238 args,316 args,
239 timeout: LOGIN_SHELL_TIMEOUT,317 timeout: LOGIN_SHELL_TIMEOUT,