fix(mobile): preserve raw RPC rejection timing

Return the transport promise directly and interpret replies separately so sibling Promise.all rejection order cannot change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-13 16:56:51 -04:00
parent 1445eca5b4
commit e84270fa04
11 changed files with 29 additions and 32 deletions
@@ -406,7 +406,7 @@ async function loadMobileResumeMetadata(client: Pick<RpcClient, 'sendRequest'>):
projectGroupResponse?.ok === true
? (projectGroupResponse.result as { groups?: MobileAiVaultResumeProjectGroup[] })
: null
const settingsResult = settingsResponse?.interpret()
const settingsResult = settingsResponse ? optionalSettingsRead.interpret(settingsResponse) : null
const settings = settingsResult?.accepted
? (settingsResult.value as MobileAiVaultResumeSettings | null | undefined)
: null
@@ -89,7 +89,7 @@ export function useNewWorkspaceCreateSubmit(args: {
let latestRuntimeSettings = args.runtimeSettings
try {
const settingsReply = await settingsRead.request(client)
const settings = settingsReply.interpret()
const settings = settingsRead.interpret(settingsReply)
if (settings.accepted) {
latestRuntimeSettings = settings.value as NewWorktreeRuntimeSettings
args.setRuntimeSettings(latestRuntimeSettings)
+1 -1
View File
@@ -82,7 +82,7 @@ export function fetchMobileHomeTaskProviders(
if (disposed()) {
return
}
const settingsResult = settingsResponse.interpret()
const settingsResult = settingsRead.interpret(settingsResponse)
const settings = settingsResult.accepted
? ((settingsResult.value ?? {}) as HomeTaskSettings)
: {}
@@ -133,7 +133,9 @@ export function useHostRepoMetadata(args: {
if (clientRef.current !== requestClient || hostId !== requestHostId) {
return
}
const hostSettingsResult = hostSettings?.interpret()
const hostSettingsResult = hostSettings
? optionalSettingsRead.interpret(hostSettings)
: null
setHostLabelById(
buildHostLabelById({
sshTargets: readSshTargets(sshTargets?.ok ? sshTargets.result : null),
@@ -27,7 +27,7 @@ export async function loadMobileNewTabAgentOptions(args: {
newTabSettingsRead.request(client),
detectedAgentsRequest
])
const readSettings = settingsResponse.interpret()
const readSettings = newTabSettingsRead.interpret(settingsResponse)
if (!detectedResponse.ok) {
throw new Error((detectedResponse as RpcFailure).error.message)
}
@@ -39,7 +39,7 @@ export function usePRBotAuthorOverrides(
if (stale) {
return
}
const overrides = response.interpret()
const overrides = botOverridesRead.interpret(response)
if (overrides.accepted) {
setLogins(overrides.value)
}
@@ -260,7 +260,7 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel
return
}
const settingsResult = settingsResponse.interpret()
const settingsResult = settingsRead.interpret(settingsResponse)
const settings = settingsResult.accepted
? ((settingsResult.value ?? {}) as RuntimeTaskSettings)
: {}
@@ -74,7 +74,7 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
let latestRuntimeTaskSettings = runtimeTaskSettings
try {
const settingsReply = await settingsRead.request(client)
const settingsResult = settingsReply.interpret()
const settingsResult = settingsRead.interpret(settingsReply)
if (settingsResult.accepted) {
latestRuntimeTaskSettings = (settingsResult.value ?? {}) as RuntimeTaskSettings
setRuntimeTaskSettings(latestRuntimeTaskSettings)
@@ -178,6 +178,3 @@ export type LegacyResultRpcDefinition<
acceptance: Acceptance
read: RpcCompatibleReader<unknown, Variant, Value>
}
/** Only the bound policy can turn this opaque reply into a value, after caller guards. */
export type DeferredRpcInterpretation<Value> = { readonly interpret: () => Value }
+7 -9
View File
@@ -25,8 +25,7 @@ import type {
RequireResultRpcDefinition,
StreamOpenerRpcDefinition,
RpcVerdict,
LegacyResultRpcDefinition,
DeferredRpcInterpretation
LegacyResultRpcDefinition
} from './rpc-operation-contract'
type RpcOperationDefinitionInput =
@@ -253,7 +252,7 @@ type RpcSendArguments<Method extends RpcMethodName> =
? [params?: RpcSendParams<Method>, options?: SendRequestOptions]
: [params: RpcSendParams<Method>, options?: SendRequestOptions]
/** Binds sending and interpretation without exposing envelopes or capturing transport failures. */
/** Binds sending and interpretation while preserving the transport promise identity. */
export function bindDeferredRpcOperation<
Method extends RpcMethodName,
Acceptance extends RpcAcceptanceName,
@@ -261,14 +260,10 @@ export function bindDeferredRpcOperation<
Value
>(operation: RpcOperation<Method, Acceptance, Variant, Value, 'after-caller-barrier'>) {
type Verdict = RpcVerdict<Acceptance, Value>
const defer = (response: RpcResponse): DeferredRpcInterpretation<Verdict> => ({
interpret: () =>
interpretRpcOutcome(operation, classifyRpcReply(operation, response)) as Verdict
})
return Object.freeze({
operation,
request(client: UnvalidatedRpcRequestPort, ...args: RpcSendArguments<Method>) {
return client.sendRequest(operation.method, ...args).then(defer)
return client.sendRequest(operation.method, ...args)
},
requestSingleFlight(
client: RpcClient,
@@ -277,7 +272,10 @@ export function bindDeferredRpcOperation<
? [params?: RpcSendParams<Method>]
: [params: RpcSendParams<Method>]
) {
return sendSingleFlightRequest(client, hostId, operation.method, args[0]).then(defer)
return sendSingleFlightRequest(client, hostId, operation.method, args[0])
},
interpret(response: RpcResponse): Verdict {
return interpretRpcOutcome(operation, classifyRpcReply(operation, response)) as Verdict
}
})
}
@@ -51,14 +51,14 @@ describe('settings historical acceptance', () => {
it('distinguishes a skipped refusal from an accepted absent settings member', async () => {
const skipped = await settingsRead.request(replyWith(refusal()))
const missing = await settingsRead.request(replyWith(success({})))
expect(skipped.interpret()).toEqual({ accepted: false })
expect(missing.interpret()).toEqual({ accepted: true, value: undefined })
expect(settingsRead.interpret(skipped)).toEqual({ accepted: false })
expect(settingsRead.interpret(missing)).toEqual({ accepted: true, value: undefined })
})
it('retains opaque settings fields and reference identity without tightening acceptance', async () => {
const value = { futureField: { nested: ['kept'] }, disabledTuiAgents: 'legacy-value' }
const reply = await settingsRead.request(replyWith(success({ settings: value })))
const result = reply.interpret()
const result = settingsRead.interpret(reply)
expect(result.accepted && result.value).toBe(value)
})
@@ -66,29 +66,29 @@ describe('settings historical acceptance', () => {
'preserves the unguarded settings read for %s only when interpreted',
async (value) => {
const reply = await settingsRead.request(replyWith(success(value)))
expect(() => reply.interpret()).toThrow(TypeError)
expect(() => reply.interpret()).toThrow(
expect(() => settingsRead.interpret(reply)).toThrow(TypeError)
expect(() => settingsRead.interpret(reply)).toThrow(
`Cannot read properties of ${String(value)} (reading 'settings')`
)
const optional = await optionalSettingsRead.request(replyWith(success(value)))
expect(optional.interpret()).toEqual({ accepted: true, value: undefined })
expect(optionalSettingsRead.interpret(optional)).toEqual({ accepted: true, value: undefined })
}
)
it.each([true, false, 0, 'text', []])('preserves property boxing for %j', async (value) => {
const reply = await settingsRead.request(replyWith(success(value)))
expect(reply.interpret()).toEqual({ accepted: true, value: undefined })
expect(settingsRead.interpret(reply)).toEqual({ accepted: true, value: undefined })
})
it('filters bot logins while distinguishing a refused refresh', async () => {
const reply = await botOverridesRead.request(
replyWith(success({ settings: { prBotAuthorOverrides: ['bot', 3, null, ''] } }))
)
expect(reply.interpret()).toEqual({ accepted: true, value: ['bot', ''] })
expect(botOverridesRead.interpret(reply)).toEqual({ accepted: true, value: ['bot', ''] })
const refused = await botOverridesRead.request(replyWith(refusal()))
expect(refused.interpret()).toEqual({ accepted: false })
expect(botOverridesRead.interpret(refused)).toEqual({ accepted: false })
const empty = await botOverridesRead.request(replyWith(success(null)))
expect(empty.interpret()).toEqual({ accepted: true, value: [] })
expect(botOverridesRead.interpret(empty)).toEqual({ accepted: true, value: [] })
})
it('does not read a stale payload until its caller permits interpretation', async () => {
@@ -103,7 +103,7 @@ describe('settings historical acceptance', () => {
)
)
expect(read).not.toHaveBeenCalled()
reply.interpret()
settingsRead.interpret(reply)
expect(read).toHaveBeenCalledOnce()
})
@@ -194,7 +194,7 @@ describe('new-tab settlement barriers', () => {
)
await expect(load(client)).rejects.toThrow('agents refused')
const reply = await newTabSettingsRead.request(replyWith(success(null)))
const readSettings = reply.interpret()
const readSettings = newTabSettingsRead.interpret(reply)
expect(() => readSettings()).toThrow(TypeError)
})
})