Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions images/chromium-headful/client/src/components/disconnected.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div class="disconnected-overlay">
<div class="message-container">
<span class="message">{{ $t('disconnected.message') }}</span>
<span class="message">{{ sessionGone ? $t('disconnected.gone') : $t('disconnected.lost') }}</span>
</div>
</div>
</template>
Expand Down Expand Up @@ -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
}
}
</script>
3 changes: 2 additions & 1 deletion images/chromium-headful/client/src/locale/en-us.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
}
10 changes: 10 additions & 0 deletions images/chromium-headful/client/src/neko/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -101,6 +105,7 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
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
Expand Down Expand Up @@ -129,6 +134,10 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
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`)
Expand Down Expand Up @@ -162,6 +171,7 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
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'))
}
Expand Down
9 changes: 9 additions & 0 deletions images/chromium-headful/client/src/neko/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {

protected [EVENT.DISCONNECTED](reason?: Error) {
this.cleanup()
this.$accessor.setSessionGone(this.sessionGone)

this.$vue.$notify({
group: 'neko',
Expand Down Expand Up @@ -166,6 +167,14 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
}

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
Expand Down
5 changes: 5 additions & 0 deletions images/chromium-headful/client/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const state = () => ({
active: false,
connecting: false,
connected: false,
sessionGone: false,
locked: {} as Record<string, boolean>,
})

Expand All @@ -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
Expand Down
42 changes: 41 additions & 1 deletion images/chromium-headful/client/tests/connect-bound.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>[] = []
const timers: { id: number; delay: number; run: () => void }[] = []
Expand Down Expand Up @@ -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'])
})
})
68 changes: 68 additions & 0 deletions images/chromium-headful/client/tests/disconnect-state.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>

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)
})
})
Loading