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();
|
store.markRecoveryExited();
|
||||||
expect(store.isInRecoveryMode()).toBe(false);
|
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 {
|
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 listeners = new Set<(s: RecoveryState) => void>();
|
||||||
private inRecoveryMode = false;
|
private inRecoveryMode = false;
|
||||||
|
|
||||||
@@ -39,8 +39,17 @@ export class RecoveryStateStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update(partial: Partial<RecoveryState>): void {
|
update(partial: Partial<RecoveryState>): void {
|
||||||
this.state = { ...this.state, ...partial };
|
this.state = Object.freeze({ ...this.state, ...partial }) as RecoveryState;
|
||||||
for (const cb of this.listeners) cb(this.state);
|
// 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 {
|
subscribe(cb: (s: RecoveryState) => void): () => void {
|
||||||
|
|||||||
Reference in New Issue
Block a user