mirror of
https://github.com/stablyai/orca.git
synced 2026-10-05 16:02:22 +00:00
fix(mobile): revalidate generic requests at transport dispatch
Bound catalog and request lifetime and check page/workspace authority after connection waits. Fence retired physical requests across logical cutover, preserve existing response ambiguity, and advertise dispatch support without changing protocol 2. Record the discovered catalog authorization gap in the tracker.
This commit is contained in:
@@ -33,6 +33,12 @@ Last reconciled: September 6, 2026. Implementation is **in progress**.
|
||||
- [x] Full unattended existing adversarial harness on iOS and Android.
|
||||
- [ ] Chat-specific interactions, migrated settings and frozen-shell OTA/rollback E2E.
|
||||
|
||||
Immediate next step: connect Desktop catalog membership to mobile RPC authorization.
|
||||
Investigation found that advertised `mobileWeb.files.*` and `mobileWeb.nativeChat.*`
|
||||
adapters were absent from the static mobile allowlist. Prior platform passes can
|
||||
include legacy fallbacks and do not prove those generic adapters were exercised.
|
||||
This gap must be fixed and verified before their end-to-end migration is complete.
|
||||
|
||||
Next: migrate remaining domain operations and mutation fingerprint handling;
|
||||
wire hosted settings with their consumers and page-owned route restoration;
|
||||
finish bundle/CSP work and verify two-page replacement/rollback on a frozen shell.
|
||||
@@ -400,3 +406,21 @@ All 11 required gates pass in `/tmp/orca-ota-e2e/page-preferences-gates/`:
|
||||
321 root files / 2,710 passed. Cleanup focused checks: 2 files / 4 passed.
|
||||
Dispatch census 229 → 230; persisted-state inventory updated deliberately.
|
||||
Page export: `1b3542d5b0c6f01d99f4775a4f741f2c3e136397a6cb59f2f1f08368557eab26`.
|
||||
|
||||
### Generic request dispatch lifetime
|
||||
|
||||
Added final synchronous dispatch guards to direct and relay transports; generic
|
||||
host requests revalidate page and workspace authority after connection waits,
|
||||
immediately before transmission. Logical connection replacement fences old
|
||||
physical requests. Catalog lookup and execution share a 15-second native budget;
|
||||
standalone catalog reads are bounded too. Cancellation prevents an unsent frame;
|
||||
it does not undo a frame already transmitted. Mutation consumers must preserve
|
||||
unknown delivery and never automatically retry it. The additive feature
|
||||
`workspace.hostRequestDispatch.v1` lets future pages require this behavior.
|
||||
|
||||
All required gates pass (`/tmp/orca-ota-e2e/dispatch-gates/`, final rechecks included).
|
||||
Mobile: 840 files / 5,540 passed. Root: 321 files / 2,710 passed.
|
||||
Reauthorization census deliberately increases host-request sites 2 → 3.
|
||||
Extracted logical-client types and request authority to retain the 300-line limit.
|
||||
Page build: `2e64a57e693f312c4113831e84404f87f009bbe5803a9ef19ddc7a8b0d5fffe9`.
|
||||
No new platform pass claimed for this slice. Native-chat mutations remain open.
|
||||
|
||||
@@ -119,17 +119,25 @@ describe('native-chat generic read migration', () => {
|
||||
expect(result.messages[0].blocks[0]).toMatchObject({ type: 'text', text: 'hello' })
|
||||
if (host && shell) {
|
||||
expect(result).toEqual(f.transcript)
|
||||
expect(f.sendRequest).toHaveBeenCalledWith('mobileWeb.nativeChat.bind', {
|
||||
worktree: 'id:host-workspace',
|
||||
pageSession: MOBILE_WEB_BRIDGE_ROUNDTRIP_CONTEXT.shellSessionId,
|
||||
tabId: 'tab'
|
||||
})
|
||||
expect(f.sendRequest).toHaveBeenCalledWith('mobileWeb.nativeChat.read', {
|
||||
worktree: 'id:host-workspace',
|
||||
pageSession: MOBILE_WEB_BRIDGE_ROUNDTRIP_CONTEXT.shellSessionId,
|
||||
resourceId: 'opaque-resource',
|
||||
read: { limit: 20 }
|
||||
})
|
||||
expect(f.sendRequest).toHaveBeenCalledWith(
|
||||
'mobileWeb.nativeChat.bind',
|
||||
{
|
||||
worktree: 'id:host-workspace',
|
||||
pageSession: MOBILE_WEB_BRIDGE_ROUNDTRIP_CONTEXT.shellSessionId,
|
||||
tabId: 'tab'
|
||||
},
|
||||
expect.objectContaining({ beforeSend: expect.any(Function) })
|
||||
)
|
||||
expect(f.sendRequest).toHaveBeenCalledWith(
|
||||
'mobileWeb.nativeChat.read',
|
||||
{
|
||||
worktree: 'id:host-workspace',
|
||||
pageSession: MOBILE_WEB_BRIDGE_ROUNDTRIP_CONTEXT.shellSessionId,
|
||||
resourceId: 'opaque-resource',
|
||||
read: { limit: 20 }
|
||||
},
|
||||
expect.objectContaining({ beforeSend: expect.any(Function) })
|
||||
)
|
||||
expect(f.sendRequest.mock.calls.some(([method]) => method === 'nativeChat.readSession')).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
@@ -37,10 +37,14 @@ describe('host-advertised unary forwarding', () => {
|
||||
.mockResolvedValueOnce({ ok: true, result: { grants: [grant] } })
|
||||
.mockResolvedValueOnce({ ok: true, result })
|
||||
await expect(executeMobileWebHostRequest(args)).resolves.toEqual(result)
|
||||
expect(sendRequest).toHaveBeenLastCalledWith(grant.method, {
|
||||
...args.payload.params,
|
||||
worktree: 'id:host-workspace'
|
||||
})
|
||||
expect(sendRequest).toHaveBeenLastCalledWith(
|
||||
grant.method,
|
||||
{
|
||||
...args.payload.params,
|
||||
worktree: 'id:host-workspace'
|
||||
},
|
||||
expect.objectContaining({ beforeSend: expect.any(Function), budgetSpansConnect: true })
|
||||
)
|
||||
expect(JSON.stringify(result)).not.toContain('host-workspace')
|
||||
})
|
||||
|
||||
@@ -57,9 +61,32 @@ describe('host-advertised unary forwarding', () => {
|
||||
pageSessionId: 'current-document',
|
||||
payload: { ...args.payload, params: { pageSession: 'retired-document' } }
|
||||
})
|
||||
expect(sendRequest).toHaveBeenLastCalledWith(grant.method, {
|
||||
worktree: 'id:host-workspace',
|
||||
pageSession: 'current-document'
|
||||
expect(sendRequest).toHaveBeenLastCalledWith(
|
||||
grant.method,
|
||||
{
|
||||
worktree: 'id:host-workspace',
|
||||
pageSession: 'current-document'
|
||||
},
|
||||
expect.objectContaining({ beforeSend: expect.any(Function) })
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['cancel', 'rebind'] as const)('revalidates %s at transport dispatch', async (change) => {
|
||||
const { args, sendRequest } = fixture()
|
||||
let active = true
|
||||
args.isActive = () => active
|
||||
sendRequest.mockResolvedValueOnce({ ok: true, result: { grants: [grant] } })
|
||||
sendRequest.mockImplementationOnce(async (_method, _params, options) => {
|
||||
if (change === 'cancel') {
|
||||
active = false
|
||||
} else {
|
||||
args.authority.clear()
|
||||
}
|
||||
options?.beforeSend?.()
|
||||
throw new Error('Transport must not write')
|
||||
})
|
||||
await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({
|
||||
code: change === 'cancel' ? 'cancelled' : 'not_found'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,14 +4,20 @@ import {
|
||||
MobileWebHostRequestPayloadSchema,
|
||||
mobileWebHostPayloadWithinBounds
|
||||
} from '../../../src/shared/mobile-web/host-rpc-contract'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcClient, SendRequestOptions } from '../transport/rpc-client'
|
||||
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
|
||||
import { mobileWebEncodedByteLength } from './mobile-web-request-accounting'
|
||||
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
|
||||
export async function readMobileWebHostCatalog(client: RpcClient, input: unknown) {
|
||||
const HOST_REQUEST_TIMEOUT_MS = 15_000
|
||||
|
||||
export async function readMobileWebHostCatalog(
|
||||
client: RpcClient,
|
||||
input: unknown,
|
||||
options: SendRequestOptions = { timeoutMs: HOST_REQUEST_TIMEOUT_MS, budgetSpansConnect: true }
|
||||
) {
|
||||
const payload = MobileWebHostCatalogPayloadSchema.parse(input)
|
||||
const response = await client.sendRequest('mobileWeb.host.catalog', payload)
|
||||
const response = await client.sendRequest('mobileWeb.host.catalog', payload, options)
|
||||
if (!response.ok) {
|
||||
throw mobileWebBrokerHostRpcError(response.error)
|
||||
}
|
||||
@@ -27,6 +33,7 @@ export type MobileWebHostRequestArguments = {
|
||||
payload: unknown
|
||||
isActive: () => boolean
|
||||
pageSessionId?: string
|
||||
requestOptions?: () => SendRequestOptions
|
||||
}
|
||||
|
||||
export async function prepareMobileWebHostRequest(
|
||||
@@ -38,7 +45,11 @@ export async function prepareMobileWebHostRequest(
|
||||
throw new MobileWebBrokerError('too_large')
|
||||
}
|
||||
const hostWorkspaceId = args.authority.hostWorkspaceId(payload.workspaceId)
|
||||
const catalog = await readMobileWebHostCatalog(args.client, { methods: [payload.method] })
|
||||
const catalog = await readMobileWebHostCatalog(
|
||||
args.client,
|
||||
{ methods: [payload.method] },
|
||||
args.requestOptions?.()
|
||||
)
|
||||
const grant = catalog.grants.find((entry) => entry.method === payload.method)
|
||||
if (
|
||||
!grant ||
|
||||
@@ -74,11 +85,29 @@ export async function prepareMobileWebHostRequest(
|
||||
export async function executeMobileWebHostRequest(
|
||||
args: MobileWebHostRequestArguments
|
||||
): Promise<unknown> {
|
||||
const deadline = Date.now() + HOST_REQUEST_TIMEOUT_MS
|
||||
const beforeSend = () => {
|
||||
if (!args.isActive()) {
|
||||
throw new MobileWebBrokerError('cancelled')
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new MobileWebBrokerError('timeout')
|
||||
}
|
||||
}
|
||||
const requestOptions = (): SendRequestOptions => {
|
||||
beforeSend()
|
||||
return { timeoutMs: deadline - Date.now(), budgetSpansConnect: true, beforeSend }
|
||||
}
|
||||
const { payload, hostWorkspaceId, grant, params } = await prepareMobileWebHostRequest(
|
||||
args,
|
||||
{ ...args, requestOptions },
|
||||
'once'
|
||||
)
|
||||
const response = await args.client.sendRequest(payload.method, params)
|
||||
const options = requestOptions()
|
||||
options.beforeSend = () => {
|
||||
beforeSend()
|
||||
args.authority.assertHostWorkspaceBinding(payload.workspaceId, hostWorkspaceId)
|
||||
}
|
||||
const response = await args.client.sendRequest(payload.method, params, options)
|
||||
if (!response.ok) {
|
||||
throw mobileWebBrokerHostRpcError(response.error)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const REAUTHORIZATION_SITES: Record<string, number> = {
|
||||
'mobile-web-agent-history-resume.ts': 1,
|
||||
'mobile-web-file-operations.ts': 1,
|
||||
'mobile-web-file-write.ts': 1,
|
||||
'mobile-web-host-requests.ts': 2,
|
||||
'mobile-web-host-requests.ts': 3,
|
||||
'mobile-web-host-subscriptions.ts': 1,
|
||||
'mobile-web-markdown-operations.ts': 2,
|
||||
'mobile-web-native-chat-binding.ts': 1,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { RpcClient } from './rpc-client'
|
||||
|
||||
export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay'
|
||||
|
||||
export type StableLogicalRpcClient = RpcClient & {
|
||||
migrateTo(
|
||||
session: RpcClient,
|
||||
path: MobileConnectionPath,
|
||||
timeoutMs?: number,
|
||||
// Checked after the replacement authenticates, before the swap — lets a racing
|
||||
// caller withdraw when another path won while this dial was in flight.
|
||||
shouldAbort?: () => boolean
|
||||
): Promise<void>
|
||||
suspendActiveSession(): void
|
||||
getActivePath(): MobileConnectionPath
|
||||
// The path the user is waiting on while migration or scheduled recovery is active.
|
||||
getPendingPath(): MobileConnectionPath | null
|
||||
setRecoveryPath(path: MobileConnectionPath | null, attempt?: number): void
|
||||
setRecoveryAttempt(attempt: number): void
|
||||
// Latched when the desktop has repeatedly refused this device's relay credential.
|
||||
setPairingRejected(rejected: boolean): void
|
||||
isPairingRejected(): boolean
|
||||
// Latched when the relay named the desktop's own sign-out as the reason it is absent.
|
||||
setHostSignedOut(signedOut: boolean): void
|
||||
isHostSignedOut(): boolean
|
||||
// Recovery attempts share this signal so status-only changes rerender.
|
||||
onConnectionPathChange(listener: () => void): () => void
|
||||
getGeneration(): number
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { SendRequestOptions } from './rpc-client'
|
||||
export class LogicalClientCutoverError extends Error {
|
||||
constructor() {
|
||||
super('RPC interrupted by connection migration')
|
||||
}
|
||||
}
|
||||
|
||||
// Why: instanceof can miss across bundle copies, so also match by message.
|
||||
export function isLogicalClientCutoverError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof LogicalClientCutoverError ||
|
||||
(error instanceof Error && error.message === 'RPC interrupted by connection migration')
|
||||
)
|
||||
}
|
||||
|
||||
export function guardLogicalClientRequest(
|
||||
options: SendRequestOptions | undefined,
|
||||
isCurrent: () => boolean
|
||||
): SendRequestOptions | undefined {
|
||||
if (!options?.beforeSend) {
|
||||
return options
|
||||
}
|
||||
return {
|
||||
...options,
|
||||
beforeSend: () => {
|
||||
if (!isCurrent()) {
|
||||
throw new LogicalClientCutoverError()
|
||||
}
|
||||
options.beforeSend?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,27 @@ describe('mobile relay RPC session liveness', () => {
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('checks caller authority after the connected wait and before relay transmission', async () => {
|
||||
const session = await authenticateSession()
|
||||
const failure = new Error('Retired page')
|
||||
let active = true
|
||||
const pending = session.sendRequest(
|
||||
'future.write',
|
||||
{},
|
||||
{
|
||||
beforeSend: () => {
|
||||
if (!active) {
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
active = false
|
||||
await expect(pending).rejects.toBe(failure)
|
||||
expect(fakes.sendText).not.toHaveBeenCalled()
|
||||
session.close()
|
||||
})
|
||||
|
||||
it('sends no periodic traffic while an authenticated relay is idle', async () => {
|
||||
const session = await authenticateSession()
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ export function connectMobileRelayRpcSession(args: {
|
||||
async sendRequest(method, params, options) {
|
||||
const budget = openRpcRequestBudget(options)
|
||||
await waitForConnected(budget.timeoutMs)
|
||||
options?.beforeSend?.()
|
||||
return sendRpc(method, params, resolvePostConnectRequestTimeout(budget, requestTimeoutMs))
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { RpcClientRequestTracker } from './rpc-client-request-tracker'
|
||||
import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
|
||||
it('rechecks authority after connecting without retaining or sending a cancelled request', async () => {
|
||||
const connected = Promise.withResolvers<void>()
|
||||
const sendEncrypted = vi.fn(() => true)
|
||||
const tracker = new RpcClientRequestTracker({
|
||||
nextId: () => 'request',
|
||||
getState: () => 'connected',
|
||||
waitForConnected: () => connected.promise,
|
||||
sendEncrypted,
|
||||
deviceToken: 'test-token'
|
||||
})
|
||||
let active = true
|
||||
const failure = new Error('Retired document')
|
||||
const request = tracker.sendRequest(
|
||||
'future.write',
|
||||
{},
|
||||
{
|
||||
beforeSend: () => {
|
||||
if (!active) {
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
active = false
|
||||
connected.resolve()
|
||||
await expect(request).rejects.toBe(failure)
|
||||
expect(isRpcDeliveryUnknown(failure)).toBe(false)
|
||||
expect(sendEncrypted).not.toHaveBeenCalled()
|
||||
expect(tracker.size()).toBe(0)
|
||||
})
|
||||
@@ -42,6 +42,7 @@ export class RpcClientRequestTracker {
|
||||
})
|
||||
}
|
||||
|
||||
requestOptions?.beforeSend?.()
|
||||
return this.sendConnectedRequest(
|
||||
method,
|
||||
params,
|
||||
|
||||
@@ -14,6 +14,8 @@ export type SendRequestOptions = {
|
||||
budgetSpansConnect?: boolean
|
||||
/** Reject instead of replaying the request after reconnect. */
|
||||
failWhenDisconnected?: boolean
|
||||
/** Revalidate caller authority synchronously at the final transport write. */
|
||||
beforeSend?: () => void
|
||||
}
|
||||
|
||||
type StreamingListener = (result: unknown) => void
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage'
|
||||
import type { ConnectionState, RpcResponse } from './types'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { RpcClient, SendRequestOptions } from './rpc-client'
|
||||
import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import {
|
||||
createStableLogicalRpcClient,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
class FakeSession implements RpcClient {
|
||||
readonly sendRequest =
|
||||
vi.fn<
|
||||
(method: string, params?: unknown, options?: { timeoutMs?: number }) => Promise<RpcResponse>
|
||||
(method: string, params?: unknown, options?: SendRequestOptions) => Promise<RpcResponse>
|
||||
>()
|
||||
readonly subscribe = vi.fn<RpcClient['subscribe']>()
|
||||
readonly updateTerminalSubscriptionViewport =
|
||||
@@ -165,6 +165,24 @@ describe('stable logical RPC client', () => {
|
||||
await expect(client.sendRequest('status.get')).resolves.toEqual(success('next'))
|
||||
})
|
||||
|
||||
it('prevents a retired physical request from writing after logical cutover', async () => {
|
||||
const initial = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connected')
|
||||
const waiting = deferred<RpcResponse>()
|
||||
initial.sendRequest.mockReturnValue(waiting.promise)
|
||||
const client = createStableLogicalRpcClient(initial, 'lan')
|
||||
const beforeSend = vi.fn()
|
||||
const pending = client.sendRequest('future.write', {}, { beforeSend })
|
||||
const rejection = expect(pending).rejects.toBeInstanceOf(LogicalClientCutoverError)
|
||||
await client.migrateTo(replacement, 'relay')
|
||||
await rejection
|
||||
const options = initial.sendRequest.mock.calls[0]![2]!
|
||||
expect(() => options.beforeSend?.()).toThrow(LogicalClientCutoverError)
|
||||
expect(beforeSend).not.toHaveBeenCalled()
|
||||
waiting.resolve(success('late'))
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('preserves delivery ambiguity without replaying a mutation after relay replacement', async () => {
|
||||
const session = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connected')
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
LogicalClientCutoverError,
|
||||
guardLogicalClientRequest
|
||||
} from './logical-client-request-authority'
|
||||
export {
|
||||
LogicalClientCutoverError,
|
||||
isLogicalClientCutoverError
|
||||
} from './logical-client-request-authority'
|
||||
import type { ConnectionState, RpcResponse } from './types'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import {
|
||||
@@ -8,21 +16,8 @@ import { waitForAuthenticated } from './replacement-session-authentication'
|
||||
import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection'
|
||||
import { LogicalClientConnectionPath } from './logical-client-connection-path'
|
||||
|
||||
export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay'
|
||||
|
||||
export class LogicalClientCutoverError extends Error {
|
||||
constructor() {
|
||||
super('RPC interrupted by connection migration')
|
||||
}
|
||||
}
|
||||
|
||||
// Why: instanceof can miss across bundle copies, so also match by message.
|
||||
export function isLogicalClientCutoverError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof LogicalClientCutoverError ||
|
||||
(error instanceof Error && error.message === 'RPC interrupted by connection migration')
|
||||
)
|
||||
}
|
||||
import type { StableLogicalRpcClient, MobileConnectionPath } from './logical-client-contract'
|
||||
export type { StableLogicalRpcClient, MobileConnectionPath } from './logical-client-contract'
|
||||
|
||||
type SubscriptionRecord = {
|
||||
method: string
|
||||
@@ -37,32 +32,6 @@ type PendingRequest = {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
export type StableLogicalRpcClient = RpcClient & {
|
||||
migrateTo(
|
||||
session: RpcClient,
|
||||
path: MobileConnectionPath,
|
||||
timeoutMs?: number,
|
||||
// Checked after the replacement authenticates, before the swap — lets a racing
|
||||
// caller withdraw when another path won while this dial was in flight.
|
||||
shouldAbort?: () => boolean
|
||||
): Promise<void>
|
||||
suspendActiveSession(): void
|
||||
getActivePath(): MobileConnectionPath
|
||||
// The path the user is waiting on while migration or scheduled recovery is active.
|
||||
getPendingPath(): MobileConnectionPath | null
|
||||
setRecoveryPath(path: MobileConnectionPath | null, attempt?: number): void
|
||||
setRecoveryAttempt(attempt: number): void
|
||||
// Latched when the desktop has repeatedly refused this device's relay credential.
|
||||
setPairingRejected(rejected: boolean): void
|
||||
isPairingRejected(): boolean
|
||||
// Latched when the relay named the desktop's own sign-out as the reason it is absent.
|
||||
setHostSignedOut(signedOut: boolean): void
|
||||
isHostSignedOut(): boolean
|
||||
// Recovery attempts share this signal so status-only changes rerender.
|
||||
onConnectionPathChange(listener: () => void): () => void
|
||||
getGeneration(): number
|
||||
}
|
||||
|
||||
export function createStableLogicalRpcClient(
|
||||
initialSession: RpcClient,
|
||||
initialPath: MobileConnectionPath
|
||||
@@ -92,11 +61,15 @@ export function createStableLogicalRpcClient(
|
||||
}
|
||||
const requestGeneration = generation
|
||||
const session = activeSession
|
||||
const guardedOptions = guardLogicalClientRequest(
|
||||
options,
|
||||
() => !closed && !suspended && requestGeneration === generation
|
||||
)
|
||||
return new Promise<RpcResponse>((resolve, reject) => {
|
||||
const pending = { reject }
|
||||
pendingRequests.add(pending)
|
||||
void session
|
||||
.sendRequest(method, projectMobileRpcRequestParams(method, params), options)
|
||||
.sendRequest(method, projectMobileRpcRequestParams(method, params), guardedOptions)
|
||||
.then(
|
||||
(response) => {
|
||||
pendingRequests.delete(pending)
|
||||
|
||||
@@ -17,7 +17,10 @@ export const MOBILE_WEB_SHELL_NATIVE_CHAT_PASTE_FOLLOWED_BY_TEXT_FEATURE =
|
||||
|
||||
export const MOBILE_WEB_SHELL_HOST_PAGE_SESSION_FEATURE = 'workspace.hostPageSession.v1'
|
||||
|
||||
export const MOBILE_WEB_SHELL_HOST_REQUEST_DISPATCH_FEATURE = 'workspace.hostRequestDispatch.v1'
|
||||
|
||||
export const MOBILE_WEB_SHELL_FEATURES = [
|
||||
MOBILE_WEB_SHELL_HOST_REQUEST_DISPATCH_FEATURE,
|
||||
MOBILE_WEB_SHELL_HOST_PAGE_SESSION_FEATURE,
|
||||
MOBILE_WEB_SHELL_NATIVE_CHAT_PASTE_FOLLOWED_BY_TEXT_FEATURE
|
||||
] as const
|
||||
|
||||
Reference in New Issue
Block a user