Connection tree with interleaved folders and hosts, tabbed terminals grouped by host, a pinned local shell, TOFU host key verification, and credentials held by the OS keyring. Terminal bytes travel on a per-session MessagePort with credit-based flow control, so one session running `cat` on a large file cannot stall the others. Transports reconnect on their own with jittered backoff, keeping the scrollback and the tab while replacing only the connection beneath. 156 tests, including integration tests against a real sshd and a migration exercised against a database built in the old shape.
76 lines
2.8 KiB
TypeScript
76 lines
2.8 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { RECONNECT, reconnectDelay } from '../src/shared/session-protocol'
|
|
|
|
/** The nominal delay before jitter, for a given 1-based attempt. */
|
|
function nominal(attempt: number): number {
|
|
return Math.min(
|
|
RECONNECT.maxDelayMs,
|
|
RECONNECT.initialDelayMs * 2 ** Math.max(0, attempt - 1),
|
|
)
|
|
}
|
|
|
|
describe('reconnectDelay', () => {
|
|
it('stays within the jitter band on every attempt', () => {
|
|
for (let attempt = 1; attempt <= RECONNECT.maxAttempts + 5; attempt++) {
|
|
const delay = reconnectDelay(attempt)
|
|
const base = nominal(attempt)
|
|
expect(delay).toBeGreaterThanOrEqual(Math.round(base * (1 - RECONNECT.jitter)) - 1)
|
|
expect(delay).toBeLessThanOrEqual(Math.round(base * (1 + RECONNECT.jitter)) + 1)
|
|
}
|
|
})
|
|
|
|
it('starts short enough that a blip is barely noticed', () => {
|
|
// A first retry the user has to wait through feels like a broken app.
|
|
expect(reconnectDelay(1)).toBeLessThanOrEqual(1_500)
|
|
})
|
|
|
|
it('climbs, and does not exceed the ceiling', () => {
|
|
expect(nominal(2)).toBeGreaterThan(nominal(1))
|
|
for (let attempt = 1; attempt <= 30; attempt++) {
|
|
expect(reconnectDelay(attempt)).toBeLessThanOrEqual(
|
|
Math.round(RECONNECT.maxDelayMs * (1 + RECONNECT.jitter)) + 1,
|
|
)
|
|
}
|
|
})
|
|
|
|
it('is never zero or negative', () => {
|
|
// A zero delay would turn the retry loop into a busy loop against a server
|
|
// that is already having a bad day.
|
|
for (let attempt = 1; attempt <= 30; attempt++) {
|
|
expect(reconnectDelay(attempt)).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('varies between calls, so sessions that drop together do not retry in lockstep', () => {
|
|
// This is the whole reason for the jitter: a network failure takes every
|
|
// session down at once, and identical delays would have them all hit the
|
|
// server on the same tick.
|
|
const samples = new Set(Array.from({ length: 40 }, () => reconnectDelay(4)))
|
|
expect(samples.size).toBeGreaterThan(1)
|
|
})
|
|
|
|
it('handles a non-positive attempt without producing nonsense', () => {
|
|
expect(reconnectDelay(0)).toBeGreaterThan(0)
|
|
expect(reconnectDelay(-3)).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('RECONNECT limits', () => {
|
|
it('gives up eventually rather than retrying forever', () => {
|
|
expect(RECONNECT.maxAttempts).toBeGreaterThan(0)
|
|
expect(RECONNECT.maxAttempts).toBeLessThanOrEqual(50)
|
|
})
|
|
|
|
it('spans roughly five minutes of trying', () => {
|
|
// Long enough to ride out a reboot or a flaky link, short enough that a
|
|
// session pointed at a decommissioned host stops rather than living
|
|
// forever in a retry loop.
|
|
let total = 0
|
|
for (let attempt = 1; attempt <= RECONNECT.maxAttempts; attempt++) {
|
|
total += nominal(attempt)
|
|
}
|
|
expect(total).toBeGreaterThan(60_000)
|
|
expect(total).toBeLessThan(15 * 60_000)
|
|
})
|
|
})
|