fix(rpc): cancelled aborted extension dialogs
Moved RPC dialog request lifecycle into a reusable helper that emits a cancel frame targeting the original request before settling an aborted local promise. Added coverage for remote confirmation cancellation and pending-request cleanup.
This commit is contained in:
@@ -603,6 +603,57 @@ export function requestRpcEditor(
|
||||
} as RpcExtensionUIRequest);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Sends an RPC extension dialog and cancels the remote presentation when its signal aborts. */
|
||||
export function requestRpcDialog<T>(
|
||||
pendingRequests: Map<string, PendingExtensionRequest>,
|
||||
output: RpcOutput,
|
||||
opts: ExtensionUIDialogOptions | undefined,
|
||||
defaultValue: T,
|
||||
request: Record<string, unknown>,
|
||||
parseResponse: (response: RpcExtensionUIResponse) => T,
|
||||
): Promise<T> {
|
||||
if (opts?.signal?.aborted) return Promise.resolve(defaultValue);
|
||||
|
||||
const id = Snowflake.next() as string;
|
||||
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
pendingRequests.delete(id);
|
||||
};
|
||||
const onAbort = () => {
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id: Snowflake.next() as string,
|
||||
method: "cancel",
|
||||
targetId: id,
|
||||
} as RpcExtensionUIRequest);
|
||||
cleanup();
|
||||
resolve(defaultValue);
|
||||
};
|
||||
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
if (opts?.timeout !== undefined) {
|
||||
timeoutId = setTimeout(() => {
|
||||
opts.onTimeout?.();
|
||||
cleanup();
|
||||
resolve(defaultValue);
|
||||
}, opts.timeout);
|
||||
}
|
||||
|
||||
pendingRequests.set(id, {
|
||||
resolve: response => {
|
||||
cleanup();
|
||||
resolve(parseResponse(response));
|
||||
},
|
||||
reject,
|
||||
});
|
||||
output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest);
|
||||
return promise;
|
||||
}
|
||||
/**
|
||||
* Run in RPC mode.
|
||||
* Listens for JSON commands on stdin, outputs events and responses on stdout.
|
||||
@@ -685,56 +736,14 @@ export async function runRpcMode(
|
||||
private output: (obj: RpcResponse | RpcExtensionUIRequest | object) => void,
|
||||
) {}
|
||||
|
||||
/** Helper for dialog methods with signal/timeout support */
|
||||
#createDialogPromise<T>(
|
||||
opts: ExtensionUIDialogOptions | undefined,
|
||||
defaultValue: T,
|
||||
request: Record<string, unknown>,
|
||||
parseResponse: (response: RpcExtensionUIResponse) => T,
|
||||
): Promise<T> {
|
||||
if (opts?.signal?.aborted) return Promise.resolve(defaultValue);
|
||||
|
||||
const id = Snowflake.next() as string;
|
||||
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
this.pendingRequests.delete(id);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
resolve(defaultValue);
|
||||
};
|
||||
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
if (opts?.timeout !== undefined) {
|
||||
timeoutId = setTimeout(() => {
|
||||
opts.onTimeout?.();
|
||||
cleanup();
|
||||
resolve(defaultValue);
|
||||
}, opts.timeout);
|
||||
}
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: (response: RpcExtensionUIResponse) => {
|
||||
cleanup();
|
||||
resolve(parseResponse(response));
|
||||
},
|
||||
reject,
|
||||
});
|
||||
this.output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest);
|
||||
return promise;
|
||||
}
|
||||
|
||||
select(
|
||||
title: string,
|
||||
options: ExtensionUISelectItem[],
|
||||
dialogOptions?: ExtensionUIDialogOptions,
|
||||
): Promise<string | undefined> {
|
||||
return this.#createDialogPromise(
|
||||
return requestRpcDialog(
|
||||
this.pendingRequests,
|
||||
this.output,
|
||||
dialogOptions,
|
||||
undefined,
|
||||
{
|
||||
@@ -748,7 +757,9 @@ export async function runRpcMode(
|
||||
}
|
||||
|
||||
confirm(title: string, message: string, dialogOptions?: ExtensionUIDialogOptions): Promise<boolean> {
|
||||
return this.#createDialogPromise(
|
||||
return requestRpcDialog(
|
||||
this.pendingRequests,
|
||||
this.output,
|
||||
dialogOptions,
|
||||
false,
|
||||
{ method: "confirm", title, message, timeout: dialogOptions?.timeout },
|
||||
@@ -768,7 +779,9 @@ export async function runRpcMode(
|
||||
placeholder?: string,
|
||||
dialogOptions?: ExtensionUIDialogOptions,
|
||||
): Promise<string | undefined> {
|
||||
return this.#createDialogPromise(
|
||||
return requestRpcDialog(
|
||||
this.pendingRequests,
|
||||
this.output,
|
||||
dialogOptions,
|
||||
undefined,
|
||||
{ method: "input", title, placeholder, timeout: dialogOptions?.timeout },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "bun:test";
|
||||
import { type PendingExtensionRequest, requestRpcDialog } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-mode";
|
||||
|
||||
describe("RPC extension UI", () => {
|
||||
it("cancels the remote dialog when its signal aborts", async () => {
|
||||
const pendingRequests = new Map<string, PendingExtensionRequest>();
|
||||
const output = vi.fn<(frame: object) => void>();
|
||||
const controller = new AbortController();
|
||||
const result = requestRpcDialog(
|
||||
pendingRequests,
|
||||
output,
|
||||
{ signal: controller.signal },
|
||||
false,
|
||||
{ method: "confirm", title: "High-risk command", message: "Allow this command?" },
|
||||
response => ("confirmed" in response ? response.confirmed : false),
|
||||
);
|
||||
const request = output.mock.calls[0]?.[0];
|
||||
if (!request || !("id" in request) || typeof request.id !== "string") {
|
||||
throw new Error("Expected the RPC dialog request to carry an id");
|
||||
}
|
||||
|
||||
controller.abort();
|
||||
|
||||
expect(await result).toBe(false);
|
||||
expect(output).toHaveBeenNthCalledWith(1, {
|
||||
type: "extension_ui_request",
|
||||
id: request.id,
|
||||
method: "confirm",
|
||||
title: "High-risk command",
|
||||
message: "Allow this command?",
|
||||
});
|
||||
expect(output).toHaveBeenNthCalledWith(2, {
|
||||
type: "extension_ui_request",
|
||||
id: expect.any(String),
|
||||
method: "cancel",
|
||||
targetId: request.id,
|
||||
});
|
||||
expect(pendingRequests.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user