mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(mobile): close reconnect review gaps
This commit is contained in:
@@ -4457,7 +4457,7 @@ export default function SessionScreen() {
|
||||
accessibilityRole={showConnectionRetry ? 'button' : undefined}
|
||||
accessibilityLabel={showConnectionRetry ? 'Reconnect to desktop' : undefined}
|
||||
>
|
||||
<StatusDot state={connState} />
|
||||
<StatusDot state={connState} verdict={connectionVerdict} />
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{terminalSummary}
|
||||
</Text>
|
||||
|
||||
@@ -31,12 +31,12 @@ export function useAllHostClients(hostIds: string[]) {
|
||||
}, [key])
|
||||
|
||||
return useMemo(() => {
|
||||
const clients: Array<{
|
||||
const clients: {
|
||||
hostId: string
|
||||
client: RpcClient
|
||||
state: ConnectionState
|
||||
path: MobileConnectionPath
|
||||
}> = []
|
||||
}[] = []
|
||||
for (const id of hostIds) {
|
||||
const found = context.getAllClients().find((entry) => entry.hostId === id)
|
||||
if (found) {
|
||||
|
||||
@@ -289,11 +289,15 @@ describe('useHostClient', () => {
|
||||
})
|
||||
|
||||
expect(stale.closeMock).toHaveBeenCalledOnce()
|
||||
expect(fresh.sendRequest).toHaveBeenCalledWith('status.get', undefined, {
|
||||
timeoutMs: expect.any(Number),
|
||||
budgetSpansConnect: true,
|
||||
strictDeadline: true
|
||||
})
|
||||
expect(fresh.sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.list',
|
||||
{ limit: 1 },
|
||||
{
|
||||
timeoutMs: expect.any(Number),
|
||||
budgetSpansConnect: true,
|
||||
strictDeadline: true
|
||||
}
|
||||
)
|
||||
expect(completed).toBe(false)
|
||||
|
||||
resolveHealthCheck?.()
|
||||
@@ -623,11 +627,15 @@ describe('useHostClient', () => {
|
||||
|
||||
expect(connectMock.mock.calls[2]?.[0]).toEqual(updatedHost)
|
||||
expect(oldReplacement.closeMock).toHaveBeenCalledOnce()
|
||||
expect(newReplacement.sendRequest).toHaveBeenCalledWith('status.get', undefined, {
|
||||
timeoutMs: expect.any(Number),
|
||||
budgetSpansConnect: true,
|
||||
strictDeadline: true
|
||||
})
|
||||
expect(newReplacement.sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.list',
|
||||
{ limit: 1 },
|
||||
{
|
||||
timeoutMs: expect.any(Number),
|
||||
budgetSpansConnect: true,
|
||||
strictDeadline: true
|
||||
}
|
||||
)
|
||||
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
@@ -219,6 +219,37 @@ describe('existing host relay endpoint lifecycle publication', () => {
|
||||
expect(events).toEqual(['publication-started', 'publication-finished', 'removed'])
|
||||
})
|
||||
|
||||
it('fences a queued stale publication before it mutates credentials', async () => {
|
||||
let releaseFirstPublication: (() => void) | null = null
|
||||
let markFirstPublicationStarted: (() => void) | null = null
|
||||
const firstPublicationStarted = new Promise<void>((resolve) => {
|
||||
markFirstPublicationStarted = resolve
|
||||
})
|
||||
const firstPublication = publishHostProfileTransaction(
|
||||
HOST,
|
||||
async () => {
|
||||
markFirstPublicationStarted?.()
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstPublication = resolve
|
||||
})
|
||||
},
|
||||
async () => {}
|
||||
)
|
||||
await firstPublicationStarted
|
||||
|
||||
const staleCredentialWrite = vi.fn(async () => {})
|
||||
const stalePublication = publishHostProfileTransaction(
|
||||
{ ...HOST, deviceToken: 'token-2' },
|
||||
staleCredentialWrite,
|
||||
async () => {}
|
||||
)
|
||||
releaseFirstPublication?.()
|
||||
|
||||
await firstPublication
|
||||
await expect(stalePublication).rejects.toThrow('host profile publication was retired')
|
||||
expect(staleCredentialWrite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an old credential write that starts during same-host re-pair publication', async () => {
|
||||
let releaseReplacement: (() => void) | null = null
|
||||
let markReplacementStarted: (() => void) | null = null
|
||||
|
||||
@@ -41,11 +41,15 @@ describe('Force Reconnect RPC health', () => {
|
||||
|
||||
expect(outcome).toBe('stalled')
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest).toHaveBeenLastCalledWith('status.get', undefined, {
|
||||
timeoutMs: 1_000,
|
||||
budgetSpansConnect: true,
|
||||
strictDeadline: true
|
||||
})
|
||||
expect(sendRequest).toHaveBeenLastCalledWith(
|
||||
'worktree.list',
|
||||
{ limit: 1 },
|
||||
{
|
||||
timeoutMs: 1_000,
|
||||
budgetSpansConnect: true,
|
||||
strictDeadline: true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('waits through a transient authorization retry', async () => {
|
||||
@@ -67,22 +71,26 @@ describe('Force Reconnect RPC health', () => {
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects a successful probe until a real application response clears the shared latch', async () => {
|
||||
it('clears the shared latch with an application-level replacement probe', async () => {
|
||||
const responsiveness = new RpcApplicationResponsiveness()
|
||||
responsiveness.recordTimeout('browser.screenshot', 123)
|
||||
const sendRequest = vi.fn(async () => ({ id: 'rpc-1', ok: true, result: {} }) as RpcResponse)
|
||||
const sendRequest = vi.fn(async (method: string) => {
|
||||
responsiveness.recordResponse(method)
|
||||
return { id: 'rpc-1', ok: true, result: {} } as RpcResponse
|
||||
})
|
||||
const client = {
|
||||
sendRequest,
|
||||
getState: () => 'connected' as const,
|
||||
getRpcUnresponsiveSince: () => responsiveness.getUnresponsiveSince()
|
||||
} as unknown as RpcClient
|
||||
|
||||
await expect(verifyForceReconnectRpcHealth(client)).rejects.toThrow(
|
||||
'Application RPC channel is still not responding'
|
||||
)
|
||||
responsiveness.recordResponse('worktree.list')
|
||||
await expect(verifyForceReconnectRpcHealth(client)).resolves.toBeUndefined()
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.list',
|
||||
{ limit: 1 },
|
||||
expect.objectContaining({ strictDeadline: true })
|
||||
)
|
||||
expect(responsiveness.getUnresponsiveSince()).toBeNull()
|
||||
})
|
||||
|
||||
it('fails when authorization retries reach auth-failed', async () => {
|
||||
|
||||
@@ -15,11 +15,15 @@ export async function verifyForceReconnectRpcHealth(
|
||||
throw lastError ?? new Error('Force Reconnect health check timed out')
|
||||
}
|
||||
try {
|
||||
await client.sendRequest('status.get', undefined, {
|
||||
timeoutMs,
|
||||
budgetSpansConnect: true,
|
||||
strictDeadline: true
|
||||
})
|
||||
await client.sendRequest(
|
||||
'worktree.list',
|
||||
{ limit: 1 },
|
||||
{
|
||||
timeoutMs,
|
||||
budgetSpansConnect: true,
|
||||
strictDeadline: true
|
||||
}
|
||||
)
|
||||
if (client.getRpcUnresponsiveSince?.() != null) {
|
||||
throw new Error('Application RPC channel is still not responding')
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export function publishHostProfileTransaction(
|
||||
): Promise<void> {
|
||||
const publicationRevision = getHostProfilePublicationRevision(host.id)
|
||||
return serializeHostProfilePublication(host.id, async () => {
|
||||
requirePublicationRevision(host.id, publicationRevision)
|
||||
await beforeHostSave?.()
|
||||
requirePublicationRevision(host.id, publicationRevision)
|
||||
await saveHost(host)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './existing-host-relay-routing'
|
||||
import {
|
||||
beginHostEndpointPublicationLifecycle,
|
||||
getHostEndpointPublicationLifecycle,
|
||||
type HostEndpointPublicationLifecycle
|
||||
} from './host-profile-publication'
|
||||
import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade'
|
||||
@@ -78,7 +79,14 @@ export function startMobileEndpointLifecycle(
|
||||
|
||||
if (initialHost.relay) {
|
||||
// Why: relay publication supersedes any committed journal retained by the retired direct owner.
|
||||
void retireMobileRelayDirectUpgradeJournalForRelayHost(initialHost.id).catch(() => {})
|
||||
void retireMobileRelayDirectUpgradeJournalForRelayHost(initialHost.id, () => {
|
||||
const current = getHostEndpointPublicationLifecycle(initialHost.id)
|
||||
return (
|
||||
!stopped &&
|
||||
current.generation === endpointLifecycle.generation &&
|
||||
current.profileRevision === endpointLifecycle.profileRevision
|
||||
)
|
||||
}).catch(() => {})
|
||||
owner = createSupervisor(
|
||||
logical,
|
||||
initialHost,
|
||||
|
||||
@@ -37,4 +37,27 @@ describe('mobile relay direct upgrade journal retirement', () => {
|
||||
JSON.stringify(journal)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not clear a replacement journal after the owning lifecycle stops', async () => {
|
||||
const journal = createMobileRelayDirectUpgradeJournal('host-1', (length) =>
|
||||
new Uint8Array(length).fill(8)
|
||||
)
|
||||
let resolveRead: ((raw: string) => void) | null = null
|
||||
keychain.readPairingKeychainItem.mockReturnValueOnce(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
)
|
||||
let active = true
|
||||
const retirement = retireMobileRelayDirectUpgradeJournalForRelayHost(
|
||||
journal.hostId,
|
||||
() => active
|
||||
)
|
||||
|
||||
active = false
|
||||
resolveRead?.(JSON.stringify(journal))
|
||||
await retirement
|
||||
|
||||
expect(keychain.deletePairingKeychainItemIfMatches).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,10 +82,11 @@ export function deleteMobileRelayDirectUpgradeJournalIfCurrent(
|
||||
}
|
||||
|
||||
export async function retireMobileRelayDirectUpgradeJournalForRelayHost(
|
||||
hostId: string
|
||||
hostId: string,
|
||||
shouldRetire: () => boolean = () => true
|
||||
): Promise<void> {
|
||||
const journal = await readMobileRelayDirectUpgradeJournal(hostId)
|
||||
if (journal) {
|
||||
if (journal && shouldRetire()) {
|
||||
await deleteMobileRelayDirectUpgradeJournalIfCurrent(journal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +442,7 @@ describe('mobile rpc-client request deadline', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await settled
|
||||
expect(outcome).toBe('Request timed out: status.get')
|
||||
expect(outcome).toBe('Request timed out: worktree.list')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
@@ -116,11 +116,15 @@ describe('stable logical RPC client', () => {
|
||||
await client.migrateTo(nextSession, 'relay')
|
||||
|
||||
await expect(verifying).resolves.toBeUndefined()
|
||||
expect(nextSession.sendRequest).toHaveBeenCalledWith('status.get', undefined, {
|
||||
timeoutMs: expect.any(Number),
|
||||
budgetSpansConnect: true,
|
||||
strictDeadline: true
|
||||
})
|
||||
expect(nextSession.sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.list',
|
||||
{ limit: 1 },
|
||||
{
|
||||
timeoutMs: expect.any(Number),
|
||||
budgetSpansConnect: true,
|
||||
strictDeadline: true
|
||||
}
|
||||
)
|
||||
pending.resolve(success('late'))
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user