diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 365363881f5b..65dc9cc419c5 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -235,12 +235,14 @@ const bootstrap = Effect.gen(function* () { } if (!(yield* Ref.get(state.quitting))) { - // The main window waits for the primary backend. In wsl-only mode that is - // the WSL backend, which can be slow to cold-boot — show a "Connecting to - // WSL" splash immediately so the app feels responsive instead of presenting - // no window until WSL is ready. (Dual mode opens fast off the Windows - // primary, so no splash there.) - if (settings.wslOnly === true && settings.wslBackendEnabled === true) { + // Show a connecting splash on Linux cold boot so the app feels responsive + // instead of presenting no window until the backend is ready. WSL-only mode + // already does this on Windows; keep that path and extend to all Linux + // launches where the backend is not yet running. + if ( + environment.platform === "linux" || + (settings.wslOnly === true && settings.wslBackendEnabled === true) + ) { yield* desktopWindow.showConnectingSplash; } yield* primaryBackend.start; @@ -271,13 +273,24 @@ const startup = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* shellEnvironment.installIntoProcess; + const shellProbeProducedPath = yield* shellEnvironment.installIntoProcess; + // Tell the server's fixPath to skip its own login-shell probe when the + // desktop already hydrated PATH. Only set this marker when we actually + // knew which shell to probe (SHELL is set) and the probe produced a PATH — + // a failed or timed-out probe recovers as an empty patch, and then the + // server should do its own probe instead of trusting it. + if ((process.env.SHELL ?? "").trim().length > 0 && shellProbeProducedPath) { + process.env.__T3CODE_SHELL_ENV_INSTALLED = "1"; + } const hasCommandLinePasswordStore = preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; const linuxElectronOptions = environment.platform === "linux" && !hasCommandLinePasswordStore ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess() : preReadyElectronOptions.linux; + // Resolved after the shell probe so XDG_CURRENT_DESKTOP can come from the + // login shell. These switches must be set before whenReady resolves, or + // Chromium ignores them. if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) { if ( linuxElectronOptions.passwordStore !== null || @@ -310,7 +323,6 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* lifecycle.register; yield* clerk.configure; - yield* electronApp.whenReady.pipe( Effect.withSpan("desktop.electron.whenReady"), Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 9ddaf40caa0a..2835c564fd03 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -12,6 +12,7 @@ const { registerSchemesMock, setDesktopNameMock, mkdirSyncMock, + readFileSyncMock, writeFileSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), @@ -20,6 +21,7 @@ const { registerSchemesMock: vi.fn(), setDesktopNameMock: vi.fn(), mkdirSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), })); @@ -39,12 +41,15 @@ vi.mock("electron", () => ({ })); vi.mock("node:fs", () => ({ - readFileSync: () => "{}", + readFileSync: readFileSyncMock, mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, })); import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; +import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; describe("DesktopPreReadyPlatform", () => { beforeEach(() => { @@ -54,6 +59,7 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockReset(); setDesktopNameMock.mockReset(); mkdirSyncMock.mockReset(); + readFileSyncMock.mockReset(); writeFileSyncMock.mockReset(); }); @@ -83,6 +89,12 @@ describe("DesktopPreReadyPlatform", () => { vi.stubEnv("XDG_DATA_HOME", "/xdg"); vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); getSwitchValueMock.mockReturnValue(""); + readFileSyncMock.mockImplementation(() => { + if (previousEntry === undefined) { + throw new Error("ENOENT"); + } + return previousEntry; + }); let desktopName = "t3code.desktop"; let desktopEntry = previousEntry; setDesktopNameMock.mockImplementation((name: string) => { @@ -111,6 +123,37 @@ describe("DesktopPreReadyPlatform", () => { ); } + it.effect("skips rewriting an unchanged Linux desktop entry to avoid cold-boot writes", () => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); + vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); + getSwitchValueMock.mockReturnValue(""); + writeFileSyncMock.mockReset(); + // The entry on disk already matches what we would render, including the + // trailing newline, so the pre-ready path must not write again. + readFileSyncMock.mockImplementation(() => + renderUrlHandlerDesktopEntry({ + displayName: resolveDesktopAppBranding({ + isDevelopment: false, + appVersion: "0.0.37", + }).displayName, + execTarget: "/Applications/current.AppImage", + scheme: ElectronProtocol.getDesktopScheme(false), + }), + ); + + return DesktopPreReadyPlatform.make.pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.asVoid, + Effect.andThen( + Effect.sync(() => { + assert.equal(writeFileSyncMock.mock.calls.length, 0); + }), + ), + Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + ); + }); + it.effect("keeps startup available when the early desktop entry cannot be written", () => { getSwitchValueMock.mockReturnValue(""); mkdirSyncMock.mockImplementation(() => { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index c07334f33bbb..0ab6abcaa917 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -51,49 +51,59 @@ export class DesktopPreReadyElectronOptions extends Context.Service< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; - return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { - const linuxPasswordStoreCommandLine = - platform === "linux" - ? readCommandLineSwitchValue(Electron.app.commandLine, "password-store") - : null; - const linux = platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess() : null; + const linuxPasswordStoreCommandLine = + platform === "linux" + ? readCommandLineSwitchValue(Electron.app.commandLine, "password-store") + : null; + const linux = platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess() : null; - if (linux !== null) { - // The portal also requires a valid desktop entry. An AppImage update may - // have removed the executable referenced by the previous launch's entry. + if (linux !== null) { + // The portal also requires a valid desktop entry. An AppImage update may + // have removed the executable referenced by the previous launch's entry. + try { + const applicationsDir = NodePath.posix.join( + process.env.XDG_DATA_HOME?.trim() || + NodePath.posix.join(NodeOS.homedir(), ".local", "share"), + "applications", + ); + NodeFS.mkdirSync(applicationsDir, { recursive: true }); + const desktopEntryPath = NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName); + const newEntryContent = renderUrlHandlerDesktopEntry({ + displayName: resolveDesktopAppBranding({ + isDevelopment: linux.isDevelopment, + appVersion: Electron.app.getVersion(), + }).displayName, + execTarget: process.env.APPIMAGE?.trim() || process.execPath, + scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), + }); + // Only rewrite the desktop entry when content actually changed to avoid + // unnecessary filesystem writes and desktop database notifications on every + // cold boot. A missing or unreadable entry is treated as absent content so + // the initial launch still writes it. Both sides are compared unchanged — + // trimming only one side would force a rewrite on every boot when the + // written content ends in a newline. + let existingContent: string | null = null; try { - const applicationsDir = NodePath.posix.join( - process.env.XDG_DATA_HOME?.trim() || - NodePath.posix.join(NodeOS.homedir(), ".local", "share"), - "applications", - ); - NodeFS.mkdirSync(applicationsDir, { recursive: true }); - NodeFS.writeFileSync( - NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), - renderUrlHandlerDesktopEntry({ - displayName: resolveDesktopAppBranding({ - isDevelopment: linux.isDevelopment, - appVersion: Electron.app.getVersion(), - }).displayName, - execTarget: process.env.APPIMAGE?.trim() || process.execPath, - scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), - }), - "utf8", - ); + existingContent = NodeFS.readFileSync(desktopEntryPath, "utf8"); } catch { - // The URL handler retries with the full environment and logs failures. + // Missing or unreadable entry is treated as absent. } - // Chromium caches its portal registration during startup. Set the identity - // before any asynchronous work can initialize it with Electron's default. - Electron.app.setDesktopName(linux.linuxDesktopEntryName); - Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass); - if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { - Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore); + if (existingContent !== newEntryContent) { + NodeFS.writeFileSync(desktopEntryPath, newEntryContent, "utf8"); } + } catch { + // The URL handler retries with the full environment and logs failures. } + // Chromium caches its portal registration during startup. Set the identity + // before any asynchronous work can initialize it with Electron's default. + Electron.app.setDesktopName(linux.linuxDesktopEntryName); + Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass); + if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { + Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore); + } + } - return { linux, linuxPasswordStoreCommandLine }; - }); + return { linux, linuxPasswordStoreCommandLine }; }).pipe(Effect.withSpan("desktop.electron.configureBeforeReady")); // Keep Electron's strict pre-ready setup isolated so later runtime layers cannot diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 5a76402b1d34..7453513bed58 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -87,7 +87,7 @@ function runShellEnvironment(input: { const program = Effect.gen(function* () { const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; - yield* shellEnvironment.installIntoProcess; + return yield* shellEnvironment.installIntoProcess; }).pipe( Effect.provide( DesktopShellEnvironment.layer.pipe( @@ -108,7 +108,7 @@ describe("DesktopShellEnvironment", () => { }; const commands: ChildProcess.Command[] = []; - yield* runShellEnvironment({ + const producedPath = yield* runShellEnvironment({ env, platform: "darwin", handler: (command) => { @@ -126,6 +126,7 @@ describe("DesktopShellEnvironment", () => { assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); + assert.equal(producedPath, true); }), ); @@ -276,6 +277,48 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("reports success when the probe returns the inherited PATH unchanged", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + const producedPath = yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => envOutput({ PATH: "/usr/bin" }), + }); + + assert.equal(env.PATH, "/usr/bin"); + assert.equal(producedPath, true); + }), + ); + + it.effect("reports failure when the login-shell probe errors", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + const producedPath = yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => "", + failure: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: "/bin/zsh", + }), + }); + + assert.equal(env.PATH, "/usr/bin"); + assert.equal(producedPath, false); + }), + ); + it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () => 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..4702e72fe975 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -63,7 +63,10 @@ export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErr export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, { - readonly installIntoProcess: Effect.Effect; + // True when a probe actually produced a PATH (even one identical to the + // inherited value). Failed or timed-out probes recover as an empty patch + // and report false, so callers can tell success apart from failure. + readonly installIntoProcess: Effect.Effect; } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} @@ -386,7 +389,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")( function* ( config: ShellEnvironmentConfig, - ): Effect.fn.Return { + ): 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 @@ -415,6 +418,7 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) { config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH; } + return Option.isSome(trimNonEmpty(profile.PATH)) || Option.isSome(trimNonEmpty(noProfile.PATH)); }, ); @@ -422,7 +426,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return< - void, + boolean, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem > { @@ -518,19 +522,25 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } } + + return Option.isSome(trimNonEmpty(shellEnvironment.PATH)); }, ); const installShellEnvironment = ( config: ShellEnvironmentConfig, -): Effect.Effect => { +): Effect.Effect< + boolean, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem +> => { if (config.platform === "win32") { return installWindowsEnvironment(config); } if (config.platform === "darwin" || config.platform === "linux") { return installPosixEnvironment(config); } - return Effect.void; + return Effect.succeed(false); }; /** @public Service construction is part of the canonical Effect module API. */ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b3964cb929c7..30ded48aea02 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -196,15 +196,16 @@ export function resolveInitialMainWindowBounds( return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; } -// A self-contained "Connecting to WSL" splash, shown immediately in wsl-only -// mode while the WSL backend (which serves the renderer) cold-boots. Inlined as -// a data URL so it needs no bundled asset and no backend — pure CSS, no JS. +// A self-contained "Connecting…" splash, shown immediately while the backend +// cold-boots. Inlined as a data URL so it needs no bundled asset and no +// backend — pure CSS, no JS. The label is intentionally backend-agnostic +// since this splash is used for both WSL-only and native Linux boots. function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { const background = getInitialWindowBackgroundColor(shouldUseDarkColors); const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937"; const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; - const html = `
Connecting to WSL…
`; + const html = `
Connecting…
`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 69f8a6d4af16..0b280fb6ffef 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -137,6 +137,12 @@ private enum TerminalInputSequence { private final class TerminalInputField: UITextField { var onDeleteBackward: (() -> Void)? var onInsert: ((String) -> Void)? + /// Uptime of the last deleteBackward(). Most keyboards route Backspace + /// through deleteBackward() AND the delegate's empty-replacement callback + /// for the same keypress; the delegate reads this to avoid emitting DEL + /// twice. Third-party keyboards that bypass deleteBackward (#11449) leave + /// it stale, so it is only honored within a short window. + var lastDeleteBackwardAt: TimeInterval = 0 private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands( action: #selector(handleHardwareKeyCommand(_:)) @@ -147,6 +153,7 @@ private final class TerminalInputField: UITextField { } override func deleteBackward() { + lastDeleteBackwardAt = ProcessInfo.processInfo.systemUptime onDeleteBackward?() super.deleteBackward() } @@ -410,7 +417,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { return false } - emitInput("\u{7F}") + // The empty-replacement path fires for the same keypress that already + // went through deleteBackward() on most keyboards — emitting here too + // would delete two characters. Only emit for keyboards that bypass + // deleteBackward() entirely (#11449). + let deleteHandled = ProcessInfo.processInfo.systemUptime - inputField.lastDeleteBackwardAt < 1.0 + if !deleteHandled { + emitInput("\u{7F}") + } return false } diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index a157efb665fa..16f1bfc5414c 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -1,7 +1,14 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; -import { assert, it } from "vite-plus/test"; +import * as NodePath from "node:path"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { hydratePosixHome } from "./os-jank.ts"; +import { fixPath } from "./os-jank.ts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; it("hydrates HOME for minimal service environments from the user account", () => { const env: NodeJS.ProcessEnv = {}; @@ -38,3 +45,47 @@ it("preserves an explicitly configured HOME", () => { assert.equal(env.HOME, "/custom/home"); }); + +it.effect("fixPath skips hydratePosixPath when __T3CODE_SHELL_ENV_INSTALLED is 1", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + __T3CODE_SHELL_ENV_INSTALLED: "1", + HOME: "/home/user", + }; + yield* fixPath().pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, env), + Effect.provide(NodeServices.layer), + ); + assert.equal(env.PATH, undefined); + }), +); + +it.effect("fixPath hydrates PATH from the login shell when the marker is absent", () => + Effect.gen(function* () { + if (process.platform === "win32") return; + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-os-jank-probe-")); + try { + const fakeShell = NodePath.join(dir, "fake-shell"); + NodeFS.writeFileSync( + fakeShell, + [ + "#!/bin/sh", + "printf '%s\\n' '__T3CODE_ENV_PATH_START__'", + "printf '%s\\n' '/sentinel/probe/bin'", + "printf '%s\\n' '__T3CODE_ENV_PATH_END__'", + ].join("\n"), + ); + NodeFS.chmodSync(fakeShell, 0o755); + const env: NodeJS.ProcessEnv = { SHELL: fakeShell, HOME: "/home/user", PATH: "/usr/bin" }; + yield* fixPath().pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, env), + Effect.provide(NodeServices.layer), + ); + assert.equal(env.PATH, "/sentinel/probe/bin:/usr/bin"); + } finally { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }), +); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 18ddbc66c0c8..23207ce51b37 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -75,20 +75,25 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< if (platform !== "darwin" && platform !== "linux") return; - yield* Effect.sync(() => hydratePosixHome(env)).pipe( - Effect.catchDefect((defect) => - Effect.sync(() => { - logPathHydrationWarning("Failed to hydrate HOME from the user account.", defect); - }), - ), - ); - yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( - Effect.catchDefect((defect) => - Effect.sync(() => { - logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); - }), - ), - ); + // If the desktop already installed the shell environment before the server + // started, skip the second login-shell probe. The parent desktop process has + // already merged PATH and session variables into its own environment, and the + // server inherits them directly. Re-running the probe would spawn an extra + // bash -ilc just to re-derive the same values. The desktop only sets the + // marker when its probe actually produced a PATH, so a failed or timed-out + // probe still falls through to the hydration below. + try { + hydratePosixHome(env); + } catch (defect) { + logPathHydrationWarning("Failed to hydrate HOME from the user account.", defect); + } + if (env.__T3CODE_SHELL_ENV_INSTALLED !== "1") { + try { + hydratePosixPath(env, platform); + } catch (defect) { + logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); + } + } }); export const expandHomePath = Effect.fn(function* (input: string) { diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index ab654e15c70b..e3a2bf6d4055 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -11,16 +11,29 @@ import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; +import { HttpServer } from "effect/unstable/http"; import * as ServerConfig from "./config.ts"; +import * as Keybindings from "./keybindings.ts"; +import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as OrchestrationReactor from "./orchestration/Services/OrchestrationReactor.ts"; +import * as ExternalLauncher from "./process/externalLauncher.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; +import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; +import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => @@ -135,6 +148,132 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => ), ); +it.effect("publishes readiness without waiting for background auto-pull", () => + Effect.scoped( + Effect.gen(function* () { + const pullGate = yield* Deferred.make(); + const pullStarted = yield* Ref.make(false); + const pullDone = yield* Deferred.make(); + const pulled: string[] = []; + const published = yield* Ref.make([]); + + const git = { + statusDetails: () => + Effect.succeed({ + isRepo: true, + isDefaultBranch: true, + hasUpstream: true, + hasWorkingTreeChanges: false, + aheadCount: 0, + behindCount: 1, + } as never), + pullCurrentBranch: (cwd: string) => + Effect.gen(function* () { + yield* Ref.set(pullStarted, true); + yield* Deferred.await(pullGate); + pulled.push(cwd); + yield* Deferred.succeed(pullDone, undefined); + return { + status: "pulled" as const, + refName: "main", + upstreamRef: "origin/main", + }; + }), + } as unknown as GitVcsDriver.GitVcsDriver["Service"]; + + const program = Effect.gen(function* () { + const startup = yield* ServerRuntimeStartup.make(); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.equal(yield* Ref.get(pullStarted), false); + + yield* startup.markHttpListening; + // Readiness must resolve while the pull is still gated: the real + // startup flow must not wait for auto-pull before accepting + // commands. If auto-pull moved back before readiness, this never + // resolves and the test times out. + yield* startup.awaitCommandReady; + + // Release the gate only after readiness resolved; the background + // pull then runs to completion. + yield* Deferred.succeed(pullGate, undefined); + yield* Deferred.await(pullDone); + + assert.equal(yield* Ref.get(pullStarted), true); + assert.deepStrictEqual(pulled, ["/repo"]); + assert.deepStrictEqual(yield* Ref.get(published), ["welcome", "ready"]); + }); + + yield* program.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + mode: "desktop", + host: "127.0.0.1", + port: 3773, + cwd: "/tmp/startup-ordering", + noBrowser: true, + startupPresentation: "browser", + autoBootstrapProjectFromCwd: false, + } as never), + Effect.provideService(Keybindings.Keybindings, { start: Effect.void } as never), + Effect.provideService(OrchestrationReactor.OrchestrationReactor, { + start: () => Effect.void, + } as never), + Effect.provideService(ProviderSessionReaper.ProviderSessionReaper, { + start: () => Effect.void, + } as never), + Effect.provideService(ServerLifecycleEvents.ServerLifecycleEvents, { + publish: (event: { readonly type: string }) => + Ref.update(published, (events) => [...events, event.type]).pipe(Effect.as({} as never)), + } as never), + Effect.provideService(ServerEnvironment.ServerEnvironment, { + getDescriptor: Effect.succeed({} as never), + } as never), + Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, { + prepareTrial: Effect.succeed(undefined), + } as never), + Effect.provideService(ProviderService.ProviderService, { + listSessions: () => Effect.succeed([]), + } as never), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { + listBindings: () => Effect.succeed([]), + } as never), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + dispatch: () => Effect.die("unused"), + } as never), + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.succeed({ threads: [] }), + listActivitiesByKind: () => Effect.succeed([]), + getShellSnapshot: () => + Effect.succeed({ + projects: [{ id: ProjectId.make("/repo"), workspaceRoot: "/repo" }], + }), + getCounts: () => Effect.succeed({ threadCount: 0, projectCount: 0 }), + } as never), + Effect.provideService(GitVcsDriver.GitVcsDriver, git), + // Unreached in this configuration (desktop mode skips pairing auth, + // noBrowser skips the launcher, non-headless skips the HTTP server), + // but statically required by the startup flow. + Effect.provideService(EnvironmentAuth.EnvironmentAuth, { + issueStartupPairingUrl: () => Effect.die("unused"), + issueStartupPairingCredential: () => Effect.die("unused"), + } as never), + Effect.provideService(ExternalLauncher.ExternalLauncher, { + launchBrowser: () => Effect.die("unused"), + } as never), + Effect.provideService(HttpServer.HttpServer, {} as never), + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ defaultAutoPull: true }), + AnalyticsService.AnalyticsService.layerTest, + NodeServices.layer, + ), + ), + ); + }), + ), +); + it.effect("resolveWelcomeBase derives cwd and project name from server config", () => Effect.gen(function* () { const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 1468e1efecb0..1953cc0ea8b2 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -969,12 +969,13 @@ export const make = (options?: StartupOptions) => }), ); + // Reconcile before accepting commands. Deferring it past signalCommandReady + // would let a client start a provider session while the reconciler snapshots + // the live set, classifying the new session as dead and overwriting its turn + // with an error status. yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions); yield* runStartupPhase("worktree-setups.reconcile", reconcileWorktreeSetups); - yield* Effect.logDebug("startup phase: syncing clean projects"); - yield* runStartupPhase("projects.auto-pull", syncAutoPullProjects); - const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); @@ -1077,6 +1078,17 @@ export const make = (options?: StartupOptions) => }, }), ); + + // Defer non-critical startup work until after the server is ready to serve + // commands. autoPullProjects can run in the background without blocking the + // first paint on desktop or the ready event for remote clients. + yield* forkParked( + Effect.gen(function* () { + yield* Effect.logDebug("startup phase: syncing clean projects"); + yield* runStartupPhase("projects.auto-pull", syncAutoPullProjects); + }), + ); + yield* Effect.logDebug("startup phase: complete"); }).pipe( Effect.annotateSpans({ diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 5cf3b7c4b4b9..1331b28395af 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2637,6 +2637,65 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("serializes concurrent pulls of the same working tree", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + const origin = yield* makeTmpDir("git-pull-origin-"); + yield* initRepoWithCommit(origin); + const parent = yield* makeTmpDir("git-pull-parent-"); + const clone = pathService.join(parent, "clone"); + yield* git(parent, ["clone", origin, clone]); + yield* git(clone, ["config", "user.email", "test@test.com"]); + yield* git(clone, ["config", "user.name", "Test"]); + yield* writeTextFile(origin, "remote.txt", "remote\n"); + yield* git(origin, ["add", "remote.txt"]); + yield* git(origin, ["commit", "-m", "remote change"]); + const expectedHead = yield* git(origin, ["rev-parse", "HEAD"]); + + const results = yield* Effect.all( + [driver.pullCurrentBranch(clone), driver.pullCurrentBranch(clone)], + { concurrency: 2 }, + ); + assert.deepEqual(results.map((result) => result.status).sort(), [ + "pulled", + "skipped_up_to_date", + ]); + assert.equal(yield* git(clone, ["rev-parse", "HEAD"]), expectedHead); + }), + ); + + it.effect("serializes concurrent pulls from a root and its subdirectory", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const origin = yield* makeTmpDir("git-pull-origin-"); + yield* initRepoWithCommit(origin); + const parent = yield* makeTmpDir("git-pull-parent-"); + const clone = pathService.join(parent, "clone"); + yield* git(parent, ["clone", origin, clone]); + yield* git(clone, ["config", "user.email", "test@test.com"]); + yield* git(clone, ["config", "user.name", "Test"]); + const subdir = pathService.join(clone, "sub"); + yield* fileSystem.makeDirectory(subdir, { recursive: true }); + yield* writeTextFile(origin, "remote.txt", "remote\n"); + yield* git(origin, ["add", "remote.txt"]); + yield* git(origin, ["commit", "-m", "remote change"]); + const expectedHead = yield* git(origin, ["rev-parse", "HEAD"]); + + const results = yield* Effect.all( + [driver.pullCurrentBranch(clone), driver.pullCurrentBranch(subdir)], + { concurrency: 2 }, + ); + assert.deepEqual(results.map((result) => result.status).sort(), [ + "pulled", + "skipped_up_to_date", + ]); + assert.equal(yield* git(clone, ["rev-parse", "HEAD"]), expectedHead); + }), + ); + it.effect("allows pushes to run longer than the default command timeout", () => Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 9367d027a210..dba7690bb836 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3536,6 +3536,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ]), ), ); + // Serializes pull operations per working tree. The startup auto-pull runs in + // the background after commands are accepted, so a client-issued pull for the + // same workspace could otherwise overlap its `git pull --ff-only` and fail on + // the repository lock. + const pullLocksByCwd = new Map(); + const withPullLock = (cwd: string, effect: Effect.Effect): Effect.Effect => + Effect.gen(function* () { + // Key by repository, not request directory: `cwd` may be any directory + // inside the worktree, so a root pull and a subdirectory pull must share + // one semaphore or their `git pull --ff-only` invocations can still + // overlap on the repository lock. + const key = yield* resolveGitCommonDir(cwd).pipe( + Effect.catchTags({ + GitCommandError: () => Effect.succeed(normalizeRepositoryPathsCacheKey(cwd)), + }), + ); + let lock = pullLocksByCwd.get(key); + if (lock === undefined) { + lock = Semaphore.makeUnsafe(1); + pullLocksByCwd.set(key, lock); + } + return yield* lock.withPermit(effect); + }); const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( input, ) => @@ -3561,7 +3584,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* withListRefsInvalidation(cwd, commit(cwd, subject, body, options)), pushCurrentBranch: (cwd, fallbackBranch, options) => withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)), - pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)), + pullCurrentBranch: (cwd) => + withPullLock(cwd, withListRefsInvalidation(cwd, pullCurrentBranch(cwd))), readRangeContext, getReviewDiffPreview, getReviewDiffFileContents,