diff --git a/CHANGELOG.md b/CHANGELOG.md index c7004091ab..8bdbab7e62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,7 +174,20 @@ already in use` and no way forward short of finding the file in a temporary ### Desktop / Web UI -- **Code's tab strip shows one project at a time** — the per-folder tab lists +- **No more "Could not reach Local Server" on corporate proxies** — on + networks with a proxy auto-configuration script, the app often opened onto + "Could not reach Local Server / Retrying automatically…" and then connected + by itself a few seconds later. The server was never down: the app's browser + engine holds every request — even ones to the local server on the same + machine — until it has finished downloading and evaluating the network's + proxy configuration, and on corporate networks that can take longer than the + ten seconds the app waited before declaring the server unreachable. The app + now starts that proxy lookup early, while the local server is still booting, + and if the ten seconds still run out it double-checks through a channel that + doesn't wait on the proxy — when that says the server is fine, it keeps the + loading screen up until the connection comes through instead of flashing an + error. Remote servers you add yourself still report unreachable after ten + seconds as before. — the per-folder tab lists that **Work** already had now apply to **Code** too: the strip across the top shows the tabs of the project you are in, keyed by server and folder, instead of every Code tab from every server in one long row. Clicking a project in diff --git a/DIVERGENCES.md b/DIVERGENCES.md index da295e8bf5..84c6e01d5c 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -203,6 +203,8 @@ from the squash message first, or use a plain merge. | Desktop gateway SSO wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Main instantiates the fork-only `src/main/gateway-sso/` controller (wrapping `@codev/gateway` `login`/`silentSso` — node-only loopback OAuth — with `shell.openExternal` and a `net.fetch` wrapper) and registers its `gateway-sso-*` IPC channels next to the sandbox ones, passing an `onConfigured` callback that restarts the running sandboxes (`SandboxController.restartRunning`) because a guest is handed its codev config once at boot and cannot see credentials that appear afterwards; preload exposes the `gatewaySso` namespace (subscribe fan-out, sandbox pattern) with types re-exported from `@opencode-ai/app/gateway/types`; the renderer platform maps `gatewaySso: window.api.gatewaySso`. | SSO login for the desktop configure gate must run in the main process (`node:http` loopback server, `~/.codev/auth.json` writes); `net.fetch` so enterprise proxies/PAC apply (egress precedent). | Keep the wiring; take upstream's surrounding changes. Keep channel names `gateway-sso-*` stable. | | `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern). | Keep the export; take upstream's other manifest changes. | | `packages/app/src/i18n/{en,es}.ts` | Added the `gateway.configure.*` key family (configure-gate dialog) and reworded `sidebar.gettingStarted.line1` — the "includes free models" pitch no longer holds with the hosted providers hidden. | Strings for the fork's configure gate; the getting-started card must not advertise models the fork does not offer. | Keep our keys and copy; take upstream's surrounding i18n changes. | +| `packages/desktop/src/main/index.ts` | Deleted upstream's `app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")`. | Chromium's `<-loopback>` bypass rule means the opposite of what it reads as: it *subtracts* the implicit rules that keep loopback off the proxy, i.e. it opts localhost traffic *into* proxying (`net/docs/proxy.md`). Today it is inert — Electron reads `--proxy-bypass-list` only together with `--proxy-server` (`shell/browser/browser_process_impl.cc`), which the app never passes — but it becomes actively harmful the moment anything launches the app with `--proxy-server`: the renderer's sidecar traffic would be routed into the proxy. Chromium already bypasses loopback by default, so nothing replaces it. | Keep deleted. If upstream adds new proxy switches, check them against Chromium's implicit-bypass semantics before taking them. | +| Startup proxy-stall gate: `packages/desktop/src/main/{index,ipc}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx`, `packages/app/src/app.tsx`, `packages/app/src/context/platform.tsx` | Three additive pieces. (1) `main/index.ts` fires `session.defaultSession.resolveProxy(url)` for the sidecar URL as soon as the port is known (fire-and-forget, result logged). (2) A `local-server-health` IPC chain: main answers with the Node-fetch `checkHealth` against the spawned sidecar once `serverReady` resolves (`false` if initialization failed), preload exposes `localServerHealth()`, the renderer platform maps it, and `platform.tsx` gains the optional `localServerHealth?()` capability. (3) `app.tsx` `ConnectionGate`: the health-check effect is restructured around an inner `poll`; when the 10 s blocking window expires without a healthy answer and the connection is the built-in sidecar, it asks `platform.localServerHealth` — if the host process vouches for the server it keeps blocking on the splash for up to another 50 s instead of showing the unreachable error. Background mode and `type: "http"` behavior are unchanged, and without the capability (web) the gate behaves exactly as upstream. | Chromium queues every renderer request — loopback included — until proxy initialization (PAC download / WPAD discovery) resolves; the implicit loopback-bypass rules are only applied once the resolution service is READY (`net/proxy_resolution/configured_proxy_resolution_service.cc`). On corporate networks that takes tens of seconds (measured 21 s vs 64 ms with an unreachable PAC host), so users behind a proxy saw "Could not reach Local Server" at every launch while the server was healthy the whole time — main's own Node-fetch health check (loopback in `NO_PROXY`) had already passed before the window opened. The prewarm overlaps proxy init with sidecar startup; the gate fallback distinguishes "server down" from "renderer network stack not ready". | Keep all three pieces; take upstream's surrounding changes. If upstream restructures `ConnectionGate`, re-apply the vouch-then-extend fallback around whatever its blocking window becomes. Keep the `local-server-health` channel name stable. | | `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`; `autoUpdater.quitAndInstall()` → `quitAndInstall(true, true)`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. The `quitAndInstall` arguments are load-bearing against the assisted installer (see the `electron-builder.config.ts` row): the defaults are `isSilent = false, isForceRunAfter = false`, and `NsisUpdater.doInstall` appends `/S` only when `isSilent` — so with an assisted installer the default would run the full wizard on **every** update, re-asking install mode and directory for a location the user already chose. `isForceRunAfter` is honoured only when `isSilent` is set (`BaseUpdater.quitAndInstall` passes `autoRunAppAfterInstall` instead when it is not), so both arguments are needed to install quietly *and* come back up. | Keep `false`; take upstream's other updater settings. Keep `quitAndInstall(true, true)` for as long as `nsis.oneClick` is `false`; the two only make sense together. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). | | Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; the `install-wsl` job additionally treats exit 3010 (`WSL_INSTALL_REBOOT_REQUIRED`, re-raised by the fork's elevated script) as success with `pendingRestart: true` rather than as a failure — `wsl --version` answers from the freshly installed MSI before the just-enabled VirtualMachinePlatform is active, so no post-install probe can detect the pending reboot; otherwise both return the same `WslCommandResult` shape and the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads from the codev storage host — the runtime as a per-architecture MSI named by its own manifest (`/codev/installers/wsl/manifest.json`, assets under `/codev/installers/wsl//`) and staged through `stageAsset` for resume + sha256 verification, the distro from the pinned `wsl.zip` — enables VirtualMachinePlatform and Microsoft-Windows-Subsystem-Linux via offline dism + installs the MSI behind one UAC prompt (Windows 10 requires the WSL optional component even for the standalone MSI — without it every `wsl` distro command fails with Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED; on machines that already have it dism is a no-op), and falls back to upstream's `wsl --install` / `--web-download` paths only when the download cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps, the 3010 handling in `install-wsl`, and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. | | Async settings stores: `packages/desktop/src/main/{store,index}.ts`, `packages/desktop/package.json` | `store.ts` no longer uses electron-store: `getStore` now returns the fork-only `store-file.ts` store — in-memory state, flushed write-behind through `store-writer.ts`'s serialized latest-wins async atomic writer (temp → fsync → rename via `fs.promises`, so the fsync runs on the libuv threadpool). Adds `flushStoreWrites(timeoutMs)`; `removeStoreFile`/`removeStoreFileIfEmpty` discard/flush the writer around the delete so a late flush cannot resurrect the file. `index.ts`: `stopSidecars` awaits `flushStoreWrites(1500)`. `package.json`: the `electron-store` dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. | conf v15's `store.set` is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. | Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend `store-file.ts` to match — its API is the superset the desktop actually uses (`get/set/has/delete/clear/store/path`). Do not reintroduce a synchronous write path on the main thread. | diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 2e5347d21c..5890702f83 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -438,6 +438,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise }>) { const server = useServer() + const platform = usePlatform() const checkServerHealth = useCheckServerHealth() const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking") @@ -448,19 +449,31 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; start props.disableHealthCheck ? true : Effect.gen(function* () { - if (!server.current) return true - const { http, type } = server.current - - while (true) { - const res = yield* Effect.promise(() => checkServerHealth(http)) - if (res.healthy) return true - if (checkMode() === "background" || type === "http") return false - } - }).pipe( - Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), - Effect.ensuring(Effect.sync(() => setCheckMode("background"))), - Effect.runPromise, - ), + const current = server.current + if (!current) return true + + const poll = Effect.gen(function* () { + while (true) { + const res = yield* Effect.promise(() => checkServerHealth(current.http)) + if (res.healthy) return true + if (checkMode() === "background" || current.type === "http") return false + } + }) + const first = yield* poll.pipe( + Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(null) }), + ) + if (first !== null) return first + // Renderer fetches ride Chromium's network stack, which queues even + // loopback requests until proxy initialization (PAC/WPAD) resolves — + // tens of seconds on some corporate networks. If the host process can + // still reach the built-in sidecar the server is fine: keep blocking + // on the splash instead of reporting it unreachable. + const localServerHealth = platform.localServerHealth + if (!localServerHealth || !ServerConnection.builtin(current)) return false + const vouched = yield* Effect.promise(() => localServerHealth().catch(() => false)) + if (!vouched) return false + return yield* poll.pipe(Effect.timeoutOrElse({ duration: "50 seconds", orElse: () => Effect.succeed(false) })) + }).pipe(Effect.ensuring(Effect.sync(() => setCheckMode("background"))), Effect.runPromise), ) const checking = createMemo( () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state), diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 22d5f780e6..8e6157e042 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -78,6 +78,13 @@ type PlatformBase = { /** Fetch override */ fetch?: typeof fetch + /** + * Host-process reachability of the built-in local sidecar, bypassing the + * renderer network stack (Electron only). Lets the startup gate tell a dead + * server from a renderer fetch stalled behind proxy initialization. + */ + localServerHealth?(): Promise + /** Get the configured default server URL (platform-specific) */ getDefaultServer?(): Promise diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 49e7fc72b0..6f4d1eb1ab 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -6,7 +6,7 @@ import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" -import { app, net, shell } from "electron" +import { app, net, session, shell } from "electron" import { Deferred, Effect, Fiber } from "effect" import contextMenu from "electron-context-menu" @@ -26,6 +26,7 @@ import { isOldLayoutEligible, } from "./onboarding" import { + checkHealth, getDefaultServerUrl, preferAppEnv, setDefaultServerUrl, @@ -202,7 +203,6 @@ const main = Effect.gen(function* () { ensureLoopbackNoProxy() useEnvProxy() - app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") const features = app.commandLine.getSwitchValue("enable-features") app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature) if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") @@ -297,6 +297,15 @@ const main = Effect.gen(function* () { (e) => Effect.runPromise(e), ), consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), + // The renderer's own health checks ride Chromium's network stack, which + // can stall behind proxy initialization even for loopback; this answers + // from the main process's Node fetch so the gate can tell "server down" + // from "renderer network not ready". + localServerHealth: () => + Effect.runPromise(Deferred.await(serverReady)).then( + (ready) => checkHealth(ready.url, ready.password), + () => false, + ), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), isFirstLaunchOnboardingPending, @@ -381,6 +390,15 @@ const main = Effect.gen(function* () { const url = `http://${hostname}:${port}` const password = randomUUID() + // Chromium queues every renderer request — loopback included — behind its + // proxy initialization (PAC download / WPAD discovery), which can take tens + // of seconds on corporate networks. Resolve once now so that wait overlaps + // sidecar startup instead of stalling the renderer's first health check. + void session.defaultSession + .resolveProxy(url) + .then((proxy) => logger.log("proxy resolution warmed", { proxy })) + .catch((error) => logger.warn("proxy resolution warmup failed", error)) + const loadingTask = yield* Effect.gen(function* () { logger.log("sidecar connection started", { url }) diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 57e3a04cb2..1c6bf1572b 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -25,6 +25,7 @@ type Deps = { killSidecar: () => Promise | void relaunch: () => void awaitInitialization: () => Promise + localServerHealth: () => Promise consumeInitialDeepLinks: () => Promise | string[] getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void @@ -49,6 +50,7 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("kill-sidecar", () => deps.killSidecar()) ipcMain.handle("await-initialization", () => deps.awaitInitialization()) + ipcMain.handle("local-server-health", () => deps.localServerHealth()) ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 7493041747..a451cbc63c 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -14,6 +14,7 @@ const api: ElectronAPI = { killSidecar: () => ipcRenderer.invoke("kill-sidecar"), installCli: () => ipcRenderer.invoke("install-cli"), awaitInitialization: () => ipcRenderer.invoke("await-initialization"), + localServerHealth: () => ipcRenderer.invoke("local-server-health"), wslServers: { getState: () => ipcRenderer.invoke("wsl-servers-get-state"), subscribe: (cb) => { diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 0658b13102..69a8f42b59 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -65,6 +65,9 @@ export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise awaitInitialization: () => Promise + // Main-process (Node fetch) reachability of the built-in sidecar; immune to + // renderer-side proxy stalls. + localServerHealth: () => Promise wslServers: WslServersAPI sandboxes: SandboxesAPI gatewaySso: GatewaySsoAPI diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index baa502b792..0d2587d6a8 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -291,6 +291,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { return fetch(input, init) }, + localServerHealth: () => window.api.localServerHealth(), + getDefaultServer: async () => { const url = await window.api.getDefaultServerUrl().catch(() => null) if (!url) return null