39ef1c1f3d
A dead tunnel was redialled every 2 s until the scope was torn down; delays now double from the base to a 30 s cap and reset on open. The generation counter duplicated what the entry map and entry.socket already say (stop() removes the entry, connect() replaces the socket), so abandonIfStale reads those instead. afterStop's unused entry parameter is dropped.
119 lines
4.2 KiB
TypeScript
119 lines
4.2 KiB
TypeScript
/**
|
|
* #106935: Desktop main must hold a long-lived keep-alive WebSocket for every
|
|
* published SSH-isolated backend. Idle-exit (#101626) treats accepted WS as
|
|
* ownership liveness; renderer sockets can drop while sshConnections still owns
|
|
* the scope. Sticky artifacts (nonce / token file / lockfile) are NOT liveness.
|
|
*
|
|
* The pool-stop teardown fence that closes this socket is asserted in
|
|
* pool-stop.test.ts (afterStop) — AGENTS.md forbids reading `.ts` source from tests.
|
|
*/
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } {
|
|
const instances: any[] = []
|
|
|
|
class FakeWs {
|
|
url: string
|
|
closed = false
|
|
listeners: Record<string, Array<(event?: any) => void>> = {}
|
|
|
|
constructor(url: string) {
|
|
this.url = url
|
|
instances.push(this)
|
|
}
|
|
|
|
addEventListener(type: string, fn: (event?: any) => void) {
|
|
;(this.listeners[type] ||= []).push(fn)
|
|
}
|
|
|
|
close() {
|
|
this.closed = true
|
|
}
|
|
|
|
emit(type: string, event?: any) {
|
|
for (const fn of this.listeners[type] || []) {
|
|
fn(event)
|
|
}
|
|
}
|
|
}
|
|
|
|
return { FakeWs, instances }
|
|
}
|
|
|
|
describe('ssh-isolated keep-alive registry (#106935)', () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('holds an open WebSocket per scope until that scope stops, then never reconnects it', async () => {
|
|
const { createSshIsolatedKeepaliveRegistry } = await import('./ssh-isolated-keepalive')
|
|
const { FakeWs, instances } = makeFakeWs()
|
|
const registry = createSshIsolatedKeepaliveRegistry({
|
|
WebSocketImpl: FakeWs,
|
|
reconnectDelayMs: 25
|
|
})
|
|
|
|
registry.start('conn:office::work', {
|
|
baseUrl: 'http://127.0.0.1:53101',
|
|
token: 'sess-work'
|
|
})
|
|
registry.start('conn:office::less', { baseUrl: 'http://127.0.0.1:53102', token: 'sess-less' })
|
|
|
|
expect(instances).toHaveLength(2)
|
|
expect(instances[0].url).toBe('ws://127.0.0.1:53101/api/ws?token=sess-work')
|
|
expect(registry.isArmed('conn:office::work')).toBe(true)
|
|
|
|
instances[0].emit('open')
|
|
expect(registry.openUrl('conn:office::work')).toBe('ws://127.0.0.1:53101/api/ws?token=sess-work')
|
|
|
|
// A dropped socket is redialled with backoff (25 → 50 ms), so a dead tunnel is not
|
|
// hammered every interval until the scope is torn down.
|
|
vi.useFakeTimers()
|
|
instances[1].emit('close', { code: 1006 })
|
|
await vi.advanceTimersByTimeAsync(25)
|
|
expect(instances).toHaveLength(3)
|
|
instances[2].emit('close', { code: 1006 })
|
|
await vi.advanceTimersByTimeAsync(25)
|
|
expect(instances).toHaveLength(3)
|
|
await vi.advanceTimersByTimeAsync(25)
|
|
expect(instances).toHaveLength(4)
|
|
vi.useRealTimers()
|
|
|
|
registry.stop('conn:office::work')
|
|
expect(instances[0].closed).toBe(true)
|
|
expect(registry.isArmed('conn:office::work')).toBe(false)
|
|
// Tearing down one sibling must not drop the other owned scope.
|
|
expect(instances[3].closed).toBe(false)
|
|
expect(registry.isArmed('conn:office::less')).toBe(true)
|
|
|
|
instances[0].emit('close', { code: 1006 })
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
expect(instances).toHaveLength(4)
|
|
expect(registry.openUrl('conn:office::work')).toBeNull()
|
|
})
|
|
|
|
it('treats empty-string scope as the v1/global SSH primary but still requires baseUrl and token', async () => {
|
|
const { createSshIsolatedKeepaliveRegistry } = await import('./ssh-isolated-keepalive')
|
|
const { FakeWs, instances } = makeFakeWs()
|
|
const registry = createSshIsolatedKeepaliveRegistry({ WebSocketImpl: FakeWs })
|
|
|
|
registry.start('', { baseUrl: '', token: 'tok' })
|
|
registry.start('', { baseUrl: 'http://127.0.0.1:9', token: '' })
|
|
expect(instances).toHaveLength(0)
|
|
expect(registry.isArmed('')).toBe(false)
|
|
|
|
registry.start('', { baseUrl: 'http://127.0.0.1:53100', token: 'primary' })
|
|
|
|
expect(instances).toHaveLength(1)
|
|
expect(instances[0].url).toBe('ws://127.0.0.1:53100/api/ws?token=primary')
|
|
expect(registry.isArmed('')).toBe(true)
|
|
|
|
instances[0].emit('open')
|
|
expect(registry.openUrl('')).toBe('ws://127.0.0.1:53100/api/ws?token=primary')
|
|
|
|
registry.stop('')
|
|
expect(instances[0].closed).toBe(true)
|
|
expect(registry.isArmed('')).toBe(false)
|
|
})
|
|
})
|