diff --git a/images/chromium-headful/client/src/components/disconnected.vue b/images/chromium-headful/client/src/components/disconnected.vue index db45cc78b..41930632e 100644 --- a/images/chromium-headful/client/src/components/disconnected.vue +++ b/images/chromium-headful/client/src/components/disconnected.vue @@ -1,7 +1,7 @@ - {{ $t('disconnected.message') }} + {{ sessionGone ? $t('disconnected.gone') : $t('disconnected.lost') }} @@ -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 { + // Only a session-gone close means the browser is gone. Anything else may be + // a network change with the browser still running. + get sessionGone() { + return this.$accessor.sessionGone + } + } diff --git a/images/chromium-headful/client/src/locale/en-us.ts b/images/chromium-headful/client/src/locale/en-us.ts index 3c976c735..494ec1b0a 100644 --- a/images/chromium-headful/client/src/locale/en-us.ts +++ b/images/chromium-headful/client/src/locale/en-us.ts @@ -127,5 +127,6 @@ 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', + lost: 'Lost connection to the browser. Reload the page to reconnect.', } diff --git a/images/chromium-headful/client/src/neko/base.ts b/images/chromium-headful/client/src/neko/base.ts index 0b81c8105..16bf97179 100644 --- a/images/chromium-headful/client/src/neko/base.ts +++ b/images/chromium-headful/client/src/neko/base.ts @@ -82,6 +82,10 @@ 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' +// The live-view proxy closes the socket with this code when the browser session +// no longer exists. Any other close leaves the browser possibly still running. +export const SESSION_GONE_CLOSE_CODE = 4410 + export interface BaseEvents { info: (...message: any[]) => void warn: (...message: any[]) => void @@ -101,6 +105,7 @@ export abstract class BaseClient extends EventEmitter { protected _stage?: ConnectStage protected _everConnected = false protected _gaveUp = false + protected _sessionGone = false // 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 +134,10 @@ export abstract class BaseClient extends EventEmitter { return this.peerConnected && this.socketOpen } + get sessionGone() { + return this._sessionGone + } + public connect(url: string, password: string, displayname: string) { if (this._gaveUp) { this.emit('debug', `not reconnecting, already gave up`) @@ -162,6 +171,7 @@ 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}`) + this._sessionGone = event.code === SESSION_GONE_CLOSE_CODE this._failure = 'transport' this.onDisconnected(new Error('websocket closed')) } diff --git a/images/chromium-headful/client/src/neko/index.ts b/images/chromium-headful/client/src/neko/index.ts index a64a53bf9..bbdce496c 100644 --- a/images/chromium-headful/client/src/neko/index.ts +++ b/images/chromium-headful/client/src/neko/index.ts @@ -111,6 +111,7 @@ export class NekoClient extends BaseClient implements EventEmitter { protected [EVENT.DISCONNECTED](reason?: Error) { this.cleanup() + this.$accessor.setSessionGone(this.sessionGone) this.$vue.$notify({ group: 'neko', @@ -166,6 +167,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 does not say whether the browser is gone. The socket close that + // follows does: the live-view proxy closes with the session-gone code once + // the session has ended. + 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..ad5dfc006 100644 --- a/images/chromium-headful/client/src/store/index.ts +++ b/images/chromium-headful/client/src/store/index.ts @@ -20,6 +20,7 @@ export const state = () => ({ active: false, connecting: false, connected: false, + sessionGone: false, locked: {} as Record, }) @@ -46,6 +47,10 @@ export const mutations = mutationTree(state, { state.connecting = true }, + setSessionGone(state, sessionGone: boolean) { + state.sessionGone = sessionGone + }, + setConnected(state, connected: boolean) { state.connected = connected state.connecting = false diff --git a/images/chromium-headful/client/tests/connect-bound.test.ts b/images/chromium-headful/client/tests/connect-bound.test.ts index 6f294542a..4074953b6 100644 --- a/images/chromium-headful/client/tests/connect-bound.test.ts +++ b/images/chromium-headful/client/tests/connect-bound.test.ts @@ -1,5 +1,10 @@ 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, + SESSION_GONE_CLOSE_CODE, + resolveConnectStageTimeoutMs, +} from '../src/neko/base' const posted: Record[] = [] const timers: { id: number; delay: number; run: () => void }[] = [] @@ -454,3 +459,38 @@ describe('live view connect failures', () => { }) }) }) + +// A connected viewer that drops is not reconnected: the overlay asks for a reload. +// Only the proxy's session-gone close means the browser itself is gone. +describe('live view drops after connecting', () => { + async function connectedClient() { + const client = new TestClient() + client.connect('ws://host/ws', 'pw', 'kernel') + client.openSocket() + await client.provide() + client.connectPeer() + return client + } + + test('a session-gone close marks the session gone and opens no new socket', async () => { + const client = await connectedClient() + + client['_ws']!.onclose({ code: SESSION_GONE_CLOSE_CODE, reason: '' } as CloseEvent) + + expect(client.sessionGone).toBe(true) + expect(client['_ws']).toBeUndefined() + expect(timers).toEqual([]) + expect(posted).toEqual([]) + }) + + test('any other close leaves the session not known to be gone', async () => { + const client = await connectedClient() + + client['_ws']!.onclose({ code: 1006, reason: '' } as CloseEvent) + + expect(client.sessionGone).toBe(false) + expect(client['_ws']).toBeUndefined() + expect(timers).toEqual([]) + expect(client.reasons.map((r) => r?.message)).toEqual(['websocket closed']) + }) +}) 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..d0e4a088b --- /dev/null +++ b/images/chromium-headful/client/tests/disconnect-state.test.ts @@ -0,0 +1,68 @@ +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: Record + +function client() { + const neko = new NekoClient() + neko['$accessor'] = accessor as any + neko['$vue'] = { $notify: mock(), $swal: mock(), $t: (key: string) => key } as any + return neko +} + +beforeEach(() => { + accessor = { + setConnected: mock(), + setSessionGone: mock(), + remote: reset, + user: reset, + video: reset, + chat: reset, + } +}) + +// The overlay says the browser shut down only when the proxy said so. +describe('disconnect overlay state', () => { + test('a session-gone close is recorded as gone', () => { + const neko = client() + neko['_sessionGone'] = true + + neko['DISCONNECTED'](new Error('websocket closed')) + + expect(accessor.setSessionGone).toHaveBeenCalledWith(true) + }) + + test('any other disconnect is recorded as not gone', () => { + const neko = client() + + neko['DISCONNECTED'](new Error('peer failed')) + + expect(accessor.setSessionGone).toHaveBeenCalledWith(false) + }) +}) + +describe('server disconnect messages', () => { + test('neko shutting down is left to the socket close', () => { + const neko = client() + neko['onDisconnected'] = mock() + + neko['system/disconnect']({ message: 'connection shutdown' }) + + expect(neko['_failure']).toBeUndefined() + expect(neko['onDisconnected']).not.toHaveBeenCalled() + expect(neko['$vue'].$swal).not.toHaveBeenCalled() + }) + + test('any other reason ends the viewer as a server disconnect', () => { + const neko = client() + neko['onDisconnected'] = mock() + + neko['system/disconnect']({ message: 'connection replaced' }) + + expect(neko['_failure']).toBe('server') + expect(neko['onDisconnected']).toHaveBeenCalledTimes(1) + }) +})