mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
Phase 4-K.1. Replaces the "connection lost → ERROR + manual reconnect" flow with an exp-backoff retry loop that reconciles the full session: SSH session, RPC tunnel + daemon redeploy, patched-adapter rebind, fs.watch resubscribe. Pieces: - Backoff (pure): exp + jitter + max-cap, full-jitter formula. Default schedule starts at 1s, ×1.5 up to 30s, ±20% jitter, 5 attempts. - ReconnectManager: state machine (idle / waiting / attempting / recovered / failed / cancelled), injectable scheduler + RNG so tests don't sleep. cancel() interrupts at the next sleep boundary. - SftpDataAdapter.swapClient: rebinds the underlying RemoteFsClient while the patched adapter stays wired into Obsidian. Caches survive because they're mtime-keyed. - main.ts: SftpClient.onClose(unexpected) kicks startReconnect. reconnectAttempt() does ssh.connect + (always-redeploy daemon) + swapClient + re-subscribe fs.watch in one pass. State changes are projected onto the StatusBar with attempt counter + ETA. - StatusBar: new RECONNECTING SyncState + is-reconnecting CSS class. - disconnect() cancels the loop, clears activeProfile. Tests: 13 new (Backoff: 6, ReconnectManager: 7) + 2 swapClient cases on SftpDataAdapter; 188 total green. UX of the reconnect *during* the loop (write-while-reconnecting, read-from-cache, cancel command, settings for max-retries) lands in 4-K.2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
54 lines
2.1 KiB
TypeScript
54 lines
2.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { nextDelay, DEFAULT_BACKOFF, type BackoffConfig } from '../src/transport/Backoff';
|
|
|
|
const noJitterCfg: BackoffConfig = {
|
|
initialMs: 1000,
|
|
multiplier: 2,
|
|
maxMs: 8000,
|
|
jitterPct: 0,
|
|
maxRetries: 5,
|
|
};
|
|
|
|
// Pinned RNG that always returns the midpoint, so jitter contributes 0.
|
|
const midRng = () => 0.5;
|
|
|
|
describe('Backoff.nextDelay', () => {
|
|
it('returns initialMs for the first attempt (prevMs === null)', () => {
|
|
expect(nextDelay(null, noJitterCfg, midRng)).toBe(1000);
|
|
});
|
|
|
|
it('multiplies the previous delay until maxMs', () => {
|
|
expect(nextDelay(1000, noJitterCfg, midRng)).toBe(2000);
|
|
expect(nextDelay(2000, noJitterCfg, midRng)).toBe(4000);
|
|
expect(nextDelay(4000, noJitterCfg, midRng)).toBe(8000);
|
|
expect(nextDelay(8000, noJitterCfg, midRng)).toBe(8000);
|
|
expect(nextDelay(16000, noJitterCfg, midRng)).toBe(8000);
|
|
});
|
|
|
|
it('applies positive jitter when rng > 0.5', () => {
|
|
const cfg: BackoffConfig = { ...noJitterCfg, jitterPct: 0.5 };
|
|
// rng()=1 → jitter sample = 1 (max positive). delay = 1000 + 1000*0.5*1 = 1500.
|
|
expect(nextDelay(null, cfg, () => 1)).toBe(1500);
|
|
});
|
|
|
|
it('applies negative jitter when rng < 0.5', () => {
|
|
const cfg: BackoffConfig = { ...noJitterCfg, jitterPct: 0.5 };
|
|
// rng()=0 → jitter sample = -1. delay = 1000 + 1000*0.5*(-1) = 500.
|
|
expect(nextDelay(null, cfg, () => 0)).toBe(500);
|
|
});
|
|
|
|
it('never returns a negative delay even with maximal negative jitter', () => {
|
|
const cfg: BackoffConfig = { ...noJitterCfg, jitterPct: 2 };
|
|
// jitter would push to -1000; clamp to 0.
|
|
expect(nextDelay(null, cfg, () => 0)).toBe(0);
|
|
});
|
|
|
|
it('DEFAULT_BACKOFF is sensible', () => {
|
|
expect(DEFAULT_BACKOFF.initialMs).toBeGreaterThan(0);
|
|
expect(DEFAULT_BACKOFF.multiplier).toBeGreaterThan(1);
|
|
expect(DEFAULT_BACKOFF.maxMs).toBeGreaterThanOrEqual(DEFAULT_BACKOFF.initialMs);
|
|
expect(DEFAULT_BACKOFF.jitterPct).toBeGreaterThanOrEqual(0);
|
|
expect(DEFAULT_BACKOFF.jitterPct).toBeLessThanOrEqual(1);
|
|
expect(DEFAULT_BACKOFF.maxRetries).toBeGreaterThan(0);
|
|
});
|
|
});
|