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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user