From 7e53fc0ee31c35829ab08a8a53608a0c73ca4b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 25 Aug 2026 15:50:44 +0200 Subject: [PATCH] fix(desktop): persist OpenCode selection before shutdown Normalize the legacy opencode system command to opencode2 in both server persistence and UI settings so the selected PATH runtime remains visible and authoritative after restart. Track config, state, and instance-data writes in the shared UI storage adapter and wait for them during native shutdown before acknowledging Tauri's flush request. Remove duplicate binary-selection writes from the selector so its parent remains the single persistence owner. Validated with UI and server typechecks, targeted storage, preference, shutdown-capture, settings, and binary resolver tests, plus a Tauri release build. --- packages/server/src/settings/service.test.ts | 4 +- packages/server/src/settings/service.ts | 3 ++ .../components/opencode-binary-selector.tsx | 4 -- .../lib/hooks/use-app-session-capture.test.ts | 4 ++ .../src/lib/hooks/use-app-session-capture.ts | 6 ++- packages/ui/src/lib/storage.test.ts | 29 ++++++++++++ packages/ui/src/lib/storage.ts | 45 +++++++++++++------ packages/ui/src/stores/preferences.test.ts | 9 +++- packages/ui/src/stores/preferences.tsx | 6 ++- 9 files changed, 86 insertions(+), 24 deletions(-) create mode 100644 packages/ui/src/lib/storage.test.ts diff --git a/packages/server/src/settings/service.test.ts b/packages/server/src/settings/service.test.ts index 505a5cca..d169c090 100644 --- a/packages/server/src/settings/service.test.ts +++ b/packages/server/src/settings/service.test.ts @@ -28,7 +28,7 @@ describe("SettingsService config persistence", () => { it("normalizes and persists a server-owner patch once", () => { let writes = 0 const service = serviceWithStore({ - getOwner: () => ({ logLevel: "info", sidecars: [] }), + getOwner: () => ({ logLevel: "info", opencodeBinary: "opencode", sidecars: [] }), replaceOwner: (_owner: string, value: unknown) => { writes += 1 return value @@ -38,7 +38,7 @@ describe("SettingsService config persistence", () => { const result = service.mergePatchOwner("config", "server", { sidecars: [{ id: "one" }] }) assert.equal(writes, 1) - assert.deepEqual(result, { logLevel: "INFO", sidecars: [{ id: "one" }] }) + assert.deepEqual(result, { logLevel: "INFO", opencodeBinary: "opencode2", sidecars: [{ id: "one" }] }) }) it("does not report a persisted patch as failed when an event listener throws", () => { diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts index 7bb10e72..4e52dd44 100644 --- a/packages/server/src/settings/service.ts +++ b/packages/server/src/settings/service.ts @@ -40,6 +40,9 @@ function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc { } else if (next.logLevel !== undefined) { next.logLevel = "DEBUG" } + if (next.opencodeBinary === "opencode") { + next.opencodeBinary = "opencode2" + } return next } diff --git a/packages/ui/src/components/opencode-binary-selector.tsx b/packages/ui/src/components/opencode-binary-selector.tsx index e5455016..92e3581d 100644 --- a/packages/ui/src/components/opencode-binary-selector.tsx +++ b/packages/ui/src/components/opencode-binary-selector.tsx @@ -30,7 +30,6 @@ const OpenCodeBinarySelector: Component = (props) = addOpenCodeBinary, removeOpenCodeBinary, serverSettings, - updateLastUsedBinary, } = useConfig() const [customPath, setCustomPath] = createSignal("") const [validating, setValidating] = createSignal(false) @@ -157,7 +156,6 @@ const OpenCodeBinarySelector: Component = (props) = if (validation.valid) { addOpenCodeBinary(path, validation.version) props.onBinaryChange(path) - updateLastUsedBinary(path) setCustomPath("") setValidationError(null) } else { @@ -182,7 +180,6 @@ const OpenCodeBinarySelector: Component = (props) = if (props.disabled) return if (path === props.selectedBinary) return props.onBinaryChange(path) - updateLastUsedBinary(path) } function handleRemoveBinary(path: string, event: Event) { @@ -192,7 +189,6 @@ const OpenCodeBinarySelector: Component = (props) = if (props.selectedBinary === path) { props.onBinaryChange("opencode2") - updateLastUsedBinary("opencode2") } } diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts index eaafcb59..7d4e9488 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts @@ -24,3 +24,7 @@ it("resumes capture only after native shutdown cancellation", () => { assert.match(capture, /listen<\{ generation: number \}>\("client-state:flush-cancelled"/) assert.match(capture, /if \(nativeShutdownGeneration !== payload\.generation\) return[\s\S]*nativeShutdownGeneration = null[\s\S]*schedule\(\)/) }) + +it("waits for server storage writes during native shutdown", () => { + assert.match(capture, /nativeShutdown \? \[storage\.flushWrites\(\)\] : \[\]/) +}) diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.ts b/packages/ui/src/lib/hooks/use-app-session-capture.ts index 7c7e5bd4..5c24d16d 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.ts @@ -7,6 +7,7 @@ import { clientStateIsPrimary, flushClientState, restorePreviousStateEnabled, updateRestorableSession, type RestorableSessionState, type RestorableTabState, type RestorableWorkspaceTabState, } from "../../stores/client-state" +import { storage } from "../storage" import { normalizeWorkspacePath } from "../../stores/app-session-reconciliation" import { createRestorableSessionPreservation, createRestoredTabCommitGuard, markPreservedWorkspaceRemoved, @@ -178,7 +179,10 @@ export function useAppSessionCapture() { : current updateRestorableSession(state) } - await flushClientState() + await Promise.all([ + flushClientState(), + ...(nativeShutdown ? [storage.flushWrites()] : []), + ]) } const nativeUnlisteners: Array<() => void> = [] let nativeDisposed = false diff --git a/packages/ui/src/lib/storage.test.ts b/packages/ui/src/lib/storage.test.ts new file mode 100644 index 00000000..6ec387ee --- /dev/null +++ b/packages/ui/src/lib/storage.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" +import { afterEach, it } from "node:test" +import { serverApi } from "./api-client" +import { ServerStorage } from "./storage" + +const originalPatchConfigOwner = serverApi.patchConfigOwner + +afterEach(() => { + serverApi.patchConfigOwner = originalPatchConfigOwner +}) + +it("flushes an in-flight server write", async () => { + let resolveWrite!: (value: Record) => void + serverApi.patchConfigOwner = >() => new Promise((resolve) => { + resolveWrite = (value) => resolve(value as T) + }) + const storage = new ServerStorage() + const write = storage.patchConfigOwner("server", { opencodeBinary: "opencode2" }) + let flushed = false + const flush = storage.flushWrites().then(() => { + flushed = true + }) + + await Promise.resolve() + assert.equal(flushed, false) + resolveWrite({ opencodeBinary: "opencode2" }) + await flush + assert.deepEqual(await write, { opencodeBinary: "opencode2" }) +}) diff --git a/packages/ui/src/lib/storage.ts b/packages/ui/src/lib/storage.ts index 4616bc94..ec4de3b8 100644 --- a/packages/ui/src/lib/storage.ts +++ b/packages/ui/src/lib/storage.ts @@ -39,6 +39,7 @@ export class ServerStorage { private instanceDataCache = new Map() private instanceDataListeners = new Map void>>() private instanceLoadPromises = new Map>() + private pendingWrites = new Set>() constructor() { serverEvents.on("storage.configChanged", (event: WorkspaceEventPayload) => { @@ -77,10 +78,11 @@ export class ServerStorage { return this.configOwnerLoadPromises.get(owner)! } - async patchConfigOwner(owner: string, patch: unknown): Promise { - const updated = await serverApi.patchConfigOwner(owner, patch) - this.setOwnerCache("config", owner, updated) - return updated + patchConfigOwner(owner: string, patch: unknown): Promise { + return this.trackWrite(serverApi.patchConfigOwner(owner, patch).then((updated) => { + this.setOwnerCache("config", owner, updated) + return updated + })) } async loadStateOwner(owner: string): Promise { @@ -103,10 +105,11 @@ export class ServerStorage { return this.stateOwnerLoadPromises.get(owner)! } - async patchStateOwner(owner: string, patch: unknown): Promise { - const updated = await serverApi.patchStateOwner(owner, patch) - this.setOwnerCache("state", owner, updated) - return updated + patchStateOwner(owner: string, patch: unknown): Promise { + return this.trackWrite(serverApi.patchStateOwner(owner, patch).then((updated) => { + this.setOwnerCache("state", owner, updated) + return updated + })) } async loadInstanceData(instanceId: string): Promise { @@ -133,15 +136,23 @@ export class ServerStorage { return this.instanceLoadPromises.get(instanceId)! } - async saveInstanceData(instanceId: string, data: InstanceData): Promise { + saveInstanceData(instanceId: string, data: InstanceData): Promise { const normalized = this.normalizeInstanceData(data) - await serverApi.writeInstanceData(instanceId, normalized) - this.setInstanceDataCache(instanceId, normalized) + return this.trackWrite(serverApi.writeInstanceData(instanceId, normalized).then(() => { + this.setInstanceDataCache(instanceId, normalized) + })) } - async deleteInstanceData(instanceId: string): Promise { - await serverApi.deleteInstanceData(instanceId) - this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA) + deleteInstanceData(instanceId: string): Promise { + return this.trackWrite(serverApi.deleteInstanceData(instanceId).then(() => { + this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA) + })) + } + + async flushWrites(): Promise { + while (this.pendingWrites.size > 0) { + await Promise.allSettled(this.pendingWrites) + } } onConfigOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void { @@ -225,6 +236,12 @@ export class ServerStorage { } } + private trackWrite(write: Promise): Promise { + this.pendingWrites.add(write) + void write.finally(() => this.pendingWrites.delete(write)).catch(() => undefined) + return write + } + private normalizeInstanceData(data?: InstanceData | null): InstanceData { const source = data ?? DEFAULT_INSTANCE_DATA const messageHistory = Array.isArray(source.messageHistory) ? [...source.messageHistory] : [] diff --git a/packages/ui/src/stores/preferences.test.ts b/packages/ui/src/stores/preferences.test.ts index 64dfddb8..5fc7aefa 100644 --- a/packages/ui/src/stores/preferences.test.ts +++ b/packages/ui/src/stores/preferences.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { buildSpeechPatch } from "../lib/speech-patch" -import { buildBinaryList, type SpeechSettingsUpdate } from "./preferences" +import { buildBinaryList, normalizeServerConfig, type SpeechSettingsUpdate } from "./preferences" describe("buildBinaryList", () => { it("does not add built-in commands to custom binary history", () => { @@ -13,6 +13,13 @@ describe("buildBinaryList", () => { }) }) +describe("normalizeServerConfig", () => { + it("maps the legacy system command to opencode2", () => { + assert.equal(normalizeServerConfig({ opencodeBinary: "opencode" }).opencodeBinary, "opencode2") + assert.equal(normalizeServerConfig({ opencodeBinary: "C:/tools/opencode2.exe" }).opencodeBinary, "C:/tools/opencode2.exe") + }) +}) + describe("buildSpeechPatch", () => { it("only includes fields that are explicitly provided", () => { const patch = buildSpeechPatch({ ttsVoice: "alloy" }) diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 94222522..ebef51de 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -454,7 +454,7 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { } } -function normalizeServerConfig( +export function normalizeServerConfig( input?: ServerConfigBucket | null, ): Required> & { speech: SpeechSettings } { const source = input ?? {} @@ -463,7 +463,9 @@ function normalizeServerConfig( source.logLevel === "INFO" || source.logLevel === "WARN" || source.logLevel === "ERROR" || source.logLevel === "DEBUG" ? source.logLevel : "DEBUG" - const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode2" + const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() && source.opencodeBinary !== "opencode" + ? source.opencodeBinary + : "opencode2" const environmentVariables = normalizeRecord(source.environmentVariables) const secureEnvVars = normalizeSecureEnvVars(source.secureEnvVars) const speech = normalizeSpeechSettings(source.speech)