feat(federation): trigger DM failover on user-initiated disconnect

disconnectInstance and forceRemoveEntry now run
failoverDmOriginsFromDisconnected BEFORE removeInstanceSpaces so any DM
with a connected sibling survives the disconnect via rekey; only DMs
without alternatives are cleared alongside the rest of the instance.
Switched setInstanceStatus to the same static import (dmOriginFailover
lazily reads store state, so no import cycle).
This commit is contained in:
Jannis Braun
2026-04-23 01:25:42 +02:00
parent 1a23871367
commit cd1f5c2b64
2 changed files with 46 additions and 11 deletions
@@ -39,16 +39,16 @@ beforeEach(() => {
}); });
describe('instanceStore failover triggers', () => { describe('instanceStore failover triggers', () => {
it('fires failover on connected → disconnected transition', async () => { it('fires failover on connected → disconnected transition', () => {
useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] });
useInstanceStore.getState().setInstanceStatus('https://b.example', 'disconnected'); useInstanceStore.getState().setInstanceStatus('https://b.example', 'disconnected');
await vi.waitFor(() => expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example')); expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example');
}); });
it('fires failover on connected → error transition', async () => { it('fires failover on connected → error transition', () => {
useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] });
useInstanceStore.getState().setInstanceStatus('https://b.example', 'error'); useInstanceStore.getState().setInstanceStatus('https://b.example', 'error');
await vi.waitFor(() => expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example')); expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example');
}); });
it('does not fire on connecting → connected', () => { it('does not fire on connecting → connected', () => {
@@ -67,4 +67,36 @@ describe('instanceStore failover triggers', () => {
useInstanceStore.getState().setInstanceStatus('https://unknown.example', 'disconnected'); useInstanceStore.getState().setInstanceStatus('https://unknown.example', 'disconnected');
expect(mockFailover).not.toHaveBeenCalled(); expect(mockFailover).not.toHaveBeenCalled();
}); });
it('disconnectInstance runs failover before removeInstanceSpaces', async () => {
// Seed a DM pinned to b.example with home as alternative — removeInstanceSpaces
// uses spaceStore, which we let run; we just check failover ran first (call order).
const spaceModule = await import('./spaceStore');
const spaceSpy = vi.spyOn(spaceModule.useSpaceStore.getState(), 'removeInstanceSpaces');
const callOrder: string[] = [];
mockFailover.mockImplementation(() => { callOrder.push('failover'); });
spaceSpy.mockImplementation(() => { callOrder.push('removeInstanceSpaces'); });
useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] });
useInstanceStore.getState().disconnectInstance('https://b.example');
await Promise.resolve();
expect(callOrder).toEqual(['failover', 'removeInstanceSpaces']);
spaceSpy.mockRestore();
});
it('forceRemoveEntry runs failover before removeInstanceSpaces', async () => {
const spaceModule = await import('./spaceStore');
const spaceSpy = vi.spyOn(spaceModule.useSpaceStore.getState(), 'removeInstanceSpaces');
const callOrder: string[] = [];
mockFailover.mockImplementation(() => { callOrder.push('failover'); });
spaceSpy.mockImplementation(() => { callOrder.push('removeInstanceSpaces'); });
useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] });
useInstanceStore.getState().forceRemoveEntry('https://b.example');
await Promise.resolve();
expect(callOrder).toEqual(['failover', 'removeInstanceSpaces']);
spaceSpy.mockRestore();
});
}); });
+10 -7
View File
@@ -8,6 +8,9 @@ import { connectInstance, disconnectInstance as disconnectWs, disconnectAllRemot
// Safe because both modules access each other lazily (at call time, not import time). // Safe because both modules access each other lazily (at call time, not import time).
// clearPasswordSyncTimers itself does not reference useInstanceStore. // clearPasswordSyncTimers itself does not reference useInstanceStore.
import { clearPasswordSyncTimers } from '../utils/federationOps'; import { clearPasswordSyncTimers } from '../utils/federationOps';
// dmOriginFailover lazily reads useInstanceStore/useSpaceStore/useChatStore at call time,
// so a static import here does not create an import-time cycle.
import { failoverDmOriginsFromDisconnected } from '../utils/dmOriginFailover';
import { useUIStore } from './uiStore'; import { useUIStore } from './uiStore';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -435,11 +438,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
), ),
})); }));
if (prev === 'connected' && (status === 'disconnected' || status === 'error')) { if (prev === 'connected' && (status === 'disconnected' || status === 'error')) {
// Dynamic import keeps the circular-dep-safe resolver pattern used elsewhere failoverDmOriginsFromDisconnected(origin);
// in this file. Fire-and-forget: failover reads state at call time.
import('../utils/dmOriginFailover').then(({ failoverDmOriginsFromDisconnected }) => {
failoverDmOriginsFromDisconnected(origin);
});
} }
}, },
@@ -467,7 +466,10 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
return { instances: updated, registry, registryUpdatedAt }; return { instances: updated, registry, registryUpdatedAt };
}); });
// Remove spaces from this instance from the space store // Failover DMs to a connected sibling BEFORE removeInstanceSpaces wipes
// this origin's pins. DMs with a connected alternative survive via rekey;
// DMs without one are removed alongside the rest of the instance's content.
failoverDmOriginsFromDisconnected(origin);
useSpaceStore.getState().removeInstanceSpaces(origin); useSpaceStore.getState().removeInstanceSpaces(origin);
// Sync updated lists to remaining instances (fire-and-forget) // Sync updated lists to remaining instances (fire-and-forget)
@@ -757,7 +759,8 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
return { instances: updated, registry, registryUpdatedAt }; return { instances: updated, registry, registryUpdatedAt };
}); });
// Clean up spaces belonging to this instance // Same rationale as disconnectInstance — preserve DMs with connected alts.
failoverDmOriginsFromDisconnected(origin);
useSpaceStore.getState().removeInstanceSpaces(origin); useSpaceStore.getState().removeInstanceSpaces(origin);
get().syncRegistry().catch(() => {}); get().syncRegistry().catch(() => {});