Skip to content

Commit 8b4de61

Browse files
authored
fix(desktop): discover shell environment without blocking startup (#28556)
1 parent 58a407c commit 8b4de61

15 files changed

Lines changed: 914 additions & 66 deletions

‎apps/desktop/src/backend/DesktopBackendConfiguration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as Layer from "effect/Layer";
88
import * as ManagedRuntime from "effect/ManagedRuntime";
99
import * as Option from "effect/Option";
1010
import * as Path from "effect/Path";
11+
import * as Stream from "effect/Stream";
1112
import { ChildProcessSpawner } from "effect/process";
1213

1314
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
@@ -22,6 +23,8 @@ import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts";
2223
const layerShellEnvironment = (prepared = true) =>
2324
Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, {
2425
installIntoProcess: Effect.succeed(prepared),
26+
awaitDiscovery: Effect.succeed(prepared),
27+
updates: Stream.empty,
2528
});
2629

2730
const layerBackendConfiguration = (prepared = true) =>
@@ -237,6 +240,7 @@ describe("DesktopBackendConfiguration", () => {
237240
assert.equal(first.cwd, environment.backendCwd);
238241
assert.equal(first.captureOutput, true);
239242
assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1");
243+
assert.equal(first.env.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING, "1");
240244
assert.isUndefined(first.env.T3CODE_PORT);
241245
assert.isUndefined(first.env.T3CODE_MODE);
242246
assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST);

‎apps/desktop/src/backend/DesktopBackendConfiguration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
532532
env: {
533533
...backendChildEnvPatch(),
534534
ELECTRON_RUN_AS_NODE: "1",
535+
T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1",
535536
T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath),
536537
},
537538
extendEnv: true,

