211 lines
6.8 KiB
TypeScript
211 lines
6.8 KiB
TypeScript
import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
|
|
import { releaseRemoteRuntimePreparedRequest } from './remote-runtime-prepared-request-admission'
|
|
import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames'
|
|
import type { RuntimeRpcResponse } from './runtime-rpc-envelope'
|
|
import type {
|
|
RemoteRuntimeSharedConnectionDiagnostics,
|
|
SharedControlConnectionState,
|
|
SharedControlLogicalSubscription,
|
|
SharedControlPendingRequest,
|
|
SharedControlReadyWaiter
|
|
} from './remote-runtime-shared-control-types'
|
|
import { getSubscriptionId, isEndResult } from './remote-runtime-shared-control-protocol'
|
|
import { withReconnectJitter } from './reconnect-jitter'
|
|
import { tagRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay'
|
|
|
|
export function buildSharedControlDiagnostics(args: {
|
|
state: SharedControlConnectionState
|
|
reconnecting: boolean
|
|
pendingRequestCount: number
|
|
subscriptionCount: number
|
|
reconnectAttempt: number
|
|
lastConnectedAt: number | null
|
|
lastClose: { code: number; reason: string } | null
|
|
lastError: string | null
|
|
}): RemoteRuntimeSharedConnectionDiagnostics {
|
|
return {
|
|
state: args.reconnecting ? 'reconnecting' : args.state,
|
|
pendingRequestCount: args.pendingRequestCount,
|
|
subscriptionCount: args.subscriptionCount,
|
|
reconnectAttempt: args.reconnectAttempt,
|
|
lastConnectedAt: args.lastConnectedAt,
|
|
lastClose: args.lastClose,
|
|
lastError: args.lastError
|
|
}
|
|
}
|
|
|
|
export function rejectSharedControlPendingRequest(
|
|
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>,
|
|
requestId: string,
|
|
error: Error
|
|
): void {
|
|
const pending = pendingRequests.get(requestId)
|
|
if (!pending) {
|
|
return
|
|
}
|
|
pendingRequests.delete(requestId)
|
|
clearTimeout(pending.timeout)
|
|
releaseRemoteRuntimePreparedRequest(pending)
|
|
pending.reject(error)
|
|
}
|
|
|
|
export function resolveSharedControlPendingResponse(
|
|
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>,
|
|
requestId: string,
|
|
response: RuntimeRpcResponse<unknown>
|
|
): void {
|
|
const pending = pendingRequests.get(requestId)
|
|
if (!pending) {
|
|
return
|
|
}
|
|
pendingRequests.delete(requestId)
|
|
clearTimeout(pending.timeout)
|
|
releaseRemoteRuntimePreparedRequest(pending)
|
|
pending.resolve(response)
|
|
}
|
|
|
|
export function refreshSharedControlPendingRequestTimeouts(
|
|
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>
|
|
): void {
|
|
for (const pending of pendingRequests.values()) {
|
|
// Why: only long-poll requests opted into keepalive refresh; refreshing an
|
|
// ordinary short RPC would keep a genuinely-stuck server call alive forever.
|
|
if (!pending.refreshTimeoutOnKeepalive) {
|
|
continue
|
|
}
|
|
const timeout = pending.timeout as ReturnType<typeof setTimeout> & { refresh?: () => void }
|
|
timeout.refresh?.()
|
|
}
|
|
}
|
|
|
|
export function rejectAllSharedControlPendingRequests(
|
|
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>,
|
|
error?: Error
|
|
): void {
|
|
const closeError = error ?? remoteRuntimeUnavailableError()
|
|
for (const [requestId, pending] of pendingRequests) {
|
|
clearTimeout(pending.timeout)
|
|
pendingRequests.delete(requestId)
|
|
releaseRemoteRuntimePreparedRequest(pending)
|
|
pending.reject(closeError)
|
|
}
|
|
}
|
|
|
|
export function markSharedControlSubscriptionsUnsent(
|
|
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
|
): void {
|
|
for (const subscription of subscriptions.values()) {
|
|
subscription.sent = false
|
|
}
|
|
}
|
|
|
|
export function finishSharedControlSubscription(
|
|
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>,
|
|
subscription: SharedControlLogicalSubscription<unknown>,
|
|
notifyClose: boolean,
|
|
error?: RemoteRuntimeClientError
|
|
): void {
|
|
if (subscription.closed) {
|
|
return
|
|
}
|
|
subscription.closed = true
|
|
subscriptions.delete(subscription.requestId)
|
|
if (error) {
|
|
subscription.callbacks.onError(error)
|
|
}
|
|
if (notifyClose) {
|
|
subscription.callbacks.onClose?.()
|
|
}
|
|
}
|
|
|
|
export function resolveSharedControlReadyWaiters(waiters: SharedControlReadyWaiter[]): void {
|
|
for (const waiter of waiters.splice(0)) {
|
|
waiter.resolve()
|
|
}
|
|
}
|
|
|
|
export function rejectSharedControlReadyWaiters(
|
|
waiters: SharedControlReadyWaiter[],
|
|
error: Error
|
|
): void {
|
|
for (const waiter of waiters.splice(0)) {
|
|
waiter.reject(error)
|
|
}
|
|
}
|
|
|
|
export function handleSharedControlSubscriptionResponse(
|
|
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>,
|
|
subscription: SharedControlLogicalSubscription<unknown>,
|
|
response: RuntimeRpcResponse<unknown>
|
|
): void {
|
|
// The replay window ends on either success or error. An error created no
|
|
// remote subscription, so a later close must finish locally instead of
|
|
// waiting forever for an id that will never arrive.
|
|
subscription.awaitingResubscribe = false
|
|
if (!response.ok) {
|
|
subscription.sent = false
|
|
}
|
|
if (response.ok) {
|
|
const subscriptionId = getSubscriptionId(response.result)
|
|
if (subscriptionId) {
|
|
subscription.remoteSubscriptionId = subscriptionId
|
|
}
|
|
}
|
|
let delivered = response
|
|
if (subscription.pendingReplayTag) {
|
|
subscription.pendingReplayTag = false
|
|
if (response.ok) {
|
|
delivered = tagRuntimeSubscriptionReplayResponse(response)
|
|
}
|
|
}
|
|
subscription.callbacks.onResponse(delivered)
|
|
if (response.ok && isEndResult(response.result)) {
|
|
finishSharedControlSubscription(subscriptions, subscription, false)
|
|
}
|
|
}
|
|
|
|
export function closeSharedControlSocketState(args: {
|
|
readyWaiters: SharedControlReadyWaiter[]
|
|
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>
|
|
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
|
socketCleanup: (() => void) | null
|
|
ws: { close: () => void } | null
|
|
error?: Error
|
|
preserveReadyWaitersAndPendingRequests?: boolean
|
|
}): void {
|
|
if (!args.preserveReadyWaitersAndPendingRequests) {
|
|
rejectSharedControlReadyWaiters(
|
|
args.readyWaiters,
|
|
args.error ?? remoteRuntimeUnavailableError()
|
|
)
|
|
rejectAllSharedControlPendingRequests(args.pendingRequests, args.error)
|
|
}
|
|
markSharedControlSubscriptionsUnsent(args.subscriptions)
|
|
try {
|
|
args.socketCleanup?.()
|
|
args.ws?.close()
|
|
} catch {
|
|
// Best-effort cleanup of remote runtime control socket.
|
|
}
|
|
}
|
|
|
|
export function scheduleSharedControlReconnect(args: {
|
|
current: ReturnType<typeof setTimeout> | null
|
|
intentionallyClosed: boolean
|
|
reconnectAttempt: number
|
|
delaysMs: readonly number[]
|
|
open: () => void
|
|
}): { timer: ReturnType<typeof setTimeout> | null; reconnectAttempt: number } {
|
|
if (args.current || args.intentionallyClosed) {
|
|
return { timer: args.current, reconnectAttempt: args.reconnectAttempt }
|
|
}
|
|
const delay = withReconnectJitter(
|
|
args.delaysMs[Math.min(args.reconnectAttempt, args.delaysMs.length - 1)]
|
|
)
|
|
const timer = setTimeout(args.open, delay)
|
|
if (typeof timer.unref === 'function') {
|
|
timer.unref()
|
|
}
|
|
return { timer, reconnectAttempt: args.reconnectAttempt + 1 }
|
|
}
|