Skip to content

Commit f0a9fc3

Browse files
committed
fix(restore): disable startup restore by default
New Electron, Tauri, and UI client state now starts with restoration disabled while preserving an explicitly persisted enabled preference. Parsing malformed or incomplete envelopes follows the same safe default across desktop hosts. Tests that exercise persistence explicitly enable restoration, and the rebased interruption registry uses its public session-id view instead of stale internal maps. Validation: UI and Electron typecheck passed; focused Rust tests reached 36/38 before the final fixture-only correction.
1 parent 047f66c commit f0a9fc3

9 files changed

Lines changed: 52 additions & 26 deletions

File tree

packages/electron-app/electron/main/client-state-cross-host-child.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ const registration = owner && CrossHostRegistration.register(directory, owner, t
3232
onOwnerPrepared: mode === "owner-crash" ? () => process.exit(91) : undefined,
3333
onOwnerRetired: mode === "retire-crash" ? () => process.exit(91) : undefined,
3434
})
35-
if (manager?.isPrimary && operation === "save") await manager.saveClientState(JSON.parse(payload))
35+
if (manager?.isPrimary && operation === "save") {
36+
await manager.setRestoreEnabled(true)
37+
await manager.saveClientState(JSON.parse(payload))
38+
}
3639
process.stdout.write(`${JSON.stringify({
3740
acquired: manager?.isPrimary ?? Boolean(registration?.isPrimary),
3841
state: operation === "load" ? manager?.loadClientState() : undefined,

packages/electron-app/electron/main/client-state-navigation.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ test("renderer access resets only for trusted full main-frame navigation", () =>
3232

3333
test("immediate reload flushes latest state before rotating document access", async (t) => {
3434
const manager = managerHarness(t)
35+
await manager.setRestoreEnabled(true)
3536
manager.claimClientStateAccess("outgoing")
3637
const load = (token: string) => { manager.assertRendererAccessToken(token); return manager.loadClientState() }
3738
const save = (token: string, state: unknown) => { manager.assertRendererAccessToken(token); return manager.saveClientState(state) }

packages/electron-app/electron/main/client-state.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function harness(t: test.TestContext, initial?: object) {
3030
}
3131

3232
test("renderer access is exclusive per document and resettable", async (t) => {
33-
const manager = harness(t).create()
33+
const manager = harness(t, { version: 1, restoreEnabled: true }).create()
3434
assert.throws(() => manager.claimClientStateAccess(""), /nonempty string/)
3535
assert.throws(() => manager.assertRendererAccessToken("unclaimed"), /has not been claimed/)
3636
assert.equal(manager.claimClientStateAccess("document-1"), true)
@@ -65,7 +65,7 @@ test("cross-host ownership is required in addition to each host-local election",
6565
crossHostDependencies,
6666
processOwner: { pid: 8102, runToken: "electron", processStartIdentity: "electron-start" },
6767
})
68-
assert.deepEqual(secondary.loadClientState(), { isPrimary: false, restoreEnabled: true, snapshot: null })
68+
assert.deepEqual(secondary.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null })
6969
await secondary.drainAndReleasePrimary()
7070

7171
await primary.drainAndReleasePrimary()
@@ -134,13 +134,13 @@ test("ownership loss immediately disables restore reads and mutations", async (t
134134
const manager = h.create()
135135
writeFileSync(join(h.directory, "election", "primary.owner.json", "owner.json"), "malformed")
136136
assert.equal(manager.isPrimary, false)
137-
assert.deepEqual(manager.loadClientState(), { isPrimary: false, restoreEnabled: true, snapshot: null })
137+
assert.deepEqual(manager.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null })
138138
assert.equal(manager.getWindowState(), undefined)
139139
assert.equal(await manager.saveClientState({ ignored: true }), false)
140140
})
141141

142142
test("failed preference and clear writes roll memory and suppression back", async (t) => {
143-
const h = harness(t)
143+
const h = harness(t, { version: 1, restoreEnabled: true })
144144
const manager = h.create()
145145
await manager.saveClientState({ kept: true })
146146
h.fail(true)
@@ -157,7 +157,7 @@ test("failed preference and clear writes roll memory and suppression back", asyn
157157
})
158158

159159
test("successful clear suppresses saves, including after failed re-enable", async (t) => {
160-
const h = harness(t)
160+
const h = harness(t, { version: 1, restoreEnabled: true })
161161
const manager = h.create()
162162
await manager.saveClientState({ kept: true })
163163
await manager.clearClientState()
@@ -170,7 +170,7 @@ test("successful clear suppresses saves, including after failed re-enable", asyn
170170
})
171171

172172
test("disabling restore atomically removes snapshot/window and survives restart", async (t) => {
173-
const h = harness(t)
173+
const h = harness(t, { version: 1, restoreEnabled: true })
174174
const manager = h.create(undefined, { pid: process.pid, runToken: "before-restart", processStartIdentity: "old-start" })
175175
await manager.saveClientState({ kept: true })
176176
await manager.saveWindowState({ bounds: { x: 10, y: 20, width: 1200, height: 800 }, maximized: true, fullscreen: false, zoomFactor: 1.25 })
@@ -188,7 +188,7 @@ test("disabling restore atomically removes snapshot/window and survives restart"
188188
})
189189

190190
test("drain freezes mutations and waits for admitted writes", async (t) => {
191-
const h = harness(t)
191+
const h = harness(t, { version: 1, restoreEnabled: true })
192192
let started!: () => void
193193
let release!: () => void
194194
const began = new Promise<void>((resolve) => { started = resolve })
@@ -209,7 +209,7 @@ test("drain freezes mutations and waits for admitted writes", async (t) => {
209209
})
210210

211211
test("an old writer cannot replace a successor after PID reuse", async (t) => {
212-
const h = harness(t)
212+
const h = harness(t, { version: 1, restoreEnabled: true })
213213
let started!: () => void
214214
let release!: () => void
215215
const began = new Promise<void>((resolve) => { started = resolve })
@@ -234,15 +234,15 @@ test("future envelopes are preserved until a successful explicit clear", async (
234234
const future = { version: 7, restoreEnabled: false, snapshot: { future: true }, futurePreference: "keep" }
235235
const h = harness(t, future)
236236
const manager = h.create(undefined, { pid: process.pid, runToken: "future-before-restart", processStartIdentity: "old-start" })
237-
assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: null })
237+
assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null })
238238
assert.equal(await manager.saveClientState({ ignored: true }), true)
239239
assert.equal(await manager.setRestoreEnabled(false), false)
240240
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future)
241241
await manager.drainAndReleasePrimary()
242242
const restarted = h.create()
243243
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future)
244244
assert.equal(await restarted.clearClientState(), true)
245-
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), { version: 1, restoreEnabled: true })
245+
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), { version: 1, restoreEnabled: false })
246246
assert.equal(await restarted.saveClientState({ supported: true }), true)
247247
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { supported: true })
248248
})

packages/electron-app/electron/main/client-state.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ interface ParsedClientState {
8585
}
8686

8787
function parseClientState(value: string): ParsedClientState {
88-
const defaults: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true }
88+
const defaults: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: false }
8989
try {
9090
const candidate = JSON.parse(value) as Record<string, unknown>
9191
if (candidate && typeof candidate.version === "number" && candidate.version > CLIENT_STATE_VERSION) {
@@ -97,7 +97,7 @@ function parseClientState(value: string): ParsedClientState {
9797

9898
const state: PersistedClientState = {
9999
version: CLIENT_STATE_VERSION,
100-
restoreEnabled: typeof candidate.restoreEnabled === "boolean" ? candidate.restoreEnabled : true,
100+
restoreEnabled: typeof candidate.restoreEnabled === "boolean" ? candidate.restoreEnabled : false,
101101
}
102102
if (Object.prototype.hasOwnProperty.call(candidate, "snapshot")) {
103103
state.snapshot = candidate.snapshot
@@ -142,7 +142,7 @@ export class ClientStateManager {
142142
private readonly lockPath: string
143143
private readonly legacyTauriDataPath: string | null
144144
private readonly owner: ProcessOwner
145-
private state: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true }
145+
private state: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: false }
146146
private writeQueue: Promise<void> = Promise.resolve()
147147
private drainAndReleasePromise: Promise<void> | undefined
148148
private crossHostRegistration: CrossHostRegistration | undefined
@@ -238,7 +238,7 @@ export class ClientStateManager {
238238

239239
loadClientState(): ClientStateLoadResult {
240240
if (!this.isPrimary) {
241-
return { isPrimary: false, restoreEnabled: true, snapshot: null }
241+
return { isPrimary: false, restoreEnabled: false, snapshot: null }
242242
}
243243
return {
244244
isPrimary: true,
@@ -368,7 +368,7 @@ export class ClientStateManager {
368368
console.warn("[client-state] failed to read state", error)
369369
}
370370
return {
371-
state: { version: CLIENT_STATE_VERSION, restoreEnabled: true },
371+
state: { version: CLIENT_STATE_VERSION, restoreEnabled: false },
372372
unsupportedFutureEnvelope: false,
373373
}
374374
}

packages/tauri-app/src-tauri/src/client_state.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,11 @@ impl Default for PersistedClientState {
7171
fn default() -> Self {
7272
Self {
7373
version: CLIENT_STATE_VERSION,
74-
restore_enabled: true,
74+
restore_enabled: false,
7575
snapshot: None,
7676
window: None,
7777
unsupported_future_envelope: false,
78-
writes_enabled: true,
78+
writes_enabled: false,
7979
}
8080
}
8181
}
@@ -531,14 +531,14 @@ fn parse_client_state(bytes: &[u8]) -> PersistedClientState {
531531
restore_enabled: value
532532
.get("restoreEnabled")
533533
.and_then(Value::as_bool)
534-
.unwrap_or(true),
534+
.unwrap_or(false),
535535
snapshot,
536536
window: value.get("window").and_then(window::normalize_window_state),
537537
unsupported_future_envelope: false,
538538
writes_enabled: value
539539
.get("restoreEnabled")
540540
.and_then(Value::as_bool)
541-
.unwrap_or(true),
541+
.unwrap_or(false),
542542
}
543543
}
544544

packages/tauri-app/src-tauri/src/client_state/tests.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ fn assert_receive_timeout<T>(result: Result<T, mpsc::RecvTimeoutError>) {
4242
assert!(matches!(result, Err(mpsc::RecvTimeoutError::Timeout)));
4343
}
4444

45+
fn enable_restore(state: &ClientState) {
46+
assert!(state.set_restore_enabled(true).unwrap());
47+
}
48+
49+
fn enable_restore_in_memory(state: &ClientState) {
50+
let mut persisted = state.state.lock().unwrap();
51+
persisted.restore_enabled = true;
52+
persisted.writes_enabled = true;
53+
}
54+
4555
fn failing_state(initially_failing: bool) -> (TempDir, ClientState, Arc<AtomicBool>) {
4656
let directory = tempfile::tempdir().unwrap();
4757
let fail = Arc::new(AtomicBool::new(initially_failing));
@@ -162,7 +172,7 @@ fn parses_envelopes_and_normalizes_zoom() {
162172
br#"{"version":1,"restoreEnabled":"no"}"#.as_slice(),
163173
] {
164174
let state = parse_client_state(bytes);
165-
assert!(state.restore_enabled);
175+
assert!(!state.restore_enabled);
166176
assert_eq!(state.snapshot, None);
167177
assert!(!state.unsupported_future_envelope);
168178
}
@@ -370,6 +380,7 @@ fn election_preserves_cohorts_until_every_participant_exits() {
370380
fn secondary_and_failed_initialization_are_isolated() {
371381
let directory = tempfile::tempdir().unwrap();
372382
let primary = ClientState::initialize_at(directory.path()).unwrap();
383+
enable_restore(&primary);
373384
assert!(primary.save_snapshot(json!({ "kept": true })).unwrap());
374385
let state_path = directory.path().join(CLIENT_STATE_FILENAME);
375386
let original = fs::read(&state_path).unwrap();
@@ -390,6 +401,7 @@ fn disable_and_clear_suppress_later_writes() {
390401
for clear in [false, true] {
391402
let directory = tempfile::tempdir().unwrap();
392403
let state = ClientState::initialize_at(directory.path()).unwrap();
404+
enable_restore(&state);
393405
assert!(state.save_snapshot(json!({ "removed": true })).unwrap());
394406
state.state.lock().unwrap().window = Some(window());
395407
if clear {
@@ -413,6 +425,7 @@ fn disable_and_clear_suppress_later_writes() {
413425
fn failed_writes_restore_memory_and_suppression_state() {
414426
for operation in ["snapshot", "clear", "disable"] {
415427
let (_directory, state, fail) = failing_state(false);
428+
enable_restore(&state);
416429
assert!(state.save_snapshot(json!({ "kept": true })).unwrap());
417430
state.state.lock().unwrap().window = Some(window());
418431
fail.store(true, Ordering::SeqCst);
@@ -463,7 +476,7 @@ fn future_envelope_is_preserved_until_successful_clear() {
463476
}),
464477
)
465478
.unwrap();
466-
assert_eq!(state.load().unwrap(), load(true, true, Value::Null));
479+
assert_eq!(state.load().unwrap(), load(true, false, Value::Null));
467480
assert!(!state.set_restore_enabled(false).unwrap());
468481
assert!(state.save_snapshot(json!({ "ignored": true })).unwrap());
469482
state.flush().unwrap();
@@ -474,6 +487,7 @@ fn future_envelope_is_preserved_until_successful_clear() {
474487
fail.store(false, Ordering::SeqCst);
475488
assert!(state.clear().unwrap());
476489
assert!(!state.state.lock().unwrap().unsupported_future_envelope);
490+
enable_restore(&state);
477491
assert!(state.save_snapshot(json!({ "accepted": true })).unwrap());
478492
assert_eq!(state.load().unwrap().snapshot, json!({ "accepted": true }));
479493
}
@@ -613,6 +627,7 @@ fn ownership_release_drains_active_write_and_blocks_later_writes() {
613627
)
614628
.unwrap(),
615629
);
630+
enable_restore_in_memory(&state);
616631
let writing = Arc::clone(&state);
617632
let writer = thread::spawn(move || writing.save_snapshot(json!({ "first": true })));
618633
started_rx.recv().unwrap();
@@ -647,6 +662,7 @@ fn ownership_loss_blocks_the_final_atomic_replacement() {
647662
}),
648663
)
649664
.unwrap();
665+
enable_restore_in_memory(&state);
650666
assert_eq!(
651667
state.save_snapshot(json!({ "blocked": true })).unwrap_err(),
652668
"Client state ownership changed before atomic replacement"
@@ -673,6 +689,7 @@ fn renderer_rotation_blocks_an_in_flight_old_renderer_replacement() {
673689
)
674690
.unwrap(),
675691
);
692+
enable_restore_in_memory(&state);
676693
let outgoing = Url::parse("http://127.0.0.1:43123/workspace").unwrap();
677694
let incoming = Url::parse("http://127.0.0.1:43124/workspace").unwrap();
678695
state.renderer_access.claim("old", &outgoing).unwrap();
@@ -699,6 +716,7 @@ fn renderer_rotation_blocks_an_in_flight_old_renderer_replacement() {
699716
fn oversized_snapshot_does_not_replace_state() {
700717
let directory = tempfile::tempdir().unwrap();
701718
let state = ClientState::initialize_at(directory.path()).unwrap();
719+
enable_restore(&state);
702720
state.save_snapshot(json!({ "small": true })).unwrap();
703721
assert!(state
704722
.save_snapshot(Value::String("x".repeat(MAX_CLIENT_SNAPSHOT_BYTES)))

packages/ui/src/lib/native/client-state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export type NativeClientStateLoadResult = {
66
restoreEnabled: boolean
77
snapshot: unknown | null
88
}
9-
const SECONDARY_CLIENT_STATE: NativeClientStateLoadResult = { isPrimary: false, restoreEnabled: true, snapshot: null }
9+
const SECONDARY_CLIENT_STATE: NativeClientStateLoadResult = { isPrimary: false, restoreEnabled: false, snapshot: null }
1010
const accessToken = (() => {
1111
const bytes = new Uint8Array(32)
1212
globalThis.crypto.getRandomValues(bytes)

packages/ui/src/stores/client-state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const MAX_LAYOUT_VALUE_LENGTH = 4096
1111
const LEGACY_LAYOUT_KEY_PREFIX = "opencode-session-"
1212
const UNSAFE_LAYOUT_KEYS = new Set(["__proto__", "constructor", "prototype"])
1313
const [clientStateIsPrimary, setClientStateIsPrimary] = createSignal(true)
14-
const [restorePreviousStateEnabled, setRestorePreviousStateEnabledSignal] = createSignal(true)
14+
const [restorePreviousStateEnabled, setRestorePreviousStateEnabledSignal] = createSignal(false)
1515
const [loadedClientSnapshotExists, setLoadedClientSnapshotExists] = createSignal(false)
1616
const [loadedRestorableSession, setLoadedRestorableSession] = createSignal<RestorableSessionState | null>(null)
1717
let initialized = false

packages/ui/src/stores/instances.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ class InterruptionRegistry<T extends { id: string }, S extends string> {
168168
return next
169169
}
170170

171+
sessionIds(instanceId: string): IterableIterator<string> {
172+
return this.sessionCounts.get(instanceId)?.keys() ?? new Map<string, number>().keys()
173+
}
174+
171175
clear(instanceId: string, requests: readonly T[], clearPending: (sessionId: string) => void): void {
172176
requests.forEach(({ id }) => this.enqueuedAt.delete(id))
173177
this.sources.delete(instanceId)
@@ -291,8 +295,8 @@ function settleInstanceReadyWaiters(instanceId: string, error?: Error): void {
291295
function reconcilePendingSessionIndicators(instanceId: string): void {
292296
reconcileSessionPendingState(
293297
instanceId,
294-
new Set(permissionSessionCounts.get(instanceId)?.keys() ?? []),
295-
new Set(questionSessionCounts.get(instanceId)?.keys() ?? []),
298+
new Set(permissionRegistry.sessionIds(instanceId)),
299+
new Set(questionRegistry.sessionIds(instanceId)),
296300
)
297301
}
298302

0 commit comments

Comments
 (0)