diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 5a76402b1d34..108c305a6dea 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as PlatformError from "effect/PlatformError"; @@ -69,11 +70,18 @@ function runShellEnvironment(input: { readonly platform: NodeJS.Platform; readonly handler: (command: ChildProcess.Command) => string; readonly failure?: PlatformError.PlatformError; + readonly stateDir?: string; }) { const environmentLayer = Layer.succeed( DesktopEnvironment.DesktopEnvironment, DesktopEnvironment.DesktopEnvironment.of({ platform: input.platform, + ...(input.stateDir === undefined + ? {} + : { + stateDir: input.stateDir, + path: { join: (directory: string, fileName: string) => `${directory}\\${fileName}` }, + }), } as DesktopEnvironment.DesktopEnvironment["Service"]), ); const spawnerLayer = Layer.succeed( @@ -348,6 +356,103 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("reuses a fresh Windows shell environment cache on the next launch", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const stateDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-windows-shell-environment-test-", + }); + const makeEnv = (): NodeJS.ProcessEnv => ({ + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }); + let commandCount = 0; + const handler = (command: ChildProcess.Command) => { + commandCount += 1; + if (command._tag !== "StandardCommand") return ""; + return command.args.includes("-NoProfile") + ? envOutput({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }) + : envOutput({ PATH: "C:\\Profile\\Node;C:\\Windows\\System32" }); + }; + + const firstEnv = makeEnv(); + yield* runShellEnvironment({ + env: firstEnv, + platform: "win32", + stateDir, + handler, + }); + assert.equal(commandCount, 2); + + const secondEnv = makeEnv(); + yield* runShellEnvironment({ + env: secondEnv, + platform: "win32", + stateDir, + handler, + }); + assert.equal(commandCount, 2); + assert.equal(secondEnv.PATH, firstEnv.PATH); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("retries incomplete Windows shell environment probes on the next launch", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const stateDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-windows-shell-environment-test-", + }); + const makeEnv = (): NodeJS.ProcessEnv => ({ + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }); + let commandCount = 0; + let profileProbeSucceeds = false; + const handler = (command: ChildProcess.Command) => { + commandCount += 1; + if (command._tag !== "StandardCommand") return ""; + if (command.args.includes("-NoProfile")) { + return envOutput({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }); + } + return profileProbeSucceeds + ? envOutput({ + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + }) + : envOutput({ FNM_DIR: "C:\\Incomplete\\fnm" }); + }; + + yield* runShellEnvironment({ + env: makeEnv(), + platform: "win32", + stateDir, + handler, + }); + assert.equal(commandCount, 2); + + profileProbeSucceeds = true; + const secondEnv = makeEnv(); + yield* runShellEnvironment({ + env: secondEnv, + platform: "win32", + stateDir, + handler, + }); + + assert.equal(commandCount, 4); + assert.match(secondEnv.PATH ?? "", /^C:\\Profile\\Node;/u); + assert.equal(secondEnv.FNM_DIR, "C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("prefers login-shell desktop session hints over inherited values on linux", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 5c6b67fd58c0..f2e4f9334423 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -16,6 +17,10 @@ interface ShellEnvironmentConfig { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; readonly userShell: Option.Option; + readonly windowsEnvironmentCache?: { + readonly path: string; + readonly directory: string; + }; } interface WindowsProbeOptions { @@ -93,6 +98,20 @@ const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); const LAUNCHCTL_TIMEOUT = Duration.seconds(2); const PROCESS_TERMINATE_GRACE = Duration.seconds(1); +const WINDOWS_ENVIRONMENT_CACHE_MAX_AGE_MS = Duration.toMillis(Duration.hours(24)); +const WINDOWS_ENVIRONMENT_CACHE_FILE_NAME = "windows-shell-environment.json"; + +const WindowsEnvironmentCache = Schema.Struct({ + version: Schema.Literal(1), + capturedAt: Schema.Number, + inheritedPath: Schema.String, + noProfile: Schema.Record(Schema.String, Schema.String), + profile: Schema.Record(Schema.String, Schema.String), +}); +type WindowsEnvironmentCache = typeof WindowsEnvironmentCache.Type; +const WindowsEnvironmentCacheJson = Schema.fromJsonString(WindowsEnvironmentCache); +const decodeWindowsEnvironmentCache = Schema.decodeUnknownOption(WindowsEnvironmentCacheJson); +const encodeWindowsEnvironmentCache = Schema.encodeEffect(WindowsEnvironmentCacheJson); const trimNonEmpty = (value: string | null | undefined): Option.Option => Option.fromNullishOr(value).pipe( @@ -386,19 +405,65 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")( function* ( config: ShellEnvironmentConfig, - ): Effect.fn.Return { - // Concurrent, not sequential: these two probes are independent (only their - // results are combined below) and each spawns its own PowerShell. Run in - // series they sit at offset 0 of desktop.startup, before anything else, and - // launch traces measured them at 2718ms then 2066ms — the entire 4.8s - // startup span, of which desktop.bootstrap is ~30ms. - const [noProfile, profile] = yield* Effect.all( - [ - readWindowsEnvironment(["PATH"], { loadProfile: false }), - readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true }), - ], - { concurrency: 2 }, - ); + ): Effect.fn.Return< + void, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem + > { + const fileSystem = yield* FileSystem.FileSystem; + const inheritedPath = Option.getOrElse(readEnvPath(config.env), () => ""); + const cache = config.windowsEnvironmentCache ?? null; + const cachePath = cache?.path ?? null; + const now = yield* Clock.currentTimeMillis; + // Loading a PowerShell profile dominates warm Windows launches. Reuse its + // small environment snapshot while the inherited PATH is unchanged, but + // bound its lifetime so profile-only changes are eventually discovered. + // A missing, corrupt, or stale cache simply falls through to the probes. + const cached: Option.Option = + cachePath === null + ? Option.none() + : yield* fileSystem.readFileString(cachePath).pipe( + Effect.map(decodeWindowsEnvironmentCache), + Effect.orElseSucceed(() => Option.none()), + Effect.map((entry) => + Option.filter( + entry, + (value) => + value.inheritedPath === inheritedPath && + now >= value.capturedAt && + now - value.capturedAt <= WINDOWS_ENVIRONMENT_CACHE_MAX_AGE_MS, + ), + ), + ); + + const [noProfile, profile] = Option.isSome(cached) + ? [cached.value.noProfile, cached.value.profile] + : yield* Effect.all( + [ + readWindowsEnvironment(["PATH"], { loadProfile: false }), + readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true }), + ], + { concurrency: 2 }, + ); + + const probesCompleted = + Option.isSome(trimNonEmpty(noProfile.PATH)) && Option.isSome(trimNonEmpty(profile.PATH)); + + if (Option.isNone(cached) && cache !== null && probesCompleted) { + const encoded = yield* encodeWindowsEnvironmentCache({ + version: 1, + capturedAt: now, + inheritedPath, + noProfile, + profile, + }).pipe(Effect.option); + if (Option.isSome(encoded)) { + yield* fileSystem.makeDirectory(cache.directory, { recursive: true }).pipe( + Effect.andThen(fileSystem.writeFileString(cache.path, `${encoded.value}\n`)), + Effect.catchCause(() => Effect.void), + ); + } + } const mergedPath = mergePaths("win32", [ trimNonEmpty(profile.PATH), trimNonEmpty(knownWindowsCliDirs(config.env).join(";")), @@ -543,6 +608,17 @@ export const make = Effect.gen(function* () { env: process.env, platform: environment.platform, userShell: Option.none(), + ...(typeof environment.stateDir === "string" && environment.stateDir.length > 0 + ? { + windowsEnvironmentCache: { + path: environment.path.join( + environment.stateDir, + WINDOWS_ENVIRONMENT_CACHE_FILE_NAME, + ), + directory: environment.stateDir, + }, + } + : {}), }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),