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.
This commit is contained in:
Pascal André 2026-08-25 15:50:44 +02:00
parent df5adf7405
commit 7e53fc0ee3
No known key found for this signature in database
9 changed files with 86 additions and 24 deletions

View file

@ -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", () => {

View file

@ -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
}

View file

@ -30,7 +30,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
addOpenCodeBinary,
removeOpenCodeBinary,
serverSettings,
updateLastUsedBinary,
} = useConfig()
const [customPath, setCustomPath] = createSignal("")
const [validating, setValidating] = createSignal(false)
@ -157,7 +156,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
if (validation.valid) {
addOpenCodeBinary(path, validation.version)
props.onBinaryChange(path)
updateLastUsedBinary(path)
setCustomPath("")
setValidationError(null)
} else {
@ -182,7 +180,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (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<OpenCodeBinarySelectorProps> = (props) =
if (props.selectedBinary === path) {
props.onBinaryChange("opencode2")
updateLastUsedBinary("opencode2")
}
}

View file

@ -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\(\)\] : \[\]/)
})

View file

@ -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

View file

@ -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<string, unknown>) => void
serverApi.patchConfigOwner = <T extends Record<string, unknown>>() => new Promise<T>((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" })
})

View file

@ -39,6 +39,7 @@ export class ServerStorage {
private instanceDataCache = new Map<string, InstanceData>()
private instanceDataListeners = new Map<string, Set<(data: InstanceData) => void>>()
private instanceLoadPromises = new Map<string, Promise<InstanceData>>()
private pendingWrites = new Set<Promise<unknown>>()
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<OwnerBucket> {
const updated = await serverApi.patchConfigOwner<OwnerBucket>(owner, patch)
this.setOwnerCache("config", owner, updated)
return updated
patchConfigOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
return this.trackWrite(serverApi.patchConfigOwner<OwnerBucket>(owner, patch).then((updated) => {
this.setOwnerCache("config", owner, updated)
return updated
}))
}
async loadStateOwner(owner: string): Promise<OwnerBucket> {
@ -103,10 +105,11 @@ export class ServerStorage {
return this.stateOwnerLoadPromises.get(owner)!
}
async patchStateOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
const updated = await serverApi.patchStateOwner<OwnerBucket>(owner, patch)
this.setOwnerCache("state", owner, updated)
return updated
patchStateOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
return this.trackWrite(serverApi.patchStateOwner<OwnerBucket>(owner, patch).then((updated) => {
this.setOwnerCache("state", owner, updated)
return updated
}))
}
async loadInstanceData(instanceId: string): Promise<InstanceData> {
@ -133,15 +136,23 @@ export class ServerStorage {
return this.instanceLoadPromises.get(instanceId)!
}
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
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<void> {
await serverApi.deleteInstanceData(instanceId)
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
deleteInstanceData(instanceId: string): Promise<void> {
return this.trackWrite(serverApi.deleteInstanceData(instanceId).then(() => {
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
}))
}
async flushWrites(): Promise<void> {
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<T>(write: Promise<T>): Promise<T> {
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] : []

View file

@ -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" })

View file

@ -454,7 +454,7 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
}
}
function normalizeServerConfig(
export function normalizeServerConfig(
input?: ServerConfigBucket | null,
): Required<Pick<ServerConfigBucket, "listeningMode" | "logLevel" | "environmentVariables" | "opencodeBinary" | "secureEnvVars">> & { 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)