From 2b99563327e437cdb56db0f8393dad0d3973193d Mon Sep 17 00:00:00 2001 From: Tanmay Sardesai Date: Sat, 24 Oct 2026 23:29:49 +0000 Subject: [PATCH] Reconnect the live view after a drop, and only say shut down when it is --- .../client/src/components/disconnected.vue | 10 +- .../client/src/locale/en-us.ts | 4 +- .../chromium-headful/client/src/neko/base.ts | 63 +++++++- .../chromium-headful/client/src/neko/index.ts | 14 ++ .../client/src/store/index.ts | 11 ++ .../client/tests/connect-bound.test.ts | 138 +++++++++++++++++- .../client/tests/disconnect-state.test.ts | 88 +++++++++++ 7 files changed, 321 insertions(+), 7 deletions(-) create mode 100644 images/chromium-headful/client/tests/disconnect-state.test.ts diff --git a/images/chromium-headful/client/src/components/disconnected.vue b/images/chromium-headful/client/src/components/disconnected.vue index db45cc78b..adb8df177 100644 --- a/images/chromium-headful/client/src/components/disconnected.vue +++ b/images/chromium-headful/client/src/components/disconnected.vue @@ -1,7 +1,7 @@ @@ -34,5 +34,11 @@ import { Component, Vue } from 'vue-property-decorator' @Component({ name: 'neko-disconnected' }) - export default class extends Vue {} + export default class extends Vue { + // The overlay can show before the client settles on a state, and a viewer + // that just dropped is about to reconnect. + get message() { + return this.$t(`disconnected.${this.$accessor.disconnectState ?? 'reconnecting'}`) + } + } diff --git a/images/chromium-headful/client/src/locale/en-us.ts b/images/chromium-headful/client/src/locale/en-us.ts index 3c976c735..31c26deac 100644 --- a/images/chromium-headful/client/src/locale/en-us.ts +++ b/images/chromium-headful/client/src/locale/en-us.ts @@ -127,5 +127,7 @@ export const files = { } export const disconnected = { - message: 'Browser has been shut down and is no longer available', + gone: 'Browser has been shut down and is no longer available', + reconnecting: 'Connection to the browser was lost. Reconnecting...', + lost: 'Lost connection to the browser. Reload to try again.', } diff --git a/images/chromium-headful/client/src/neko/base.ts b/images/chromium-headful/client/src/neko/base.ts index 0b81c8105..12ffe0e9a 100644 --- a/images/chromium-headful/client/src/neko/base.ts +++ b/images/chromium-headful/client/src/neko/base.ts @@ -82,6 +82,18 @@ export const CONNECT_STAGE_TIMEOUT_MS = resolveConnectStageTimeoutMs( // prose. The three stages mean a bound expired; the rest never reached one. export type ConnectFailure = ConnectStage | 'unsupported' | 'peer' | 'server' +// A connected session that drops is reconnected rather than torn down: a +// network change or a relay that stops answering ends the peer while the +// browser keeps running, and a viewer left disconnected stops counting as +// activity, so the session idles out underneath it. Each attempt is a full +// staged connect, so the delays only space attempts out. They are an interim +// budget for riding out a network change, not a value tuned per region. +export const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 10000] + +// The live-view proxy closes the socket with this code when the browser session +// no longer exists, so there is nothing left to reconnect to. +export const SESSION_GONE_CLOSE_CODE = 4410 + export interface BaseEvents { info: (...message: any[]) => void warn: (...message: any[]) => void @@ -101,6 +113,12 @@ export abstract class BaseClient extends EventEmitter { protected _stage?: ConnectStage protected _everConnected = false protected _gaveUp = false + protected _sessionGone = false + // Counts attempts since the session last dropped; zero while connected or + // before the first connect succeeds. + protected _reconnectAttempt = 0 + protected _reconnectTimer?: number + protected _connectArgs?: [url: string, password: string, displayname: string] // Tagged where the failure happens, so a pre-connect disconnect is reported // with a reason an embedder can act on. Unset falls back to 'peer'. protected _failure?: ConnectFailure @@ -129,6 +147,14 @@ export abstract class BaseClient extends EventEmitter { return this.peerConnected && this.socketOpen } + get reconnecting() { + return typeof this._reconnectTimer !== 'undefined' + } + + get sessionGone() { + return this._sessionGone + } + public connect(url: string, password: string, displayname: string) { if (this._gaveUp) { this.emit('debug', `not reconnecting, already gave up`) @@ -149,6 +175,7 @@ export abstract class BaseClient extends EventEmitter { // Per connect attempt: a prior session's success must not suppress this // attempt's bound or make onDisconnected treat it as a dropped session. this._everConnected = false + this._connectArgs = [url, password, displayname] this._displayname = displayname this[EVENT.CONNECTING]() @@ -162,6 +189,9 @@ export abstract class BaseClient extends EventEmitter { this._ws.onopen = () => this.armStage('signaling') this._ws.onclose = (event) => { this.emit('debug', `websocket closed: code=${event.code}, reason=${event.reason}`) + if (event.code === SESSION_GONE_CLOSE_CODE) { + this._sessionGone = true + } this._failure = 'transport' this.onDisconnected(new Error('websocket closed')) } @@ -179,6 +209,11 @@ export abstract class BaseClient extends EventEmitter { } this._stage = undefined + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer) + this._reconnectTimer = undefined + } + if (this._ws_heartbeat) { clearInterval(this._ws_heartbeat) this._ws_heartbeat = undefined @@ -553,6 +588,7 @@ export abstract class BaseClient extends EventEmitter { } this._everConnected = true + this._reconnectAttempt = 0 this.emit('debug', `connected`) this[EVENT.CONNECTED]() @@ -590,8 +626,11 @@ export abstract class BaseClient extends EventEmitter { // The bound expiring is this connect's terminal event, so report it here and // let onDisconnected take the ordinary disconnect path rather than reporting - // a second event for the same failure. - this.reportFailure('KERNEL_CONNECTION_TIMEOUT', stage) + // a second event for the same failure. Only the last reconnect attempt is + // terminal; an earlier one leaves onDisconnected to schedule the next. + if (this._reconnectAttempt === 0 || this._reconnectAttempt === RECONNECT_DELAYS_MS.length) { + this.reportFailure('KERNEL_CONNECTION_TIMEOUT', stage) + } this.onDisconnected(new Error(`${stage} timeout`)) } @@ -613,16 +652,34 @@ export abstract class BaseClient extends EventEmitter { } protected onDisconnected(reason?: Error) { + const dropped = this._everConnected || this._reconnectAttempt > 0 + // A disconnect before any peer was established is a failed connect, not a // dropped session. disconnect() clears the bound below, so without this the // parent frame would hear nothing at all. - if (!this._gaveUp && !this._everConnected) { + if (!this._gaveUp && !dropped) { this.giveUp(this._failure ?? 'peer', reason ?? new Error('connection failed')) return } this.disconnect() this.emit('debug', `disconnected:`, reason) + + // The server ends a session on purpose (a kick, a replaced viewer), and a + // gone session has nothing to come back to; reconnecting would fight both. + if (dropped && !this._gaveUp && !this._sessionGone && this._failure !== 'server') { + if (this._reconnectAttempt < RECONNECT_DELAYS_MS.length) { + const delay = RECONNECT_DELAYS_MS[this._reconnectAttempt++] + this.emit('debug', `reconnecting in ${delay}ms, attempt ${this._reconnectAttempt}`) + this._reconnectTimer = window.setTimeout(() => { + this._reconnectTimer = undefined + this.connect(...this._connectArgs!) + }, delay) + } else { + this.reportFailure('KERNEL_CONNECTION_FAILED', this._failure ?? 'peer') + } + } + this[EVENT.DISCONNECTED](reason) } diff --git a/images/chromium-headful/client/src/neko/index.ts b/images/chromium-headful/client/src/neko/index.ts index a64a53bf9..c784bce56 100644 --- a/images/chromium-headful/client/src/neko/index.ts +++ b/images/chromium-headful/client/src/neko/index.ts @@ -112,6 +112,12 @@ export class NekoClient extends BaseClient implements EventEmitter { protected [EVENT.DISCONNECTED](reason?: Error) { this.cleanup() + if (this.reconnecting) { + this.$accessor.setDisconnectState('reconnecting') + return + } + this.$accessor.setDisconnectState(this.sessionGone ? 'gone' : 'lost') + this.$vue.$notify({ group: 'neko', type: 'error', @@ -166,6 +172,14 @@ export class NekoClient extends BaseClient implements EventEmitter { } protected [EVENT.SYSTEM.DISCONNECT]({ message }: SystemMessagePayload) { + // neko sends this as it stops, on a restart and on browser teardown alike, + // so it is not the server ending this viewer. The socket close that follows + // reconnects, and the proxy answers a reconnect to a torn-down browser with + // the session-gone code. + if (message == 'connection shutdown') { + return + } + if (message == 'kicked') { this.$accessor.logout() message = this.$vue.$t('connection.kicked') as string diff --git a/images/chromium-headful/client/src/store/index.ts b/images/chromium-headful/client/src/store/index.ts index 2bc6c91a0..b25f16c42 100644 --- a/images/chromium-headful/client/src/store/index.ts +++ b/images/chromium-headful/client/src/store/index.ts @@ -14,12 +14,18 @@ import * as settings from './settings' import * as client from './client' import * as emoji from './emoji' +// Why a viewer that was connected no longer is: a reconnect is pending, the +// attempts ran out with the browser possibly still running, or the browser +// session itself is gone. +export type DisconnectState = 'reconnecting' | 'lost' | 'gone' + export const state = () => ({ displayname: get('displayname', ''), password: get('password', ''), active: false, connecting: false, connected: false, + disconnectState: undefined as DisconnectState | undefined, locked: {} as Record, }) @@ -46,10 +52,15 @@ export const mutations = mutationTree(state, { state.connecting = true }, + setDisconnectState(state, disconnectState: DisconnectState) { + state.disconnectState = disconnectState + }, + setConnected(state, connected: boolean) { state.connected = connected state.connecting = false if (connected) { + state.disconnectState = undefined set('displayname', state.displayname) set('password', state.password) } diff --git a/images/chromium-headful/client/tests/connect-bound.test.ts b/images/chromium-headful/client/tests/connect-bound.test.ts index 6f294542a..cf820b441 100644 --- a/images/chromium-headful/client/tests/connect-bound.test.ts +++ b/images/chromium-headful/client/tests/connect-bound.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { BaseClient, CONNECT_STAGE_TIMEOUT_MS, resolveConnectStageTimeoutMs } from '../src/neko/base' +import { + BaseClient, + CONNECT_STAGE_TIMEOUT_MS, + RECONNECT_DELAYS_MS, + SESSION_GONE_CLOSE_CODE, + resolveConnectStageTimeoutMs, +} from '../src/neko/base' const posted: Record[] = [] const timers: { id: number; delay: number; run: () => void }[] = [] @@ -97,6 +103,21 @@ class TestClient extends BaseClient { this['_ws']!.readyState = FakeSocket.OPEN this['_ws']!.onopen() } + + closeSocket(code = 1006) { + this['_ws']!.onclose({ code, reason: '' } as CloseEvent) + } + + async establish() { + this.openSocket() + await this.provide() + this.connectPeer() + } + + failPeer() { + lastPeer!.iceConnectionState = 'failed' + this['_peer']!.oniceconnectionstatechange() + } } function setPeerConstructor(impl: () => unknown) { @@ -454,3 +475,118 @@ describe('live view connect failures', () => { }) }) }) + +// A connected session that drops is reconnected: a network change can end the +// peer while the browser keeps running, and a viewer that stays disconnected +// lets the session idle out underneath it. +describe('live view reconnects', () => { + async function droppedClient() { + const client = new TestClient() + client.connect('ws://host/ws', 'pw', 'kernel') + await client.establish() + const socket = client['_ws'] + client.failPeer() + return { client, socket } + } + + test('a dropped session reconnects after the first delay without telling the parent', async () => { + const { client, socket } = await droppedClient() + + expect(client.reconnecting).toBe(true) + expect(client['_ws']).toBeUndefined() + + runTimers(RECONNECT_DELAYS_MS[0]) + + expect(client.reconnecting).toBe(false) + expect(client['_ws']).toBeDefined() + expect(client['_ws']).not.toBe(socket) + expect(client['_ws']!.url).toBe('ws://host/ws?password=pw&username=kernel') + expect(posted).toEqual([]) + }) + + test('a reconnect that connects starts the next drop from the first delay', async () => { + const { client } = await droppedClient() + runTimers(RECONNECT_DELAYS_MS[0]) + client.closeSocket() + runTimers(RECONNECT_DELAYS_MS[1]) + await client.establish() + + expect(client.connectEvents).toBe(2) + + client.failPeer() + + expect(timers.map((timer) => timer.delay)).toEqual([RECONNECT_DELAYS_MS[0]]) + }) + + test('failed attempts back off, then report one failure and stop', async () => { + const { client } = await droppedClient() + + for (const delay of RECONNECT_DELAYS_MS) { + expect(timers.map((timer) => timer.delay)).toEqual([delay]) + runTimers(delay) + client.closeSocket() + } + + expect(client.reconnecting).toBe(false) + expect(timers).toEqual([]) + expect(posted).toEqual([ + { + type: 'KERNEL_CONNECTION_FAILED', + reason: 'transport', + iceConnectionState: 'disconnected', + connectionState: undefined, + signalingState: undefined, + socketOpen: false, + }, + ]) + }) + + test('an attempt that times out is retried, and only the last timeout is reported', async () => { + const { client } = await droppedClient() + + for (const delay of RECONNECT_DELAYS_MS) { + runTimers(delay) + runTimers(CONNECT_STAGE_TIMEOUT_MS.transport) + } + + expect(timers).toEqual([]) + expect(posted.map((m) => [m.type, m.reason])).toEqual([['KERNEL_CONNECTION_TIMEOUT', 'transport']]) + expect(client.reconnecting).toBe(false) + }) + + test('the proxy closing with the session-gone code stops reconnecting', async () => { + const { client } = await droppedClient() + runTimers(RECONNECT_DELAYS_MS[0]) + client.openSocket() + + client.closeSocket(SESSION_GONE_CLOSE_CODE) + + expect(client.sessionGone).toBe(true) + expect(client.reconnecting).toBe(false) + expect(timers).toEqual([]) + expect(posted).toEqual([]) + }) + + test('a server-initiated disconnect is not reconnected', async () => { + const client = new TestClient() + client.connect('ws://host/ws', 'pw', 'kernel') + await client.establish() + + client['_failure'] = 'server' + client['onDisconnected'](new Error('kicked')) + + expect(client.reconnecting).toBe(false) + expect(timers).toEqual([]) + }) + + test('a connect that never succeeded is reported, not reconnected', () => { + const client = new TestClient() + client.connect('ws://host/ws', 'pw', 'kernel') + client.openSocket() + + client.closeSocket() + + expect(client.reconnecting).toBe(false) + expect(posted.map((m) => m.type)).toEqual(['KERNEL_CONNECTION_FAILED']) + }) +}) diff --git a/images/chromium-headful/client/tests/disconnect-state.test.ts b/images/chromium-headful/client/tests/disconnect-state.test.ts new file mode 100644 index 000000000..2507d4dfc --- /dev/null +++ b/images/chromium-headful/client/tests/disconnect-state.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test' + +mock.module('~/store', () => ({ accessor: {} })) +const { NekoClient } = await import('../src/neko/index') + +const reset = { reset: mock() } +let accessor: { setConnected: ReturnType; setDisconnectState: ReturnType } & Record< + string, + unknown +> +let notify: ReturnType + +function client() { + const neko = new NekoClient() + neko['$accessor'] = accessor as any + neko['$vue'] = { $notify: notify, $t: (key: string) => key } as any + return neko +} + +beforeEach(() => { + accessor = { + setConnected: mock(), + setDisconnectState: mock(), + remote: reset, + user: reset, + video: reset, + chat: reset, + } + notify = mock() +}) + +// The overlay copy follows this state, so a viewer that is about to reconnect +// is never told the browser was shut down. +describe('disconnect state', () => { + test('a pending reconnect shows reconnecting and raises no notification', () => { + const neko = client() + neko['_reconnectTimer'] = 1 + + neko['DISCONNECTED'](new Error('peer failed')) + + expect(accessor.setDisconnectState).toHaveBeenCalledWith('reconnecting') + expect(notify).not.toHaveBeenCalled() + }) + + test('a gone session shows gone', () => { + const neko = client() + neko['_sessionGone'] = true + + neko['DISCONNECTED'](new Error('websocket closed')) + + expect(accessor.setDisconnectState).toHaveBeenCalledWith('gone') + expect(notify).toHaveBeenCalledTimes(1) + }) + + test('a disconnect with no reconnect left shows lost, not gone', () => { + const neko = client() + + neko['DISCONNECTED'](new Error('websocket closed')) + + expect(accessor.setDisconnectState).toHaveBeenCalledWith('lost') + }) +}) + +describe('server disconnect messages', () => { + test('neko shutting down is left to the socket close, which reconnects', () => { + const neko = client() + const swal = mock() + neko['$vue'] = { $swal: swal } as any + neko['onDisconnected'] = mock() + + neko['system/disconnect']({ message: 'connection shutdown' }) + + expect(neko['_failure']).toBeUndefined() + expect(neko['onDisconnected']).not.toHaveBeenCalled() + expect(swal).not.toHaveBeenCalled() + }) + + test('any other reason ends the viewer as a server disconnect', () => { + const neko = client() + neko['$vue'] = { $swal: mock(), $t: (key: string) => key } as any + neko['onDisconnected'] = mock() + + neko['system/disconnect']({ message: 'connection replaced' }) + + expect(neko['_failure']).toBe('server') + expect(neko['onDisconnected']).toHaveBeenCalledTimes(1) + }) +})