fix(pi): retain ownership for uncertain mutations

This commit is contained in:
2026-08-25 23:53:54 +08:00
parent 621ebb1781
commit 019cbb115a
21 changed files with 1113 additions and 67 deletions

View File

@@ -18,16 +18,24 @@ export type PiRpcResponse<T = unknown> = {
export type PiRpcEvent = Record<string, unknown> & { type: string };
export type PiRpcRetryPolicy = 'none' | 'read-only-once';
export type PiRpcLateResult =
| { response: PiRpcResponse; error?: never }
| { response?: never; error: PiProcessError };
export type PiRpcRequestOptions = {
signal?: AbortSignal;
timeoutMs?: number;
retry?: PiRpcRetryPolicy;
retainAfterTimeout?: boolean;
onLateResult?(result: PiRpcLateResult): void;
};
type PendingRequest = {
commandType: string;
timedOut: boolean;
resolve(response: PiRpcResponse): void;
reject(error: PiProcessError): void;
notifyLate(result: PiRpcLateResult): void;
cancel(): void;
};
@@ -155,17 +163,30 @@ export class PiRpcClient {
}
pending.cancel();
this.pending.delete(value.id);
if (value.success) pending.resolve(value);
else {
pending.reject(new PiProcessError(
if (value.success) {
if (pending.timedOut) pending.notifyLate({ response: value });
else pending.resolve(value);
} else {
const error = new PiProcessError(
'PI_RPC_RESPONSE_ERROR',
`Pi RPC ${value.command ?? pending.commandType} failed: ${value.error ?? 'unknown error'}`,
{ generation: this.generation },
));
);
if (pending.timedOut) pending.notifyLate({ error });
else pending.reject(error);
}
return;
}
if (value.type === 'agent_settled') {
for (const [id, pending] of this.pending) {
if (!pending.timedOut) continue;
pending.cancel();
this.pending.delete(id);
this.retire(id);
}
}
for (const listener of this.listeners) {
try {
listener(value as PiRpcEvent);
@@ -185,7 +206,8 @@ export class PiRpcClient {
for (const [id, pending] of this.pending) {
pending.cancel();
this.retire(id);
pending.reject(error);
if (pending.timedOut) pending.notifyLate({ error });
else pending.reject(error);
}
this.pending.clear();
}
@@ -209,8 +231,12 @@ export class PiRpcClient {
const response = new Promise<PiRpcResponse<T>>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pending.delete(id);
this.retire(id);
const pending = this.pending.get(id);
if (options.retainAfterTimeout && pending) pending.timedOut = true;
else {
this.pending.delete(id);
this.retire(id);
}
reject(new PiProcessError(
'PI_RPC_TIMEOUT',
`Pi RPC ${command.type} timed out after ${timeoutMs}ms`,
@@ -218,18 +244,33 @@ export class PiRpcClient {
));
}, timeoutMs);
const abort = (): void => {
const pending = this.pending.get(id);
this.pending.delete(id);
this.retire(id);
clearTimeout(timeout);
reject(new PiProcessError('PI_RPC_ABORTED', `Pi RPC ${command.type} was aborted`, {
const error = new PiProcessError('PI_RPC_ABORTED', `Pi RPC ${command.type} was aborted`, {
generation: this.generation,
}));
});
if (pending?.timedOut) pending.notifyLate({ error });
else reject(error);
};
options.signal?.addEventListener('abort', abort, { once: true });
this.pending.set(id, {
commandType: command.type,
timedOut: false,
resolve: (value) => resolve(value as PiRpcResponse<T>),
reject,
notifyLate: (result) => {
try {
options.onLateResult?.(result);
} catch (error) {
try {
this.onEventListenerError?.(error);
} catch {
// Diagnostics must not affect transport state.
}
}
},
cancel: () => {
clearTimeout(timeout);
options.signal?.removeEventListener('abort', abort);
@@ -248,6 +289,12 @@ export class PiRpcClient {
return record;
} catch (error) {
const pending = this.pending.get(id);
if (error instanceof PiProcessError
&& error.code === 'PI_RPC_TIMEOUT'
&& options.retainAfterTimeout
&& pending?.timedOut) {
throw error;
}
if (pending) {
pending.cancel();
this.pending.delete(id);