From 0a39da26585544b9ed8feb9356dd42629b4d771a Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Tue, 21 Apr 2026 10:52:24 -0400 Subject: [PATCH 1/2] fix(server): require opencode >= 1.14.19 to prevent lockfile corruption Older opencode versions (< 1.14.19) have a bug in their Npm.install health check: the `declared` set is built by including raw `name@version` strings from the caller's `add` input alongside bare keys from package.json, while the `locked` set only ever contains bare names. The sets never match, so `arborist.reify()` runs on every probe. When ~/.config/opencode is a symlink into a dotfiles directory, arborist records paths relative to the resolved target and prepends another `../` segment on each pass, corrupting the lockfile. In the field this produced 31 MB lockfiles that blocked cold start for 45-75 seconds per probe. T3 Code currently spawns a local opencode server during every provider health check (via `connectToOpenCodeServer` -> `loadOpenCodeInventory`), which triggers that forkDetach'd Npm.install and further pollutes the lockfile. This change short-circuits the probe before the server spawn when the detected version is below 1.14.19, returning a provider snapshot with `status: "error"` and a message directing the user to upgrade. - Add `MINIMUM_OPENCODE_VERSION = "1.14.19"` gate in OpenCodeProvider. - Treat unparseable `--version` output as failing the gate. - Leave the external-server code path unchanged (no local spawn, no bug). - Bump the test double's default version string to a gate-passing value and add two tests covering the version-gate and unparseable-version paths. Fixes #2248 --- .../provider/Layers/OpenCodeProvider.test.ts | 33 ++++++++++++++++++- .../src/provider/Layers/OpenCodeProvider.ts | 31 +++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index ffce7084342..4a43c37b686 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -16,9 +16,12 @@ import { import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; import type { OpenCodeInventory } from "../opencodeRuntime.ts"; +const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; + const runtimeMock = { state: { runVersionError: null as Error | null, + versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, inventory: { providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, @@ -27,6 +30,7 @@ const runtimeMock = { }, reset() { this.state.runVersionError = null; + this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; this.state.inventory = { providerList: { connected: [], all: [] as unknown[], default: {} }, @@ -56,7 +60,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: runtimeMock.state.runVersionError, }), ) - : Effect.succeed({ stdout: "opencode 1.0.0\n", stderr: "", code: 0 }), + : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), createOpenCodeSdkClient: () => ({}) as unknown as ReturnType, loadOpenCodeInventory: () => @@ -108,6 +112,33 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { }), ); + it.effect("refuses to probe when opencode is older than the required minimum", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode 1.4.7\n"; + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "1.4.7"); + assert.ok(snapshot.message?.includes("1.14.19")); + assert.ok(snapshot.message?.toLowerCase().includes("upgrade")); + }), + ); + + it.effect("refuses to probe when opencode --version output is unparseable", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "garbled binary output\n"; + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, null); + assert.ok(snapshot.message?.includes("1.14.19")); + }), + ); + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index d43372ec5cd..586bc3d6f48 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -15,6 +15,7 @@ import { parseGenericCliVersion, providerModelsFromSettings, } from "../providerSnapshot.ts"; +import { compareCliVersions } from "../cliVersion.ts"; import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; import { OpenCodeRuntime, @@ -24,6 +25,7 @@ import { import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; const PROVIDER = "opencode" as const; +const MINIMUM_OPENCODE_VERSION = "1.14.19"; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ readonly cause: unknown; @@ -354,6 +356,35 @@ export const OpenCodeProviderLive = Layer.effect( return fallback(Cause.squash(versionExit.cause)); } version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + + if (!version) { + return fallback( + new Error( + `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + ), + null, + ); + } + if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { + return buildServerProvider({ + provider: PROVIDER, + enabled: input.settings.enabled, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + }, + }); + } } const inventoryExit = yield* Effect.exit( From 0cff00bf2cb9c9be9a66e5dfa16c13ff2440afb5 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 19 Apr 2026 22:04:30 -0400 Subject: [PATCH 2/2] fix(desktop): reveal window on did-finish-load fallback to avoid Wayland deadlock On Linux/Wayland, Electron's `ready-to-show` event only fires after `show()` is called when the BrowserWindow is created with `show: false`, because the wl_surface has no role assigned until then and the compositor never reports the surface as ready. The standard "wait for ready, then show" pattern deadlocks: nothing ever calls `show()`, so the window never appears. Add `did-finish-load` as a Linux-only fallback trigger so the first event from either source reveals the window. Other platforms keep the no-flash `ready-to-show` path, since `did-finish-load` typically fires before the first paint there. Extract the bind-once logic into `bindFirstRevealTrigger` with focused unit tests. --- apps/desktop/src/main.ts | 22 ++++---- apps/desktop/src/windowReveal.test.ts | 74 +++++++++++++++++++++++++++ apps/desktop/src/windowReveal.ts | 28 ++++++++++ 3 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/windowReveal.test.ts create mode 100644 apps/desktop/src/windowReveal.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 529ed55d03f..c5507c6fb03 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -75,6 +75,7 @@ import { } from "./updateMachine.ts"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; import { resolveDesktopAppBranding } from "./appBranding.ts"; +import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; syncShellEnvironment(); @@ -1998,16 +1999,17 @@ function createWindow(): BrowserWindow { emitUpdateState(); }); - let initialRevealScheduled = false; - const revealInitialWindow = () => { - if (initialRevealScheduled) { - return; - } - initialRevealScheduled = true; - revealWindow(window); - }; - - window.once("ready-to-show", revealInitialWindow); + // On Linux/Wayland with `show: false`, Electron's `ready-to-show` only + // fires after `show()` is called, deadlocking the standard "wait for + // ready, then show" pattern. Add `did-finish-load` as a Linux-only + // fallback so the window still surfaces once the renderer has loaded + // the page. Other platforms keep the no-flash `ready-to-show` path, + // since `did-finish-load` typically fires before the first paint there. + const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; + if (process.platform === "linux") { + revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); + } + bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window)); if (isDevelopment) { void window.loadURL(resolveDesktopDevServerUrl()); diff --git a/apps/desktop/src/windowReveal.test.ts b/apps/desktop/src/windowReveal.test.ts new file mode 100644 index 00000000000..88285fec0cb --- /dev/null +++ b/apps/desktop/src/windowReveal.test.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from "node:events"; + +import { describe, expect, it, vi } from "vitest"; + +import { bindFirstRevealTrigger } from "./windowReveal.ts"; + +describe("bindFirstRevealTrigger", () => { + it("reveals when the first trigger fires", () => { + const window = new EventEmitter(); + const webContents = new EventEmitter(); + const reveal = vi.fn(); + + bindFirstRevealTrigger( + [ + (fire) => window.once("ready-to-show", fire), + (fire) => webContents.once("did-finish-load", fire), + ], + reveal, + ); + + window.emit("ready-to-show"); + + expect(reveal).toHaveBeenCalledTimes(1); + }); + + it("reveals when only the fallback trigger fires (Wayland deadlock case)", () => { + const window = new EventEmitter(); + const webContents = new EventEmitter(); + const reveal = vi.fn(); + + bindFirstRevealTrigger( + [ + (fire) => window.once("ready-to-show", fire), + (fire) => webContents.once("did-finish-load", fire), + ], + reveal, + ); + + webContents.emit("did-finish-load"); + + expect(reveal).toHaveBeenCalledTimes(1); + }); + + it("only reveals once when multiple triggers fire", () => { + const window = new EventEmitter(); + const webContents = new EventEmitter(); + const reveal = vi.fn(); + + bindFirstRevealTrigger( + [ + (fire) => window.once("ready-to-show", fire), + (fire) => webContents.once("did-finish-load", fire), + ], + reveal, + ); + + webContents.emit("did-finish-load"); + window.emit("ready-to-show"); + + expect(reveal).toHaveBeenCalledTimes(1); + }); + + it("subscribers using `once` ignore re-emitted events after reveal", () => { + const window = new EventEmitter(); + const reveal = vi.fn(); + + bindFirstRevealTrigger([(fire) => window.once("ready-to-show", fire)], reveal); + + window.emit("ready-to-show"); + window.emit("ready-to-show"); + + expect(reveal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/windowReveal.ts b/apps/desktop/src/windowReveal.ts new file mode 100644 index 00000000000..8faf65aeb15 --- /dev/null +++ b/apps/desktop/src/windowReveal.ts @@ -0,0 +1,28 @@ +export type RevealSubscription = (listener: () => void) => void; + +/** + * Wire a reveal callback to fire exactly once, on whichever of the provided + * event subscribers fires first. Each subscriber is responsible for binding + * its own event source. + * + * Used by the desktop main window's first-paint reveal logic. The standard + * Electron pattern is to wait for `ready-to-show` before calling `show()`, + * but on Linux/Wayland with `show: false`, `ready-to-show` only fires after + * `show()` is called, deadlocking that pattern. Subscribing to both + * `ready-to-show` and `did-finish-load` (or any other "renderer is alive" + * signal) lets the window surface reliably across platforms. + */ +export function bindFirstRevealTrigger( + subscribers: readonly RevealSubscription[], + reveal: () => void, +): void { + let revealed = false; + const fire = () => { + if (revealed) return; + revealed = true; + reveal(); + }; + for (const subscribe of subscribers) { + subscribe(fire); + } +}