Add client ACK

This commit is contained in:
Kenneth Allen 2021-10-15 19:00:18 +11:00
parent 9fc770d086
commit e96fbc27ba
4 changed files with 68 additions and 19 deletions

View File

@ -1,5 +1,5 @@
import { ExponentialBackoff, Websocket, WebsocketBuilder } from 'websocket-ts' import { ExponentialBackoff, Websocket, WebsocketBuilder } from 'websocket-ts'
import { ActionClientMessage, ActionServerMessage, ConfirmServerMessage, HandshakeClientMessage, HandshakeServerMessage, ServerMessage, KetchupSynced } from 'ketchup-common' import { ActionClientMessage, ActionServerMessage, ConfirmServerMessage, HandshakeClientMessage, HandshakeServerMessage, ServerMessage, KetchupSynced, HeartbeatClientMessage } from 'ketchup-common'
import EventEmitter from 'events' import EventEmitter from 'events'
export default class KetchupClient<State, Action> extends EventEmitter { export default class KetchupClient<State, Action> extends EventEmitter {
@ -7,11 +7,13 @@ export default class KetchupClient<State, Action> extends EventEmitter {
synced?: KetchupSynced<State, Action> synced?: KetchupSynced<State, Action>
clientId?: number clientId?: number
lastTimestamp?: number lastTimestamp?: number
scheduledAck?: NodeJS.Timeout
constructor( constructor(
url: string, url: string,
readonly reducer: (state: State, action: Action) => State, readonly reducer: (state: State, action: Action) => State,
private readonly timeSource = Date.now private readonly timeSource = Date.now,
private readonly ackDebounce = 5000,
) { ) {
super() super()
this.ws = new WebsocketBuilder(url) this.ws = new WebsocketBuilder(url)
@ -19,6 +21,7 @@ export default class KetchupClient<State, Action> extends EventEmitter {
this.synced = undefined this.synced = undefined
this.clientId = undefined this.clientId = undefined
this.lastTimestamp = undefined this.lastTimestamp = undefined
this.cancelAck()
}) })
.onOpen(ws => { .onOpen(ws => {
ws.send(JSON.stringify({ ws.send(JSON.stringify({
@ -41,16 +44,19 @@ export default class KetchupClient<State, Action> extends EventEmitter {
this.synced = new KetchupSynced(hMsg.confState, hMsg.confBefore, this.reducer, hMsg.unconfActions) this.synced = new KetchupSynced(hMsg.confState, hMsg.confBefore, this.reducer, hMsg.unconfActions)
this.synced.on('projection', proj => this.emit('projection', proj)) this.synced.on('projection', proj => this.emit('projection', proj))
this.emit('projection', this.synced.projState) this.emit('projection', this.synced.projState)
this.considerAck()
break break
case 'action': case 'action':
this.synced!.tryProcess(msg as ActionServerMessage<Action>) this.synced!.tryProcess(msg as ActionServerMessage<Action>)
this.considerAck()
break break
case 'confirm': case 'confirm':
this.synced!.confirmBefore((msg as ConfirmServerMessage).confBefore) this.synced!.confirmBefore((msg as ConfirmServerMessage).confBefore)
this.considerAck()
break break
default: default:
throw new Error(`Unknown message from server: ${msg}`) throw new Error(`Unknown message from server: ${msg}`)
} }
@ -63,7 +69,8 @@ export default class KetchupClient<State, Action> extends EventEmitter {
timestamp = this.lastTimestamp + 1 timestamp = this.lastTimestamp + 1
} }
if (this.synced?.tryProcess({ timestamp, action, clientId: this.clientId! })) { if (this.synced!.tryProcess({ timestamp, action, clientId: this.clientId! })) {
this.cancelAck()
this.lastTimestamp = timestamp this.lastTimestamp = timestamp
this.ws.send(JSON.stringify({ this.ws.send(JSON.stringify({
message: 'action', message: 'action',
@ -72,4 +79,38 @@ export default class KetchupClient<State, Action> extends EventEmitter {
} as ActionClientMessage<Action>)) } as ActionClientMessage<Action>))
} }
} }
considerAck() {
if (this.scheduledAck) {
return
}
let oldestUnconfTimestamp = this.synced?.oldestUnconfTimestamp
if (oldestUnconfTimestamp !== undefined && this.lastTimestamp !== undefined) {
let behind = oldestUnconfTimestamp - this.lastTimestamp
if (behind > this.ackDebounce) { // Way behind, ACK now
this.sendAck()
} else if (behind >= 0) { // Somewhat behind, ACK soon if we don't send an action
this.scheduledAck = setTimeout(
() => { this.sendAck(); this.scheduledAck = undefined },
this.ackDebounce - behind,
)
}
}
}
sendAck() {
this.lastTimestamp = this.timeSource()
this.ws.send(JSON.stringify({
message: 'heartbeat',
timestamp: this.lastTimestamp,
} as HeartbeatClientMessage))
}
cancelAck() {
if (this.scheduledAck) {
clearTimeout(this.scheduledAck)
this.scheduledAck = undefined
}
}
} }

View File

@ -88,7 +88,7 @@ export class KetchupSynced<State, Action> extends EventEmitter {
while (lo < hi) { while (lo < hi) {
const mid = Math.floor((lo + hi) / 2) const mid = Math.floor((lo + hi) / 2)
const comp = compareTimed(x, this.projs[mid]) const comp = compareTimed(x, this.projs[mid])
if (comp === 0 ) { if (comp === 0) {
return { idx: mid, exact: true } return { idx: mid, exact: true }
} else if (comp > 0) { } else if (comp > 0) {
lo = mid + 1 lo = mid + 1
@ -98,6 +98,10 @@ export class KetchupSynced<State, Action> extends EventEmitter {
} }
return { idx: lo, exact: false } return { idx: lo, exact: false }
} }
get oldestUnconfTimestamp() {
return this.projs?.[0]?.timestamp
}
} }
export interface ActionServerMessage<Action> extends UnconfAction<Action> { export interface ActionServerMessage<Action> extends UnconfAction<Action> {
@ -128,6 +132,11 @@ export interface HandshakeClientMessage {
message: 'handshake' message: 'handshake'
timestamp: number timestamp: number
} }
export interface HeartbeatClientMessage {
message: 'heartbeat'
timestamp: number
}
export type ClientMessage<State, Action> = export type ClientMessage<State, Action> =
ActionClientMessage<Action> | ActionClientMessage<Action> |
HandshakeClientMessage HandshakeClientMessage |
HeartbeatClientMessage

View File

@ -1,6 +1,6 @@
import WebSocket from 'ws' import WebSocket from 'ws'
import min from 'lodash/min' import min from 'lodash/min'
import { ActionClientMessage, KetchupSynced as KetchupSynced, ActionServerMessage, HandshakeServerMessage, ConfirmServerMessage, ClientMessage, HandshakeClientMessage } from 'ketchup-common' import { ActionClientMessage, KetchupSynced as KetchupSynced, ActionServerMessage, HandshakeServerMessage, ConfirmServerMessage, ClientMessage, HandshakeClientMessage, HeartbeatClientMessage } from 'ketchup-common'
interface RemoteClient { interface RemoteClient {
syncedAt?: number syncedAt?: number
@ -15,11 +15,11 @@ export default class KetchupServer<State, Action> {
constructor( constructor(
initState: State, initState: State,
reducer: (state: State, action: Action) => State, reducer: (state: State, action: Action) => State,
) { ) {
this.synced = new KetchupSynced<State, Action>( this.synced = new KetchupSynced<State, Action>(
initState, initState,
Date.now() - 1000, // TODO: Handle clients joining with clocks running significantly behind Date.now() - (1000 * 60 * 60 * 24), // TODO: Handle clients joining with clocks running significantly behind
reducer, reducer,
) )
} }
@ -55,6 +55,11 @@ export default class KetchupServer<State, Action> {
} }
break break
case 'heartbeat':
client.syncedAt = (msg as HeartbeatClientMessage).timestamp
this.considerConfirm()
break
default: default:
throw new Error(`Invalid message from client: ${JSON.stringify(msg)}`) throw new Error(`Invalid message from client: ${JSON.stringify(msg)}`)
} }
@ -93,7 +98,6 @@ export default class KetchupServer<State, Action> {
private considerConfirm() { private considerConfirm() {
const oldestSync = min([...this.clients.values()].map(c => c.syncedAt)) const oldestSync = min([...this.clients.values()].map(c => c.syncedAt))
if (oldestSync !== undefined && this.synced.confirmBefore(oldestSync) > 0) { if (oldestSync !== undefined && this.synced.confirmBefore(oldestSync) > 0) {
console.log('Confirming before', oldestSync)
const msg = JSON.stringify({ const msg = JSON.stringify({
message: 'confirm', message: 'confirm',
confBefore: this.synced.confBefore, confBefore: this.synced.confBefore,
@ -101,8 +105,6 @@ export default class KetchupServer<State, Action> {
for (const client of this.clients.values()) { for (const client of this.clients.values()) {
client.ws.send(msg) client.ws.send(msg)
} }
} else {
console.log('Not confirming before', oldestSync)
} }
} }
} }

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import useKetchup from 'ketchup-react' import useKetchup from 'ketchup-react'
import { addPlayerAction, reducer, removePlayerAction, resetAction } from 'wonders-common' import { addPlayerAction, reducer, removePlayerAction, resetAction } from 'wonders-common'
import './App.css'; import './App.css';
@ -10,9 +10,6 @@ import Row from 'react-bootstrap/Row'
export default function App() { export default function App() {
const [state, dispatch] = useKetchup('ws://localhost:4000', reducer) const [state, dispatch] = useKetchup('ws://localhost:4000', reducer)
useEffect(() => {
console.debug('State', state)
}, [state])
const [user, setUser] = useState<string>() const [user, setUser] = useState<string>()
function selectUser(name?: string) { function selectUser(name?: string) {
@ -29,10 +26,10 @@ export default function App() {
} }
return <Container fluid> return <Container fluid>
<UserSelector user={user} selectUser={selectUser} removeUser={removeUser} users={state?.players.map(p => p.name) ?? []} locked={state !== undefined && state.stage !== 'starting'}/> <UserSelector user={user} selectUser={selectUser} removeUser={removeUser} users={state?.players.map(p => p.name) ?? []} locked={state !== undefined && state.stage !== 'starting'} />
{state {state
? <> ? <>
<Game state={state} dispatch={dispatch} playerName={user}/> <Game state={state} dispatch={dispatch} playerName={user} />
<button onClick={() => window.confirm('Reset game?') && dispatch(resetAction())}> Reset</button> <button onClick={() => window.confirm('Reset game?') && dispatch(resetAction())}> Reset</button>
</> </>
: <Row>Loading...</Row> : <Row>Loading...</Row>