mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
fix(remote): stabilize shared control and terminal parking (#11656)
* fix(remote): stabilize shared control and terminal parking * fix(remote): harden parking review edge cases * fix(terminal): restore parked local floating buffer * fix(ci): drop superseded paired parking evidence * fix(terminal): preserve floating park watchers * fix(ci): include web client in paired e2e artifact * fix(ci): reuse renderer build for paired e2e
This commit is contained in:
@@ -12,10 +12,12 @@ import {
|
||||
} from './remote-runtime-shared-control-ready'
|
||||
import { SharedControlReconnectScheduler } from './remote-runtime-shared-control-reconnect'
|
||||
import { requestSharedControl } from './remote-runtime-shared-control-requests'
|
||||
import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability'
|
||||
import * as sharedControlState from './remote-runtime-shared-control-state'
|
||||
import * as sharedControlSend from './remote-runtime-shared-control-send'
|
||||
import { closeSharedControlSocket } from './remote-runtime-shared-control-socket-close'
|
||||
import { closeSharedControlConnectionSubscription } from './remote-runtime-shared-control-subscription-close'
|
||||
import type { RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness'
|
||||
import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions'
|
||||
import { startSharedControlSubscription } from './remote-runtime-shared-control-subscription-start'
|
||||
@@ -43,6 +45,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
private lastError: string | null = null
|
||||
private readonly pendingRequests = new Map<string, SharedControlPendingRequest<unknown>>()
|
||||
private readonly subscriptions = new Map<string, SharedControlLogicalSubscription<unknown>>()
|
||||
private readonly retiredRequestIds = new SharedControlRetiredRequestIds()
|
||||
private readonly readyWaiters: SharedControlReadyWaiter[] = []
|
||||
private everReady = false
|
||||
private readonly socketGeneration = new SharedControlSocketGeneration()
|
||||
@@ -72,7 +75,8 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
params,
|
||||
timeoutMs,
|
||||
ensureReady: () => this.ensureReadyWithTimeout(timeoutMs),
|
||||
send: (requestId) => this.sendRequest(requestId)
|
||||
send: (requestId) => this.sendRequest(requestId),
|
||||
retireRequestId: (requestId) => this.retiredRequestIds.retire(requestId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,6 +207,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
pendingRequests: this.pendingRequests,
|
||||
subscriptions: this.subscriptions,
|
||||
retiredRequestIds: this.retiredRequestIds,
|
||||
readyWaiters: this.readyWaiters,
|
||||
setState: (state) => {
|
||||
this.state = state
|
||||
@@ -256,10 +261,12 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
}
|
||||
|
||||
private closeSubscription(requestId: string): void {
|
||||
sharedControlSubscriptions.closeSharedControlLogicalSubscription({
|
||||
closeSharedControlConnectionSubscription({
|
||||
subscriptions: this.subscriptions,
|
||||
subscription: this.subscriptions.get(requestId),
|
||||
request: (method, params) => this.sendSubscriptionCleanupRequest(method, params)
|
||||
retiredRequestIds: this.retiredRequestIds,
|
||||
requestId,
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
send: (payload) => this.sendEncrypted(payload)
|
||||
})
|
||||
this.reconnect.clearWhenIdle(this.subscriptions.size === 0 && this.state === 'closed')
|
||||
}
|
||||
@@ -273,15 +280,6 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
})
|
||||
}
|
||||
|
||||
private sendSubscriptionCleanupRequest(method: string, params: unknown): void {
|
||||
sharedControlSubscriptions.sendSharedControlCleanupRequest({
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
method,
|
||||
params,
|
||||
send: (payload) => this.sendEncrypted(payload)
|
||||
})
|
||||
}
|
||||
|
||||
private handleSocketClosed(error: RemoteRuntimeClientError, socketGeneration: number): void {
|
||||
if (
|
||||
!this.socketGeneration.acceptClose({
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { parseRemoteRuntimeRpcFrame } from './remote-runtime-request-frames'
|
||||
import { logUnknownSharedControlResponse } from './remote-runtime-shared-control-diagnostics-log'
|
||||
import {
|
||||
handleSharedControlLogicalResponse,
|
||||
sendSharedControlCleanupRequest
|
||||
} from './remote-runtime-shared-control-subscriptions'
|
||||
import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
import { sendRetiredSharedControlCleanupRequest } from './remote-runtime-shared-control-subscription-close'
|
||||
import { handleSharedControlLogicalResponse } from './remote-runtime-shared-control-subscriptions'
|
||||
import {
|
||||
refreshSharedControlPendingRequestTimeouts,
|
||||
resolveSharedControlPendingResponse
|
||||
@@ -20,6 +19,7 @@ export function dispatchSharedControlFrame(args: {
|
||||
frame: SharedControlFrame
|
||||
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
retiredRequestIds: SharedControlRetiredRequestIds
|
||||
deviceToken: string
|
||||
send: (payload: unknown) => boolean
|
||||
}): void {
|
||||
@@ -36,18 +36,27 @@ export function dispatchSharedControlFrame(args: {
|
||||
subscription,
|
||||
response,
|
||||
request: (method, params) =>
|
||||
sendSharedControlCleanupRequest({
|
||||
sendRetiredSharedControlCleanupRequest({
|
||||
retiredRequestIds: args.retiredRequestIds,
|
||||
deviceToken: args.deviceToken,
|
||||
method,
|
||||
params,
|
||||
send: args.send
|
||||
})
|
||||
})
|
||||
if (!args.subscriptions.has(response.id)) {
|
||||
args.retiredRequestIds.retire(response.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (args.pendingRequests.has(response.id)) {
|
||||
resolveSharedControlPendingResponse(args.pendingRequests, response.id, response)
|
||||
args.retiredRequestIds.retire(response.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (args.retiredRequestIds.has(response.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
|
||||
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version'
|
||||
import { dispatchSharedControlFrame } from './remote-runtime-shared-control-frame-dispatch'
|
||||
import { parseSharedControlFrame } from './remote-runtime-shared-control-protocol'
|
||||
import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
import { resolveSharedControlReadyWaiters } from './remote-runtime-shared-control-state'
|
||||
import type {
|
||||
SharedControlConnectionState,
|
||||
@@ -19,6 +20,7 @@ export function handleSharedControlTextFrame(args: {
|
||||
environmentId?: string
|
||||
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
retiredRequestIds: SharedControlRetiredRequestIds
|
||||
readyWaiters: SharedControlReadyWaiter[]
|
||||
setState: (state: SharedControlConnectionState) => void
|
||||
handleSocketClosed: (error: RemoteRuntimeClientError) => void
|
||||
@@ -65,6 +67,7 @@ export function handleSharedControlTextFrame(args: {
|
||||
frame: parsed.frame,
|
||||
pendingRequests: args.pendingRequests,
|
||||
subscriptions: args.subscriptions,
|
||||
retiredRequestIds: args.retiredRequestIds,
|
||||
deviceToken: args.deviceToken,
|
||||
send: args.sendEncrypted
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ export function requestSharedControl<TResult>(args: {
|
||||
timeoutMs: number
|
||||
ensureReady: () => Promise<void>
|
||||
send: (requestId: string) => void
|
||||
retireRequestId?: (requestId: string) => void
|
||||
// Why: default off — ordinary short RPCs keep an absolute deadline. Only
|
||||
// long-polls routed through this path opt in so keepalives extend them.
|
||||
refreshTimeoutOnKeepalive?: boolean
|
||||
@@ -48,6 +49,7 @@ export function requestSharedControl<TResult>(args: {
|
||||
}
|
||||
pendingRequests.delete(requestId)
|
||||
releaseRemoteRuntimePreparedRequest(pending)
|
||||
args.retireRequestId?.(requestId)
|
||||
// Why: one stalled method does not prove the shared socket is dead;
|
||||
// socket liveness owns connection-wide teardown so other RPCs survive.
|
||||
pending.reject(remoteRuntimeTimeoutError())
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
|
||||
describe('SharedControlRetiredRequestIds', () => {
|
||||
it('retains recent ids through repeated late frames and expires them', () => {
|
||||
let now = 1_000
|
||||
const ids = new SharedControlRetiredRequestIds({
|
||||
ttlMs: 100,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
ids.retire('request-1')
|
||||
expect(ids.has('request-1')).toBe(true)
|
||||
expect(ids.has('request-1')).toBe(true)
|
||||
|
||||
now += 101
|
||||
expect(ids.has('request-1')).toBe(false)
|
||||
expect(ids.size).toBe(0)
|
||||
})
|
||||
|
||||
it('evicts the oldest ids at its configured bound', () => {
|
||||
const ids = new SharedControlRetiredRequestIds({ maxIds: 2 })
|
||||
|
||||
ids.retire('request-1')
|
||||
ids.retire('request-2')
|
||||
ids.retire('request-3')
|
||||
|
||||
expect(ids.size).toBe(2)
|
||||
expect(ids.has('request-1')).toBe(false)
|
||||
expect(ids.has('request-2')).toBe(true)
|
||||
expect(ids.has('request-3')).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes an existing id to the newest eviction rank', () => {
|
||||
const ids = new SharedControlRetiredRequestIds({ maxIds: 2 })
|
||||
|
||||
ids.retire('request-1')
|
||||
ids.retire('request-2')
|
||||
ids.retire('request-1')
|
||||
ids.retire('request-3')
|
||||
|
||||
expect(ids.has('request-1')).toBe(true)
|
||||
expect(ids.has('request-2')).toBe(false)
|
||||
expect(ids.has('request-3')).toBe(true)
|
||||
})
|
||||
|
||||
it('expires ids correctly after the clock moves backward', () => {
|
||||
let now = 1_000
|
||||
const ids = new SharedControlRetiredRequestIds({
|
||||
ttlMs: 100,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
ids.retire('request-1')
|
||||
now = 900
|
||||
ids.retire('request-2')
|
||||
now = 1_001
|
||||
|
||||
expect(ids.has('request-1')).toBe(true)
|
||||
expect(ids.has('request-2')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
const DEFAULT_MAX_RETIRED_REQUEST_IDS = 2_048
|
||||
const DEFAULT_RETIRED_REQUEST_ID_TTL_MS = 60_000
|
||||
|
||||
export class SharedControlRetiredRequestIds {
|
||||
private readonly ids = new Map<string, number>()
|
||||
private readonly maxIds: number
|
||||
private readonly ttlMs: number
|
||||
private readonly now: () => number
|
||||
|
||||
constructor(
|
||||
options: {
|
||||
maxIds?: number
|
||||
ttlMs?: number
|
||||
now?: () => number
|
||||
} = {}
|
||||
) {
|
||||
this.maxIds = Math.max(1, options.maxIds ?? DEFAULT_MAX_RETIRED_REQUEST_IDS)
|
||||
this.ttlMs = Math.max(1, options.ttlMs ?? DEFAULT_RETIRED_REQUEST_ID_TTL_MS)
|
||||
this.now = options.now ?? Date.now
|
||||
}
|
||||
|
||||
retire(requestId: string): void {
|
||||
const now = this.now()
|
||||
this.pruneExpired(now)
|
||||
this.ids.delete(requestId)
|
||||
this.ids.set(requestId, now + this.ttlMs)
|
||||
while (this.ids.size > this.maxIds) {
|
||||
const oldestId = this.ids.keys().next().value
|
||||
if (oldestId === undefined) {
|
||||
return
|
||||
}
|
||||
this.ids.delete(oldestId)
|
||||
}
|
||||
}
|
||||
|
||||
has(requestId: string): boolean {
|
||||
this.pruneExpired(this.now())
|
||||
return this.ids.has(requestId)
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
this.pruneExpired(this.now())
|
||||
return this.ids.size
|
||||
}
|
||||
|
||||
private pruneExpired(now: number): void {
|
||||
for (const [requestId, expiresAt] of this.ids) {
|
||||
if (expiresAt <= now) {
|
||||
this.ids.delete(requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
import {
|
||||
closeSharedControlConnectionSubscription,
|
||||
sendRetiredSharedControlCleanupRequest
|
||||
} from './remote-runtime-shared-control-subscription-close'
|
||||
import { createSharedControlSubscription } from './remote-runtime-shared-control-subscriptions'
|
||||
import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types'
|
||||
|
||||
describe('shared-control subscription retirement', () => {
|
||||
it('retires a closed subscription and its cleanup request', () => {
|
||||
const subscriptions = new Map<string, SharedControlLogicalSubscription<unknown>>()
|
||||
const subscription = createSharedControlSubscription({
|
||||
requestId: 'request-1',
|
||||
method: 'runtime.clientEvents.subscribe',
|
||||
params: null,
|
||||
retainedParamsBytes: 0,
|
||||
callbacks: { onResponse: vi.fn(), onError: vi.fn() }
|
||||
})
|
||||
subscription.sent = true
|
||||
subscription.remoteSubscriptionId = 'subscription-1'
|
||||
subscriptions.set(subscription.requestId, subscription)
|
||||
const retiredRequestIds = new SharedControlRetiredRequestIds()
|
||||
let cleanupRequestId = ''
|
||||
|
||||
closeSharedControlConnectionSubscription({
|
||||
subscriptions,
|
||||
retiredRequestIds,
|
||||
requestId: subscription.requestId,
|
||||
deviceToken: 'device-token',
|
||||
send: (payload) => {
|
||||
cleanupRequestId = (payload as { id: string }).id
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
expect(subscriptions.size).toBe(0)
|
||||
expect(retiredRequestIds.has(subscription.requestId)).toBe(true)
|
||||
expect(retiredRequestIds.has(cleanupRequestId)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not retire an unsent cleanup request', () => {
|
||||
const retiredRequestIds = new SharedControlRetiredRequestIds()
|
||||
let cleanupRequestId = ''
|
||||
|
||||
sendRetiredSharedControlCleanupRequest({
|
||||
retiredRequestIds,
|
||||
deviceToken: 'device-token',
|
||||
method: 'runtime.clientEvents.unsubscribe',
|
||||
params: null,
|
||||
send: (payload) => {
|
||||
cleanupRequestId = (payload as { id: string }).id
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
expect(retiredRequestIds.has(cleanupRequestId)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
import {
|
||||
closeSharedControlLogicalSubscription,
|
||||
sendSharedControlCleanupRequest
|
||||
} from './remote-runtime-shared-control-subscriptions'
|
||||
import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types'
|
||||
|
||||
export function closeSharedControlConnectionSubscription(args: {
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
retiredRequestIds: SharedControlRetiredRequestIds
|
||||
requestId: string
|
||||
deviceToken: string
|
||||
send: (payload: unknown) => boolean
|
||||
}): void {
|
||||
const subscription = args.subscriptions.get(args.requestId)
|
||||
closeSharedControlLogicalSubscription({
|
||||
subscriptions: args.subscriptions,
|
||||
subscription,
|
||||
request: (method, params) =>
|
||||
sendRetiredSharedControlCleanupRequest({
|
||||
retiredRequestIds: args.retiredRequestIds,
|
||||
deviceToken: args.deviceToken,
|
||||
method,
|
||||
params,
|
||||
send: args.send
|
||||
})
|
||||
})
|
||||
if (subscription && !args.subscriptions.has(args.requestId)) {
|
||||
args.retiredRequestIds.retire(args.requestId)
|
||||
}
|
||||
}
|
||||
|
||||
export function sendRetiredSharedControlCleanupRequest(args: {
|
||||
retiredRequestIds: SharedControlRetiredRequestIds
|
||||
deviceToken: string
|
||||
method: string
|
||||
params: unknown
|
||||
send: (payload: unknown) => boolean
|
||||
}): void {
|
||||
const requestId = sendSharedControlCleanupRequest(args)
|
||||
if (requestId) {
|
||||
args.retiredRequestIds.retire(requestId)
|
||||
}
|
||||
}
|
||||
@@ -86,15 +86,17 @@ export function sendSharedControlCleanupRequest(args: {
|
||||
method: string
|
||||
params: unknown
|
||||
send: (payload: unknown) => boolean
|
||||
}): void {
|
||||
}): string | null {
|
||||
// Why: cleanup is best-effort and often runs during teardown; send it
|
||||
// synchronously so close() cannot race the async request path.
|
||||
args.send({
|
||||
id: randomUUID(),
|
||||
const requestId = randomUUID()
|
||||
const sent = args.send({
|
||||
id: requestId,
|
||||
deviceToken: args.deviceToken,
|
||||
method: args.method,
|
||||
params: args.params
|
||||
})
|
||||
return sent ? requestId : null
|
||||
}
|
||||
|
||||
export function replaySharedControlSubscriptions(args: {
|
||||
|
||||
Reference in New Issue
Block a user