harden(desktop): RecoveryStateStore listener safety + frozen state

- Snapshot listener set before notifying so subscribers can subscribe/
  unsubscribe during notification without breaking the pass
- Per-callback try/catch so one throwing subscriber does not silence others
- Object.freeze on each state object so the live reference returned by
  get() cannot be accidentally mutated externally (compile-time
  Readonly<> is hint only)
- 3 new tests pinning these invariants
This commit is contained in:
Jannis Braun
2026-05-03 04:06:43 +02:00
parent 15dfa0a68c
commit d3b3abacff
2 changed files with 44 additions and 3 deletions
+32
View File
@@ -71,4 +71,36 @@ describe('RecoveryStateStore', () => {
store.markRecoveryExited();
expect(store.isInRecoveryMode()).toBe(false);
});
it('a throwing listener does not stop other listeners', () => {
const store = new RecoveryStateStore();
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const a = vi.fn(() => { throw new Error('boom'); });
const b = vi.fn();
store.subscribe(a);
store.subscribe(b);
expect(() => store.update({ updateState: 'checking' })).not.toThrow();
expect(a).toHaveBeenCalledTimes(1);
expect(b).toHaveBeenCalledTimes(1);
errSpy.mockRestore();
});
it('a listener can unsubscribe itself during notification without breaking the pass', () => {
const store = new RecoveryStateStore();
const b = vi.fn();
let unsubA: (() => void) | null = null;
unsubA = store.subscribe(() => { unsubA?.(); });
store.subscribe(b);
expect(() => store.update({ updateState: 'checking' })).not.toThrow();
// Second update: the self-unsubscribed listener should be gone, b still fires
store.update({ updateState: 'idle' });
expect(b).toHaveBeenCalledTimes(2);
});
it('returned state is frozen — accidental external mutation throws in strict mode', () => {
const store = new RecoveryStateStore();
store.update({ updateState: 'checking' });
const s = store.get();
expect(Object.isFrozen(s)).toBe(true);
});
});
+12 -3
View File
@@ -30,7 +30,7 @@ const INITIAL_STATE: RecoveryState = {
};
export class RecoveryStateStore {
private state: RecoveryState = { ...INITIAL_STATE };
private state: RecoveryState = Object.freeze({ ...INITIAL_STATE }) as RecoveryState;
private listeners = new Set<(s: RecoveryState) => void>();
private inRecoveryMode = false;
@@ -39,8 +39,17 @@ export class RecoveryStateStore {
}
update(partial: Partial<RecoveryState>): void {
this.state = { ...this.state, ...partial };
for (const cb of this.listeners) cb(this.state);
this.state = Object.freeze({ ...this.state, ...partial }) as RecoveryState;
// Snapshot before iterating: a listener can subscribe/unsubscribe others
// (or itself) during notification without affecting the current notify pass.
const snapshot = Array.from(this.listeners);
for (const cb of snapshot) {
try {
cb(this.state);
} catch (err) {
console.error('[recovery] listener threw:', err);
}
}
}
subscribe(cb: (s: RecoveryState) => void): () => void {