From f0fae69358ce90f4bc90f96efa2c9f1513f9b3a1 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Wed, 9 Sep 2026 23:06:42 +0530 Subject: [PATCH 01/21] faster linux boot: splash earlier, defer reconcile, skip redundant shell probe Desktop shows connecting splash on all Linux boots, not just WSL-only. Shell environment installs after electron whenReady so the splash gets to show before the slow login-shell probe. Server skip marker avoids a second bash -ilc in fixPath. reconcileProviderSessions and autoPullProjects defer to after ready so the first paint and remote ready event are not blocked. PR made by claude-sonnet-4.6 from opencode on behalf of Kriday --- apps/desktop/src/app/DesktopApp.ts | 25 +++--- .../src/app/DesktopPreReadyPlatform.ts | 84 ++++++++++--------- apps/server/src/os-jank.ts | 31 +++---- apps/server/src/serverRuntimeStartup.ts | 19 +++-- 4 files changed, 90 insertions(+), 69 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e84e6d5c730d..61ef6016896b 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -210,11 +210,11 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { - // In wsl-only mode the renderer is served by 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; extend it to all Linux launches where the backend is + // not yet running. The splash is dismissed when handleBackendReady fires. + if (environment.platform === "linux") { yield* desktopWindow.showConnectingSplash; } yield* primaryBackend.start; @@ -245,7 +245,16 @@ const startup = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; + yield* electronApp.whenReady.pipe( + Effect.withSpan("desktop.electron.whenReady"), + Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), + ); + yield* logStartupInfo("app ready"); yield* shellEnvironment.installIntoProcess; + // Mark the process environment so the server's fixPath knows the desktop + // already did the login-shell probe. This avoids a redundant bash -ilc + // spawn when the server starts in the same process. + process.env.__T3CODE_SHELL_ENV_INSTALLED = "1"; const hasCommandLinePasswordStore = preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; const linuxElectronOptions = @@ -284,12 +293,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)), - ); - yield* logStartupInfo("app ready"); if (environment.platform === "linux") { const selectedBackend = yield* safeStorage.selectedStorageBackend; yield* logStartupInfo("safe storage ready", { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index c07334f33bbb..387224da7301 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -51,49 +51,55 @@ 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. - 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", - ); - } 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); + 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. The URL handler retries with the full environment and logs + // failures, so a stale entry from a removed AppImage is eventually corrected. + const existingContent = NodeFS.readFileSync(desktopEntryPath, "utf8").trim(); + 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/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 18ddbc66c0c8..8218a9ba63ff 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -75,20 +75,23 @@ 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. + 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.ts b/apps/server/src/serverRuntimeStartup.ts index 3d04abaa1914..c1cf4414680b 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -875,11 +875,6 @@ export const make = (options?: StartupOptions) => }), ); - yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions); - - 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"); @@ -982,6 +977,20 @@ export const make = (options?: StartupOptions) => }, }), ); + + // Defer non-critical startup work until after the server is ready to serve + // commands. reconcileProviderSessions and 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* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions); + + yield* Effect.logDebug("startup phase: syncing clean projects"); + yield* runStartupPhase("projects.auto-pull", syncAutoPullProjects); + }), + ); + yield* Effect.logDebug("startup phase: complete"); }).pipe( Effect.annotateSpans({ From 2b2514e99c0aeacd8b1a98cec341683f01107297 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Wed, 9 Sep 2026 23:28:24 +0530 Subject: [PATCH 02/21] Fix review findings: reconcile before ready, fresh profile entry, keep WSL splash - Keep reconcileProviderSessions before signalCommandReady to avoid the race where a client-started session gets classified as dead - Treat missing desktop entry as empty content so fresh profiles write it - Keep the WSL-only splash condition alongside the new Linux one --- apps/desktop/src/app/DesktopApp.ts | 9 ++++++--- apps/desktop/src/app/DesktopPreReadyPlatform.ts | 13 ++++++------- apps/server/src/serverRuntimeStartup.ts | 13 ++++++++----- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 61ef6016896b..ea62ab785cb3 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -212,9 +212,12 @@ const bootstrap = Effect.gen(function* () { if (!(yield* Ref.get(state.quitting))) { // 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; extend it to all Linux launches where the backend is - // not yet running. The splash is dismissed when handleBackendReady fires. - if (environment.platform === "linux") { + // 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; diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 387224da7301..3eba515578a6 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -67,10 +67,7 @@ export const make = Effect.gen(function* () { "applications", ); NodeFS.mkdirSync(applicationsDir, { recursive: true }); - const desktopEntryPath = NodePath.posix.join( - applicationsDir, - linux.linuxDesktopEntryName, - ); + const desktopEntryPath = NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName); const newEntryContent = renderUrlHandlerDesktopEntry({ displayName: resolveDesktopAppBranding({ isDevelopment: linux.isDevelopment, @@ -81,9 +78,11 @@ export const make = Effect.gen(function* () { }); // Only rewrite the desktop entry when content actually changed to avoid // unnecessary filesystem writes and desktop database notifications on every - // cold boot. The URL handler retries with the full environment and logs - // failures, so a stale entry from a removed AppImage is eventually corrected. - const existingContent = NodeFS.readFileSync(desktopEntryPath, "utf8").trim(); + // cold boot. A missing entry (fresh profile) is just empty content so the + // initial launch still writes it. + const existingContent = NodeFS.existsSync(desktopEntryPath) + ? NodeFS.readFileSync(desktopEntryPath, "utf8").trim() + : ""; if (existingContent !== newEntryContent) { NodeFS.writeFileSync(desktopEntryPath, newEntryContent, "utf8"); } diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index c1cf4414680b..1064442db37a 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -875,6 +875,12 @@ 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); + const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); @@ -979,13 +985,10 @@ export const make = (options?: StartupOptions) => ); // Defer non-critical startup work until after the server is ready to serve - // commands. reconcileProviderSessions and autoPullProjects can run in the - // background without blocking the first paint on desktop or the ready event - // for remote clients. + // 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* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions); - yield* Effect.logDebug("startup phase: syncing clean projects"); yield* runStartupPhase("projects.auto-pull", syncAutoPullProjects); }), From 880652f42ca304ca5480b9d984d5d13dea045795 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Wed, 9 Sep 2026 23:36:35 +0530 Subject: [PATCH 03/21] Update DesktopPreReadyPlatform test for existsSync check --- apps/desktop/src/app/DesktopPreReadyPlatform.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 9ddaf40caa0a..a84acb8d600b 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, + existsSyncMock, writeFileSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), @@ -20,6 +21,7 @@ const { registerSchemesMock: vi.fn(), setDesktopNameMock: vi.fn(), mkdirSyncMock: vi.fn(), + existsSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), })); @@ -39,6 +41,7 @@ vi.mock("electron", () => ({ })); vi.mock("node:fs", () => ({ + existsSync: existsSyncMock, readFileSync: () => "{}", mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, @@ -54,6 +57,7 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockReset(); setDesktopNameMock.mockReset(); mkdirSyncMock.mockReset(); + existsSyncMock.mockReset(); writeFileSyncMock.mockReset(); }); @@ -83,6 +87,7 @@ describe("DesktopPreReadyPlatform", () => { vi.stubEnv("XDG_DATA_HOME", "/xdg"); vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); getSwitchValueMock.mockReturnValue(""); + existsSyncMock.mockReturnValue(previousEntry !== undefined); let desktopName = "t3code.desktop"; let desktopEntry = previousEntry; setDesktopNameMock.mockImplementation((name: string) => { From 4e5acb80de70cbe7b52eb1959248990a3f0e66ca Mon Sep 17 00:00:00 2001 From: kridaydave Date: Wed, 9 Sep 2026 23:51:10 +0530 Subject: [PATCH 04/21] Fix CodeRabbit: password-store switches before ready, symmetric entry compare - Restore password-store command-line switch setup before whenReady so Chromium accepts it; still resolved after the shell probe so login-shell XDG_CURRENT_DESKTOP hints keep working on KDE - Compare desktop entry content untrimmed on both sides; trimming only the existing content forced a rewrite on every cold boot - Add a test asserting an unchanged entry is not rewritten --- apps/desktop/src/app/DesktopApp.ts | 13 +++-- .../src/app/DesktopPreReadyPlatform.test.ts | 50 ++++++++++++++++--- .../src/app/DesktopPreReadyPlatform.ts | 15 ++++-- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index ea62ab785cb3..429b55879492 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -248,11 +248,6 @@ const startup = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* electronApp.whenReady.pipe( - Effect.withSpan("desktop.electron.whenReady"), - Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), - ); - yield* logStartupInfo("app ready"); yield* shellEnvironment.installIntoProcess; // Mark the process environment so the server's fixPath knows the desktop // already did the login-shell probe. This avoids a redundant bash -ilc @@ -264,6 +259,9 @@ const startup = Effect.gen(function* () { 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 || @@ -296,6 +294,11 @@ 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)), + ); + yield* logStartupInfo("app ready"); if (environment.platform === "linux") { const selectedBackend = yield* safeStorage.selectedStorageBackend; yield* logStartupInfo("safe storage ready", { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index a84acb8d600b..2835c564fd03 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -12,7 +12,7 @@ const { registerSchemesMock, setDesktopNameMock, mkdirSyncMock, - existsSyncMock, + readFileSyncMock, writeFileSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), @@ -21,7 +21,7 @@ const { registerSchemesMock: vi.fn(), setDesktopNameMock: vi.fn(), mkdirSyncMock: vi.fn(), - existsSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), })); @@ -41,13 +41,15 @@ vi.mock("electron", () => ({ })); vi.mock("node:fs", () => ({ - existsSync: existsSyncMock, - 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(() => { @@ -57,7 +59,7 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockReset(); setDesktopNameMock.mockReset(); mkdirSyncMock.mockReset(); - existsSyncMock.mockReset(); + readFileSyncMock.mockReset(); writeFileSyncMock.mockReset(); }); @@ -87,7 +89,12 @@ describe("DesktopPreReadyPlatform", () => { vi.stubEnv("XDG_DATA_HOME", "/xdg"); vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); getSwitchValueMock.mockReturnValue(""); - existsSyncMock.mockReturnValue(previousEntry !== undefined); + readFileSyncMock.mockImplementation(() => { + if (previousEntry === undefined) { + throw new Error("ENOENT"); + } + return previousEntry; + }); let desktopName = "t3code.desktop"; let desktopEntry = previousEntry; setDesktopNameMock.mockImplementation((name: string) => { @@ -116,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 3eba515578a6..0ab6abcaa917 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -78,11 +78,16 @@ export const make = Effect.gen(function* () { }); // Only rewrite the desktop entry when content actually changed to avoid // unnecessary filesystem writes and desktop database notifications on every - // cold boot. A missing entry (fresh profile) is just empty content so the - // initial launch still writes it. - const existingContent = NodeFS.existsSync(desktopEntryPath) - ? NodeFS.readFileSync(desktopEntryPath, "utf8").trim() - : ""; + // 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 { + existingContent = NodeFS.readFileSync(desktopEntryPath, "utf8"); + } catch { + // Missing or unreadable entry is treated as absent. + } if (existingContent !== newEntryContent) { NodeFS.writeFileSync(desktopEntryPath, newEntryContent, "utf8"); } From ed26c722b5e8c65077112942094b1f4ae966f847 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Thu, 10 Sep 2026 00:06:52 +0530 Subject: [PATCH 05/21] Fix Macroscope: generic splash label, only set shell marker when SHELL is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Splash now says "Connecting…" instead of "Connecting to WSL" since it shows on all Linux boots, not just WSL-only - Only set __T3CODE_SHELL_ENV_INSTALLED when SHELL is set so the server retains its own login-shell probe for users whose shell is not bash --- apps/desktop/src/app/DesktopApp.ts | 12 ++++++++---- apps/desktop/src/window/DesktopWindow.ts | 9 +++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 429b55879492..ed632908d29b 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -249,10 +249,14 @@ const startup = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* shellEnvironment.installIntoProcess; - // Mark the process environment so the server's fixPath knows the desktop - // already did the login-shell probe. This avoids a redundant bash -ilc - // spawn when the server starts in the same process. - process.env.__T3CODE_SHELL_ENV_INSTALLED = "1"; + // 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) — otherwise the server should + // do its own probe with the user's real shell (zsh, fish, ...) instead of + // trusting the desktop's fallback probe. + if ((process.env.SHELL ?? "").trim().length > 0) { + process.env.__T3CODE_SHELL_ENV_INSTALLED = "1"; + } const hasCommandLinePasswordStore = preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; const linuxElectronOptions = diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0a966ec36e4d..880227b33cde 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -180,15 +180,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)}`; } From 0a18a3359ab61d112ea06d93ad35ea63b280aa88 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Sat, 12 Sep 2026 19:38:26 +0530 Subject: [PATCH 06/21] fix(mobile): pin expo-audio to 57.0.4 so the iOS patch stays applied The release smoke test deletes the lockfile and re-resolves. With a ~57.0.4 range that now picks up 57.0.5, which leaves the patches/expo-audio@57.0.4.patch unused and fails the install with ERR_PNPM_UNUSED_PATCH. 57.0.5 does not include the AudioRecorder fix, so pin to the patched version instead. Commit made by muse-spark in opencode on behalf of Kriday. --- apps/mobile/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a5d2f40b4a93..461e7edf56cf 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -75,7 +75,7 @@ "effect": "catalog:", "expo": "~57.0.18", "expo-asset": "~57.0.15", - "expo-audio": "~57.0.4", + "expo-audio": "57.0.4", "expo-auth-session": "~57.0.10", "expo-blur": "~57.0.2", "expo-build-properties": "~57.0.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f175605d8bf..1e33a5d03964 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,7 +321,7 @@ importers: specifier: ~57.0.15 version: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2) expo-audio: - specifier: ~57.0.4 + specifier: 57.0.4 version: 57.0.4(patch_hash=fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a)(expo-asset@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-auth-session: specifier: ~57.0.10 From f6307c1037d758febaa23f97babd5600e2af3607 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Sun, 13 Sep 2026 20:59:39 +0530 Subject: [PATCH 07/21] Fix multiple issues: iOS backspace, Forgejo error details, dialog click, model picker tooltip, badge overlap - #11449: Emit DEL (\u{7F}) when third-party iOS keyboard backspace sends empty replacement - #11568: Read Forgejo response body before constructing error detail for API failures - #11573: Add -webkit-app-region:no-drag to shared dialog popup base class - #11536: Show model-picker shortcut in ProviderModelPicker tooltip - #11562: Add relative z-10 to ProviderInstanceIcon elements to prevent badge overlap Co-authored-by: opencode --- .../t3-terminal/ios/T3TerminalView.swift | 1 + apps/server/src/sourceControl/ForgejoCli.ts | 42 ++++++++++--------- .../components/chat/ProviderInstanceIcon.tsx | 4 +- .../components/chat/ProviderModelPicker.tsx | 10 ++++- apps/web/src/components/ui/dialog-styles.ts | 2 +- 5 files changed, 36 insertions(+), 23 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 262cc8a8a74d..69f8a6d4af16 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -410,6 +410,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { return false } + emitInput("\u{7F}") return false } diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts index 9fdb4149c8b6..0eacba0759cf 100644 --- a/apps/server/src/sourceControl/ForgejoCli.ts +++ b/apps/server/src/sourceControl/ForgejoCli.ts @@ -366,25 +366,6 @@ export const make = Effect.gen(function* () { .execute(request) .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); const status = response.status; - if (status < 200 || status >= 300) - return yield* new ForgejoCliError({ - command: "fj", - cwd: input.cwd, - httpStatus: status, - ...(status === 401 - ? { reason: "authentication" as const } - : status === 403 - ? { reason: "forbidden" as const } - : status === 404 - ? { reason: "not-found" as const } - : status === 429 - ? { reason: "rate-limit" as const } - : {}), - detail: - status === 404 - ? "Forgejo repository or pull request was not found." - : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`, - }); const body = status === 204 || status === 205 ? { text: "", truncated: false, invalidUtf8: false } @@ -399,6 +380,29 @@ export const make = Effect.gen(function* () { reason: "invalid-response", detail: "Forgejo returned an oversized or invalid response.", }); + if (status < 200 || status >= 300) { + const detail = + status === 404 + ? "Forgejo repository or pull request was not found." + : body.text + ? `Forgejo API request failed (HTTP ${status}): ${body.text}` + : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`; + return yield* new ForgejoCliError({ + command: "fj", + cwd: input.cwd, + httpStatus: status, + ...(status === 401 + ? { reason: "authentication" as const } + : status === 403 + ? { reason: "forbidden" as const } + : status === 404 + ? { reason: "not-found" as const } + : status === 429 + ? { reason: "rate-limit" as const } + : {}), + detail, + }); + } return { exitCode: ChildProcessSpawner.ExitCode(0), stdout: body.text, diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 4a40ed15bcb3..d1b19a0038e6 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -36,9 +36,9 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { data-provider-accent-color={props.accentColor} > {Icon ? ( - + ) : ( - + {providerInstanceInitials(props.displayName)} )} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 6f777399451d..5b72d22133cf 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -25,6 +25,7 @@ import { type ComposerControlSize, } from "./ComposerControl"; import { composerFloatingLayerProps } from "./composerEventScope"; +import { shortcutLabelForCommand } from "../../keybindings"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /** @@ -153,6 +154,13 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { setIsMenuOpen(false); }; + const shortcutLabel = props.keybindings + ? shortcutLabelForCommand(props.keybindings, "modelPicker.toggle") + : null; + const triggerTooltipContent = shortcutLabel + ? `${props.triggerLabel ?? triggerLabel} · ${shortcutLabel}` + : props.triggerLabel ?? triggerLabel; + return ( {props.triggerLabel ?? triggerTitle} - {props.triggerLabel ?? triggerLabel} + {triggerTooltipContent} {selectedModel?.isUnavailable && props.triggerLabel === undefined ? ( diff --git a/apps/web/src/components/ui/dialog-styles.ts b/apps/web/src/components/ui/dialog-styles.ts index 4dda3fe93924..f2eeae928e99 100644 --- a/apps/web/src/components/ui/dialog-styles.ts +++ b/apps/web/src/components/ui/dialog-styles.ts @@ -5,7 +5,7 @@ const DIALOG_BACKDROP_CLASS = `dialog-backdrop ${DIALOG_BACKDROP_BASE_CLASS}`; const DIALOG_MEDIA_BACKDROP_CLASS = `${DIALOG_BACKDROP_BASE_CLASS} bg-black/75 backdrop-blur-none`; const DIALOG_POPUP_BASE_CLASS = - "-translate-y-[calc(1.25rem*var(--nested-dialogs))] relative flex min-h-0 w-full min-w-0 scale-[calc(1-0.1*var(--nested-dialogs))] flex-col opacity-[calc(1-0.1*var(--nested-dialogs))] outline-none transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0"; + "-translate-y-[calc(1.25rem*var(--nested-dialogs))] relative flex min-h-0 w-full min-w-0 scale-[calc(1-0.1*var(--nested-dialogs))] flex-col opacity-[calc(1-0.1*var(--nested-dialogs))] outline-none transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 [-webkit-app-region:no-drag]"; const DIALOG_POPUP_CLASS = `dialog-glass ${DIALOG_POPUP_BASE_CLASS} rounded-2xl border`; const DIALOG_MEDIA_POPUP_CLASS = `${DIALOG_POPUP_BASE_CLASS} max-h-[92vh] w-auto max-w-[92vw] overflow-visible rounded-none border border-transparent bg-transparent p-0 shadow-none`; From e4212234c84253ce739fc057d374074aaf44a96c Mon Sep 17 00:00:00 2001 From: kridaydave Date: Sun, 13 Sep 2026 21:10:21 +0530 Subject: [PATCH 08/21] fix: format ProviderInstanceIcon and ProviderModelPicker for oxfmt Co-authored-by: opencode --- apps/web/src/components/chat/ProviderInstanceIcon.tsx | 7 ++++++- apps/web/src/components/chat/ProviderModelPicker.tsx | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index d1b19a0038e6..44c1ff122fd2 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -38,7 +38,12 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { {Icon ? ( ) : ( - + {providerInstanceInitials(props.displayName)} )} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 5b72d22133cf..5d44b51472b1 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -159,7 +159,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { : null; const triggerTooltipContent = shortcutLabel ? `${props.triggerLabel ?? triggerLabel} · ${shortcutLabel}` - : props.triggerLabel ?? triggerLabel; + : (props.triggerLabel ?? triggerLabel); return ( Date: Sun, 13 Sep 2026 21:12:40 +0530 Subject: [PATCH 09/21] fix: bump z-index to z-20 in ProviderInstanceIcon to prevent badge overlap Address macroscopeapp review thread: badge paints over icon because z-10 is insufficient in the isolated stacking context. Co-authored-by: opencode --- apps/web/src/components/chat/ProviderInstanceIcon.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 44c1ff122fd2..457cb6527f74 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -36,11 +36,11 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { data-provider-accent-color={props.accentColor} > {Icon ? ( - + ) : ( From ed4e614c541fa67c0dd8bf3ba066304b62feea93 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Sun, 13 Sep 2026 21:33:11 +0530 Subject: [PATCH 10/21] fix: raise ProviderInstanceIcon root z-index to z-20 CodeRabbit: z-20 nested in isolated context can't outrank sibling NEW_BADGE_CLASS (absolute z-10). Raise root stacking level so the entire component stacks above the badge. Co-authored-by: opencode --- apps/web/src/components/chat/ProviderInstanceIcon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 457cb6527f74..3e2f666f4e7a 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -29,7 +29,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { return ( Date: Sun, 13 Sep 2026 21:46:42 +0530 Subject: [PATCH 11/21] fix: raise ProviderInstanceIcon root stacking level above NEW_BADGE_CLASS z-10 in ModelPickerSidebar --- apps/web/src/components/chat/ModelPickerSidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index dd53270268ba..a0ea763ede61 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -185,7 +185,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { displayName={entry.displayName} accentColor={entry.accentColor} showBadge={showInstanceBadge} - className="size-6" + className="size-6 z-30" iconClassName="size-5" indicatorBackground={ isHovered && !isDisabled From ec427d59e5623ddb14e81b65b792770c3ac12a61 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Mon, 14 Sep 2026 12:07:04 +0530 Subject: [PATCH 12/21] fix(mobile): dedupe iOS backspace DEL between deleteBackward and delegate Most keyboards route Backspace through both deleteBackward() and the empty-replacement delegate callback for one keypress, deleting two chars. The delegate now skips its DEL when deleteBackward just fired, while third-party keyboards that bypass deleteBackward (#11449) still emit via the delegate. --- .../modules/t3-terminal/ios/T3TerminalView.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 } From 4d6b335d258040eb25afd27a229d399adf94a82f Mon Sep 17 00:00:00 2001 From: kridaydave Date: Mon, 14 Sep 2026 12:07:05 +0530 Subject: [PATCH 13/21] fix(web): let ProviderInstanceIcon adornments paint above the glyph The icon/fallback z-20 sat above the z-10 status dot and badge inside the isolated stacking context, clipping them. Drop the child z-indexes (root keeps isolate z-20 for external stacking) so the adornments render on top. --- apps/web/src/components/chat/ProviderInstanceIcon.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 3e2f666f4e7a..0b63fc8fe29e 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -36,14 +36,9 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { data-provider-accent-color={props.accentColor} > {Icon ? ( - + ) : ( - + {providerInstanceInitials(props.displayName)} )} From 8f54ce6e8b5ff6542c0f09831d98cc768cbacbc4 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Mon, 14 Sep 2026 12:22:42 +0530 Subject: [PATCH 14/21] fix(server): serialize concurrent pulls per working tree The startup auto-pull now runs in the background after commands are accepted, so a client-issued pull for the same workspace could overlap its git pull --ff-only and fail on the repository lock. Route all pullCurrentBranch calls through a per-working-tree permit instead of re-serializing startup. --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 28 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 17 +++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index bc8a12700997..0b7b4d673b9f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2083,6 +2083,34 @@ 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("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 86a3e2ebd812..061a7a205af2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3525,6 +3525,20 @@ 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 => { + const key = normalizeRepositoryPathsCacheKey(cwd); + let lock = pullLocksByCwd.get(key); + if (lock === undefined) { + lock = Semaphore.makeUnsafe(1); + pullLocksByCwd.set(key, lock); + } + return lock.withPermit(effect); + }; const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( input, ) => @@ -3550,7 +3564,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, From 99fdd1999a634d18f11a126cb06e1ba9b1ae178b Mon Sep 17 00:00:00 2001 From: kridaydave Date: Thu, 17 Sep 2026 21:54:02 +0530 Subject: [PATCH 15/21] trigger mergeability recheck From e69bce247338388021e6dd5da96b821dbc91f458 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Thu, 17 Sep 2026 21:54:32 +0530 Subject: [PATCH 16/21] force recheck From 4cc57a96774c6329c010b2b7c65c21e7984afce3 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Thu, 17 Sep 2026 22:16:23 +0530 Subject: [PATCH 17/21] fix(os-jank): add focused tests for fixPath shell env skip path fix(serverRuntimeStartup): add ordering test proving readiness precedes auto-pull Co-authored-by: Macroscope-bot --- apps/server/src/os-jank.test.ts | 33 +++++++++++++++++++- apps/server/src/serverRuntimeStartup.test.ts | 30 ++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index a157efb665fa..a05e39d6f559 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -1,7 +1,12 @@ import * as NodeOS from "node:os"; -import { assert, it } from "vite-plus/test"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +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 +43,29 @@ 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 when __T3CODE_SHELL_ENV_INSTALLED marker is absent", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { HOME: "/home/user" }; + yield* fixPath().pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, env), + Effect.provide(NodeServices.layer), + ); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 4a8aca46bdda..281a62e35ac0 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -135,6 +135,36 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => ), ); +it.effect("readiness is published before auto-pull starts", () => + Effect.scoped( + Effect.gen(function* () { + const commandGate = yield* ServerRuntimeStartup.makeCommandGate; + const pullStarted = yield* Ref.make(false); + const pullDone = yield* Deferred.make(); + + const syncAutoPull = Effect.gen(function* () { + yield* Ref.set(pullStarted, true); + yield* Deferred.succeed(pullDone, undefined).pipe(Effect.orDie); + }); + + const fiber = yield* commandGate.awaitCommandReady.pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + assert.equal(yield* Ref.get(pullStarted), false); + + yield* commandGate.signalCommandReady; + + const result = yield* Fiber.join(fiber); + assert.equal(result, undefined); + + yield* syncAutoPull.pipe(Effect.forkScoped); + yield* Deferred.await(pullDone); + + assert.equal(yield* Ref.get(pullStarted), true); + }), + ), +); + it.effect("resolveWelcomeBase derives cwd and project name from server config", () => Effect.gen(function* () { const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( From 261cf6371155cabf4be19c639cb0e91bce4e7620 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Thu, 17 Sep 2026 22:29:40 +0530 Subject: [PATCH 18/21] fix(desktop): only mark shell env installed when probe produced PATH fix(server): key pull semaphore by git common dir so root/subdir pulls serialize --- apps/desktop/src/app/DesktopApp.ts | 9 +++--- apps/server/src/os-jank.ts | 4 ++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 31 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 27 +++++++++++------ 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index c40ee4d81ac8..1a7760cb7472 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -273,13 +273,14 @@ const startup = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; + const pathBeforeShellProbe = process.env.PATH; 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) — otherwise the server should - // do its own probe with the user's real shell (zsh, fish, ...) instead of - // trusting the desktop's fallback probe. - if ((process.env.SHELL ?? "").trim().length > 0) { + // knew which shell to probe (SHELL is set) and the probe produced a PATH — + // a failed or timed-out probe leaves PATH unchanged, and then the server + // should do its own probe instead of trusting an empty patch. + if ((process.env.SHELL ?? "").trim().length > 0 && process.env.PATH !== pathBeforeShellProbe) { process.env.__T3CODE_SHELL_ENV_INSTALLED = "1"; } const hasCommandLinePasswordStore = diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 8218a9ba63ff..23207ce51b37 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -79,7 +79,9 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< // 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. + // 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) { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 191a44e3ab66..1331b28395af 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2665,6 +2665,37 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + 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 106b2e5fa3be..dba7690bb836 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3541,15 +3541,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // 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 => { - const key = normalizeRepositoryPathsCacheKey(cwd); - let lock = pullLocksByCwd.get(key); - if (lock === undefined) { - lock = Semaphore.makeUnsafe(1); - pullLocksByCwd.set(key, lock); - } - return lock.withPermit(effect); - }; + 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, ) => From 56df25f226128de330c7beac245e3f1fe31442dc Mon Sep 17 00:00:00 2001 From: Kriday Dave Date: Thu, 17 Sep 2026 22:35:18 +0530 Subject: [PATCH 19/21] Update apps/desktop/src/app/DesktopApp.ts Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> --- apps/desktop/src/app/DesktopApp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 1a7760cb7472..8e2a80188c83 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -280,7 +280,7 @@ const startup = Effect.gen(function* () { // knew which shell to probe (SHELL is set) and the probe produced a PATH — // a failed or timed-out probe leaves PATH unchanged, and then the server // should do its own probe instead of trusting an empty patch. - if ((process.env.SHELL ?? "").trim().length > 0 && process.env.PATH !== pathBeforeShellProbe) { + if ((process.env.SHELL ?? "").trim().length > 0) { process.env.__T3CODE_SHELL_ENV_INSTALLED = "1"; } const hasCommandLinePasswordStore = From 150b8bb9c985723d5ad6b402efb70e416c8135fc Mon Sep 17 00:00:00 2001 From: kridaydave Date: Thu, 17 Sep 2026 22:47:02 +0530 Subject: [PATCH 20/21] fix(desktop): report whether shell probe produced PATH, gate marker on it installIntoProcess now returns whether a probe actually produced a PATH, so DesktopApp sets __T3CODE_SHELL_ENV_INSTALLED only on real success. This keeps the skip for successful unchanged PATHs while letting failed or timed-out probes fall through to server-side hydration. --- apps/desktop/src/app/DesktopApp.ts | 9 ++-- .../src/shell/DesktopShellEnvironment.test.ts | 47 ++++++++++++++++++- .../src/shell/DesktopShellEnvironment.ts | 20 ++++++-- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 8e2a80188c83..65dc9cc419c5 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -273,14 +273,13 @@ const startup = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const pathBeforeShellProbe = process.env.PATH; - 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 leaves PATH unchanged, and then the server - // should do its own probe instead of trusting an empty patch. - if ((process.env.SHELL ?? "").trim().length > 0) { + // 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 = 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. */ From 55be05e8bb44fe97d8a6c75e143088a6eca8b920 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 18 Sep 2026 12:11:39 +0530 Subject: [PATCH 21/21] fix(server): strengthen startup ordering and fixPath fallback tests Test the real make startup flow with a gated auto-pull instead of a synthetic command gate, and drive the absent-marker fixPath branch with a controlled fake login shell asserting the merged PATH. --- apps/server/src/os-jank.test.ts | 36 ++++- apps/server/src/serverRuntimeStartup.test.ts | 149 ++++++++++++++++--- 2 files changed, 157 insertions(+), 28 deletions(-) diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index a05e39d6f559..16f1bfc5414c 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -1,7 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { hydratePosixHome } from "./os-jank.ts"; @@ -59,13 +61,31 @@ it.effect("fixPath skips hydratePosixPath when __T3CODE_SHELL_ENV_INSTALLED is 1 }), ); -it.effect("fixPath hydrates PATH when __T3CODE_SHELL_ENV_INSTALLED marker is absent", () => +it.effect("fixPath hydrates PATH from the login shell when the marker is absent", () => Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { HOME: "/home/user" }; - yield* fixPath().pipe( - Effect.provideService(HostProcessPlatform, "linux"), - Effect.provideService(HostProcessEnvironment, env), - Effect.provide(NodeServices.layer), - ); + 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/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 3a04cfac2ae6..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,32 +148,128 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => ), ); -it.effect("readiness is published before auto-pull starts", () => +it.effect("publishes readiness without waiting for background auto-pull", () => Effect.scoped( Effect.gen(function* () { - const commandGate = yield* ServerRuntimeStartup.makeCommandGate; + const pullGate = yield* Deferred.make(); const pullStarted = yield* Ref.make(false); const pullDone = yield* Deferred.make(); - - const syncAutoPull = Effect.gen(function* () { - yield* Ref.set(pullStarted, true); - yield* Deferred.succeed(pullDone, undefined).pipe(Effect.orDie); + 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"]); }); - const fiber = yield* commandGate.awaitCommandReady.pipe(Effect.forkScoped); - - yield* Effect.yieldNow; - assert.equal(yield* Ref.get(pullStarted), false); - - yield* commandGate.signalCommandReady; - - const result = yield* Fiber.join(fiber); - assert.equal(result, undefined); - - yield* syncAutoPull.pipe(Effect.forkScoped); - yield* Deferred.await(pullDone); - - assert.equal(yield* Ref.get(pullStarted), true); + 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, + ), + ), + ); }), ), );