From 0e05bf34c2879b29c3c2062200aaee5f270bbd01 Mon Sep 17 00:00:00 2001 From: Tanmay Sardesai Date: Tue, 27 Oct 2026 04:56:03 +0000 Subject: [PATCH 1/2] Ask for a reload when the live view drops, and only say shut down when it is --- .../client/src/components/disconnected.vue | 31 ++++++++- .../client/src/locale/en-us.ts | 4 +- .../chromium-headful/client/src/neko/base.ts | 10 +++ .../chromium-headful/client/src/neko/index.ts | 9 +++ .../client/src/store/index.ts | 5 ++ .../client/tests/connect-bound.test.ts | 42 +++++++++++- .../client/tests/disconnect-state.test.ts | 68 +++++++++++++++++++ 7 files changed, 165 insertions(+), 4 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..05a5042ac 100644 --- a/images/chromium-headful/client/src/components/disconnected.vue +++ b/images/chromium-headful/client/src/components/disconnected.vue @@ -1,7 +1,8 @@ @@ -17,8 +18,10 @@ .message-container { display: flex; + flex-direction: column; justify-content: center; align-items: center; + gap: 16px; padding: 20px; .message { @@ -26,6 +29,19 @@ color: $text-muted; text-align: center; } + + .reload { + min-height: 44px; + padding: 0 20px; + border: 0; + border-radius: 22px; + background: rgba($color: #fff, $alpha: 0.15); + color: #fff; + font-size: 15px; + font-weight: 600; + cursor: pointer; + touch-action: manipulation; + } } } @@ -34,5 +50,16 @@ 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, so offer a reload rather + // than claiming it shut down. + get sessionGone() { + return this.$accessor.sessionGone + } + + reload() { + location.reload() + } + } diff --git a/images/chromium-headful/client/src/locale/en-us.ts b/images/chromium-headful/client/src/locale/en-us.ts index 3c976c735..c18fa0335 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', + lost: 'Lost connection to the browser', + reload: 'Reload', } 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) + }) +}) From 8496fdf5f1fee06c0404fdde86b5cf459f21dedb Mon Sep 17 00:00:00 2001 From: Tanmay Sardesai Date: Tue, 27 Oct 2026 07:23:45 +0000 Subject: [PATCH 2/2] Drop the reload button and ask for a page reload in the message --- .../client/src/components/disconnected.vue | 23 +------------------ .../client/src/locale/en-us.ts | 3 +-- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/images/chromium-headful/client/src/components/disconnected.vue b/images/chromium-headful/client/src/components/disconnected.vue index 05a5042ac..41930632e 100644 --- a/images/chromium-headful/client/src/components/disconnected.vue +++ b/images/chromium-headful/client/src/components/disconnected.vue @@ -2,7 +2,6 @@
{{ sessionGone ? $t('disconnected.gone') : $t('disconnected.lost') }} -
@@ -18,10 +17,8 @@ .message-container { display: flex; - flex-direction: column; justify-content: center; align-items: center; - gap: 16px; padding: 20px; .message { @@ -29,19 +26,6 @@ color: $text-muted; text-align: center; } - - .reload { - min-height: 44px; - padding: 0 20px; - border: 0; - border-radius: 22px; - background: rgba($color: #fff, $alpha: 0.15); - color: #fff; - font-size: 15px; - font-weight: 600; - cursor: pointer; - touch-action: manipulation; - } } } @@ -52,14 +36,9 @@ @Component({ name: 'neko-disconnected' }) 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, so offer a reload rather - // than claiming it shut down. + // a network change with the browser still running. get sessionGone() { return this.$accessor.sessionGone } - - reload() { - location.reload() - } } diff --git a/images/chromium-headful/client/src/locale/en-us.ts b/images/chromium-headful/client/src/locale/en-us.ts index c18fa0335..494ec1b0a 100644 --- a/images/chromium-headful/client/src/locale/en-us.ts +++ b/images/chromium-headful/client/src/locale/en-us.ts @@ -128,6 +128,5 @@ export const files = { export const disconnected = { gone: 'Browser has been shut down and is no longer available', - lost: 'Lost connection to the browser', - reload: 'Reload', + lost: 'Lost connection to the browser. Reload the page to reconnect.', }