‎apps/desktop/src/main.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,10 @@ const layerDesktopAppActivation = DesktopAppActivation.layer.pipe(
186186
const layerDesktopBackend = DesktopBackendPool.layer.pipe(
187187
Layer.provideMerge(DesktopAppIdentity.layer),
188188
Layer.provideMerge(DesktopBackendConfiguration.layer),
189-
Layer.provideMerge(DesktopShellEnvironment.layer),
190189
Layer.provideMerge(DesktopWslEnvironment.layer),
191190
Layer.provideMerge(DesktopWslServerTree.layer),
192191
Layer.provideMerge(DesktopTelemetryPublisher.layer),
192+
Layer.provideMerge(DesktopShellEnvironment.layer),
193193
Layer.provideMerge(layerDesktopWindow),
194194
);
195195

‎apps/desktop/src/shell/DesktopShellEnvironment.test.ts‎

Lines changed: 272 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
33
import { assert, describe, it } from "@effect/vitest";
44
import * as HostProcess from "@t3tools/shared/HostProcess";
55
import * as Effect from "effect/Effect";
6+
import * as Deferred from "effect/Deferred";
7+
import * as FileSystem from "effect/FileSystem";
68
import * as Layer from "effect/Layer";
79
import * as Logger from "effect/Logger";
810
import * as PlatformError from "effect/PlatformError";
911
import * as Sink from "effect/Sink";
1012
import * as Stream from "effect/Stream";
13+
import * as Schema from "effect/Schema";
14+
import * as Option from "effect/Option";
1115
import * as ChildProcess from "effect/process/ChildProcess";
1216
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
1317
import * as NodeOS from "node:os";
@@ -19,6 +23,15 @@ import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";
1923
vi.mock("node:os", { spy: true });
2024

2125
const textEncoder = new TextEncoder();
26+
const cacheCodec = Schema.fromJsonString(
27+
Schema.Struct({
28+
version: Schema.Number,
29+
key: Schema.String,
30+
environment: Schema.Record(Schema.String, Schema.NullOr(Schema.String)),
31+
}),
32+
);
33+
const encodeCache = Schema.encodeSync(cacheCodec);
34+
const decodeCache = Schema.decodeUnknownEffect(cacheCodec);
2235

2336
function envOutput(values: Readonly<Record<string, string>>): string {
2437
return Object.entries(values)
@@ -69,18 +82,28 @@ function runShellEnvironment(input: {
6982
readonly env: NodeJS.ProcessEnv;
7083
readonly platform: NodeJS.Platform;
7184
readonly userShell?: string;
85+
readonly stateDir?: string;
86+
readonly use?: (
87+
service: DesktopShellEnvironment.DesktopShellEnvironment["Service"],
88+
) => Effect.Effect<boolean, never, import("effect/Scope").Scope>;
7289
readonly handler: (
7390
command: ChildProcess.Command,
7491
) => string | { output: string; exitCode: number };
7592
readonly failure?: PlatformError.PlatformError;
7693
readonly processSpawner?: ChildProcessSpawner.ChildProcessSpawner["Service"];
7794
}) {
78-
const layerEnvironment = Layer.succeed(
95+
const layerEnvironment = Layer.effect(
7996
DesktopEnvironment.DesktopEnvironment,
80-
DesktopEnvironment.DesktopEnvironment.of({
81-
platform: input.platform,
82-
} as DesktopEnvironment.DesktopEnvironment["Service"]),
83-
);
97+
Effect.gen(function* () {
98+
const fs = yield* FileSystem.FileSystem;
99+
const stateDir = input.stateDir ?? (yield* fs.makeTempDirectoryScoped());
100+
return DesktopEnvironment.DesktopEnvironment.of({
101+
platform: input.platform,
102+
stateDir,
103+
homeDirectory: "/test-home",
104+
} as DesktopEnvironment.DesktopEnvironment["Service"]);
105+
}),
106+
).pipe(Layer.provide(NodeServices.layer));
84107
const layerSpawner = Layer.succeed(
85108
ChildProcessSpawner.ChildProcessSpawner,
86109
ChildProcessSpawner.make((command) => {
@@ -97,8 +120,9 @@ function runShellEnvironment(input: {
97120

98121
const program = Effect.gen(function* () {
99122
const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
100-
const prepared = yield* shellEnvironment.installIntoProcess;
101-
assert.equal(yield* shellEnvironment.installIntoProcess, prepared);
123+
if (input.use) return yield* input.use(shellEnvironment);
124+
const prepared = yield* shellEnvironment.awaitDiscovery;
125+
assert.equal(yield* shellEnvironment.awaitDiscovery, prepared);
102126
return prepared;
103127
}).pipe(
104128
Effect.provide(
@@ -121,6 +145,247 @@ function runShellEnvironment(input: {
121145
}
122146

123147
describe("DesktopShellEnvironment", () => {
148+
it.effect("does not wait for discovery when the cache is missing", () =>
149+
Effect.gen(function* () {
150+
const started = yield* Deferred.make<void>();
151+
const release = yield* Deferred.make<void>();
152+
yield* runShellEnvironment({
153+
env: { SHELL: "/test-shell", PATH: "/usr/bin" },
154+
platform: "darwin",
155+
handler: () => "",
156+
processSpawner: ChildProcessSpawner.make(() =>
157+
Deferred.succeed(started, undefined).pipe(
158+
Effect.andThen(Deferred.await(release)),
159+
Effect.as(makeProcess(envOutput({ PATH: "/fresh/bin" }))),
160+
),
161+
),
162+
use: (service) =>
163+
Effect.gen(function* () {
164+
const startup = yield* service.installIntoProcess.pipe(Effect.forkChild);
165+
yield* Deferred.await(started);
166+
assert.isDefined(startup.pollUnsafe());
167+
yield* Deferred.succeed(release, undefined);
168+
return yield* service.awaitDiscovery;
169+
}),
170+
});
171+
}),
172+
);
173+
174+
it.effect(
175+
"reuses cached paths immediately and streams fresh discovery without keeping removed paths",
176+
() =>
177+
Effect.gen(function* () {
178+
const fs = yield* FileSystem.FileSystem;
179+
const stateDir = yield* fs.makeTempDirectoryScoped();
180+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner.pipe(
181+
Effect.provide(NodeServices.layer),
182+
);
183+
const executable = yield* HostProcess.ExecutablePath;
184+
const first = { SHELL: "/bin/zsh", PATH: "/usr/bin" };
185+
yield* runShellEnvironment({
186+
env: first,
187+
stateDir,
188+
platform: "darwin",
189+
handler: () =>
190+
envOutput({
191+
PATH: "/old/bin:/usr/bin",
192+
__MISE_DIFF: "old-mise",
193+
SSH_AUTH_SOCK: "/old-session.sock",
194+
JAVA_HOME: "/old/java",
195+
SSL_CERT_FILE: "/old/cert.pem",
196+
}),
197+
});
198+
const cachePath = `${stateDir}/shell-environment-cache.json`;
199+
const cached = yield* fs.readFileString(cachePath).pipe(Effect.flatMap(decodeCache));
200+
assert.equal(cached.environment.PATH, "/old/bin:/usr/bin");
201+
assert.notProperty(cached.environment, "SSH_AUTH_SOCK");
202+
const started = yield* Deferred.make<void>();
203+
const release = yield* Deferred.make<void>();
204+
const env: NodeJS.ProcessEnv = {
205+
SHELL: "/bin/zsh",
206+
PATH: "/launcher/bin",
207+
SSH_AUTH_SOCK: "/current-session.sock",
208+
};
209+
const source = [
210+
"const values = { PATH: '/fresh/bin:' + process.env.PATH, __MISE_DIFF: 'fresh-mise', JAVA_HOME: process.env.JAVA_HOME, SSL_CERT_FILE: process.env.SSL_CERT_FILE };",
211+
"for (const [name, value] of Object.entries(values)) {",
212+
" if (value !== undefined) process.stdout.write('__T3CODE_ENV_' + name + '_START__\\n' + value + '\\n__T3CODE_ENV_' + name + '_END__\\n');",
213+
"}",
214+
].join("\n");
215+
let calls = 0;
216+
yield* runShellEnvironment({
217+
env,
218+
stateDir,
219+
platform: "darwin",
220+
handler: () => "",
221+
processSpawner: ChildProcessSpawner.make((command) => {
222+
calls++;
223+
return Deferred.succeed(started, undefined).pipe(
224+
Effect.andThen(Deferred.await(release)),
225+
Effect.andThen(
226+
command._tag === "StandardCommand"
227+
? spawner.spawn(ChildProcess.make(executable, ["-e", source], command.options))
228+
: Effect.succeed(makeProcess("")),
229+
),
230+
);
231+
}),
232+
use: (service) =>
233+
Effect.gen(function* () {
234+
yield* service.installIntoProcess;
235+
yield* service.installIntoProcess;
236+
assert.equal(env.PATH, "/old/bin:/usr/bin:/launcher/bin");
237+
assert.equal(env.SSH_AUTH_SOCK, "/current-session.sock");
238+
const replay = yield* Stream.runHead(service.updates);
239+
assert.isTrue(Option.isSome(replay));
240+
if (Option.isSome(replay))
241+
assert.equal(replay.value.environment.__MISE_DIFF, "old-mise");
242+
yield* Deferred.await(started);
243+
assert.equal(calls, 1);
244+
yield* Deferred.succeed(release, undefined);
245+
yield* service.awaitDiscovery;
246+
assert.equal(env.PATH, "/fresh/bin:/launcher/bin");
247+
assert.equal(env.__MISE_DIFF, "fresh-mise");
248+
assert.isUndefined(env.JAVA_HOME);
249+
assert.isUndefined(env.SSL_CERT_FILE);
250+
const fresh = yield* Stream.runHead(service.updates);
251+
assert.isTrue(Option.isSome(fresh));
252+
if (Option.isSome(fresh)) {
253+
assert.equal(fresh.value.environment.PATH, env.PATH);
254+
assert.isTrue(fresh.value.discovered);
255+
}
256+
return true;
257+
}),
258+
});
259+
const refreshed = yield* fs.readFileString(cachePath).pipe(Effect.flatMap(decodeCache));
260+
assert.equal(refreshed.environment.PATH, "/fresh/bin:/launcher/bin");
261+
assert.notProperty(refreshed.environment, "JAVA_HOME");
262+
assert.notProperty(refreshed.environment, "SSL_CERT_FILE");
263+
}).pipe(Effect.provide(NodeServices.layer)),
264+
);
265+
266+
it.effect.each([
267+
"not json",
268+
'{"version":2,"key":"old","environment":{"PATH":"/bad/bin"}}',
269+
'{"version":1,"key":"other-shell","environment":{"PATH":"/bad/bin"}}',
270+
])("ignores an unusable cache and still starts before discovery: %s", (contents) =>
271+
Effect.gen(function* () {
272+
const fs = yield* FileSystem.FileSystem;
273+
const stateDir = yield* fs.makeTempDirectoryScoped();
274+
yield* fs.writeFileString(`${stateDir}/shell-environment-cache.json`, contents);
275+
const started = yield* Deferred.make<void>();
276+
const release = yield* Deferred.make<void>();
277+
const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin" };
278+
yield* runShellEnvironment({
279+
env,
280+
stateDir,
281+
platform: "darwin",
282+
handler: () => "",
283+
processSpawner: ChildProcessSpawner.make(() =>
284+
Deferred.succeed(started, undefined).pipe(
285+
Effect.andThen(Deferred.await(release)),
286+
Effect.as(makeProcess(envOutput({ PATH: "/fresh/bin" }))),
287+
),
288+
),
289+
use: (service) =>
290+
Effect.gen(function* () {
291+
const startup = yield* service.installIntoProcess.pipe(Effect.forkChild);
292+
yield* Deferred.await(started);
293+
assert.isDefined(startup.pollUnsafe());
294+
assert.equal(env.PATH, "/usr/bin");
295+
yield* Deferred.succeed(release, undefined);
296+
return yield* service.awaitDiscovery;
297+
}),
298+
});
299+
assert.equal(env.PATH, "/fresh/bin:/usr/bin");
300+
}).pipe(Effect.provide(NodeServices.layer)),
301+
);
302+
303+
it.effect(
304+
"retains the last successful cache on refresh failure and ignores session values in the cache",
305+
() =>
306+
Effect.gen(function* () {
307+
const fs = yield* FileSystem.FileSystem;
308+
const stateDir = yield* fs.makeTempDirectoryScoped();
309+
const contents = encodeCache({
310+
version: 1,
311+
key: '["darwin","/test-home","/bin/zsh",""]',
312+
environment: {
313+
PATH: "/cached/bin",
314+
__MISE_DIFF: "cached-mise",
315+
JAVA_HOME: "/cached/java",
316+
NODE_EXTRA_CA_CERTS: "/cached/ca.pem",
317+
SSL_CERT_FILE: "/cached/cert.pem",
318+
SSL_CERT_DIR: "/cached/certs",
319+
SSH_AUTH_SOCK: "/stale.sock",
320+
NODE_OPTIONS: "--bad",
321+
},
322+
});
323+
const cachePath = `${stateDir}/shell-environment-cache.json`;
324+
yield* fs.writeFileString(cachePath, contents);
325+
const env: NodeJS.ProcessEnv = {
326+
SHELL: "/bin/zsh",
327+
PATH: "/usr/bin",
328+
__MISE_DIFF: "launcher-mise",
329+
JAVA_HOME: "/launcher/java",
330+
NODE_EXTRA_CA_CERTS: "/launcher/ca.pem",
331+
SSL_CERT_FILE: "",
332+
SSH_AUTH_SOCK: "/current.sock",
333+
};
334+
const prepared = yield* runShellEnvironment({
335+
env,
336+
stateDir,
337+
platform: "darwin",
338+
handler: () => "",
339+
use: (service) =>
340+
Effect.gen(function* () {
341+
yield* service.installIntoProcess;
342+
const replay = yield* Stream.runHead(service.updates);
343+
assert.isTrue(Option.isSome(replay));
344+
if (Option.isSome(replay)) {
345+
assert.equal(replay.value.environment.JAVA_HOME, "/launcher/java");
346+
assert.equal(replay.value.environment.NODE_EXTRA_CA_CERTS, "/launcher/ca.pem");
347+
}
348+
return yield* service.awaitDiscovery;
349+
}),
350+
});
351+
assert.isFalse(prepared);
352+
assert.equal(env.PATH, "/cached/bin:/usr/bin");
353+
assert.equal(env.__MISE_DIFF, "cached-mise");
354+
assert.equal(env.JAVA_HOME, "/launcher/java");
355+
assert.equal(env.NODE_EXTRA_CA_CERTS, "/launcher/ca.pem");
356+
assert.equal(env.SSL_CERT_FILE, "");
357+
assert.equal(env.SSL_CERT_DIR, "/cached/certs");
358+
assert.equal(env.SSH_AUTH_SOCK, "/current.sock");
359+
assert.isUndefined(env.NODE_OPTIONS);
360+
assert.equal(yield* fs.readFileString(cachePath), contents);
361+
}).pipe(Effect.provide(NodeServices.layer)),
362+
);
363+
364+
it.effect("cancels background discovery when the application scope closes", () =>
365+
Effect.gen(function* () {
366+
const started = yield* Deferred.make<void>();
367+
const stopped = yield* Deferred.make<void>();
368+
yield* runShellEnvironment({
369+
env: { SHELL: "/bin/zsh", PATH: "/usr/bin" },
370+
platform: "darwin",
371+
handler: () => "",
372+
processSpawner: ChildProcessSpawner.make(() =>
373+
Deferred.succeed(started, undefined).pipe(
374+
Effect.andThen(Effect.never),
375+
Effect.ensuring(Deferred.succeed(stopped, undefined)),
376+
),
377+
),
378+
use: (service) =>
379+
Effect.gen(function* () {
380+
yield* service.installIntoProcess;
381+
yield* Deferred.await(started);
382+
return true;
383+
}),
384+
});
385+
yield* Deferred.await(stopped);
386+
}),
387+
);
388+
124389
it.effect(
125390
"captures the login-shell PATH when startup writes more than a pipe buffer to stderr",
126391
() =>

0 commit comments

Comments
 (0)