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">{{ message }}</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 {
// 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'}`)
}
}
</script>
4 changes: 3 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,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.',
}
63 changes: 60 additions & 3 deletions images/chromium-headful/client/src/neko/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -101,6 +113,12 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
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
Expand Down Expand Up @@ -129,6 +147,14 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
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`)
Expand All @@ -149,6 +175,7 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
// 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]()

Expand All @@ -162,6 +189,9 @@ 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}`)
if (event.code === SESSION_GONE_CLOSE_CODE) {
this._sessionGone = true
}
this._failure = 'transport'
this.onDisconnected(new Error('websocket closed'))
}
Expand All @@ -179,6 +209,11 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
}
this._stage = undefined

if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer)
this._reconnectTimer = undefined
}

if (this._ws_heartbeat) {
clearInterval(this._ws_heartbeat)
this._ws_heartbeat = undefined
Expand Down Expand Up @@ -553,6 +588,7 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
}

this._everConnected = true
this._reconnectAttempt = 0

this.emit('debug', `connected`)
this[EVENT.CONNECTED]()
Expand Down Expand Up @@ -590,8 +626,11 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {

// 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`))
}

Expand All @@ -613,16 +652,34 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
}

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

Expand Down
14 changes: 14 additions & 0 deletions images/chromium-headful/client/src/neko/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
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',
Expand Down Expand Up @@ -166,6 +172,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 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
Expand Down
11 changes: 11 additions & 0 deletions images/chromium-headful/client/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('displayname', ''),
password: get<string>('password', ''),
active: false,
connecting: false,
connected: false,
disconnectState: undefined as DisconnectState | undefined,
locked: {} as Record<string, boolean>,
})

Expand All @@ -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)
}
Expand Down
138 changes: 137 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,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<string, any>[] = []
const timers: { id: number; delay: number; run: () => void }[] = []
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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'])
})
})
Loading
Loading