diff --git a/src/infra/approval-handler-runtime.test.ts b/src/infra/approval-handler-runtime.test.ts index 9d6922866d26..61ecdb1a1383 100644 --- a/src/infra/approval-handler-runtime.test.ts +++ b/src/infra/approval-handler-runtime.test.ts @@ -364,4 +364,44 @@ describe("createLazyChannelApprovalNativeRuntimeAdapter", () => { expect(onDelivered).toHaveBeenCalledWith({ request: { id: "exec:1" } }); expect(load).toHaveBeenCalledTimes(1); }); + + it("unbinds in-flight wrapped entry when stop() fires between deliverPending and bindPending", async () => { + const deliverGate = { resolve: () => {}, promise: Promise.resolve() }; + const deliverPromise = new Promise((resolve) => { + deliverGate.resolve = resolve; + }); + deliverGate.promise = deliverPromise; + const deliverPending = vi.fn(async () => { + await deliverPromise; + return { messageId: "in-flight" }; + }); + const bindPending = vi.fn().mockResolvedValue({ bindingId: "bound-in-flight" }); + const unbindPending = vi.fn(); + + const runtime = await createTestApprovalHandler( + makeNativeApprovalCapability({ + deliverPending, + bindPending, + unbindPending, + }), + ); + const approvalRuntime = expectApprovalRuntime(runtime); + const request = makeExecApprovalRequest("exec:in-flight"); + + const inflight = approvalRuntime.handleRequested(request); + await new Promise((r) => setTimeout(r, 0)); + + // stop() while deliverPending is parked — onStopped flips the closure flag. + await approvalRuntime.stop(); + deliverGate.resolve(); + await inflight; + + expect(unbindPending).toHaveBeenCalledTimes(1); + const unbind = firstCallArg(unbindPending) as + | { entry?: unknown; binding?: unknown; request?: unknown } + | undefined; + expect(unbind?.entry).toEqual({ messageId: "in-flight" }); + expect(unbind?.request).toBe(request); + expect(bindPending).not.toHaveBeenCalled(); + }); }); diff --git a/src/infra/approval-handler-runtime.ts b/src/infra/approval-handler-runtime.ts index 494d70c907b1..bd3ddb975f4b 100644 --- a/src/infra/approval-handler-runtime.ts +++ b/src/infra/approval-handler-runtime.ts @@ -442,6 +442,7 @@ export async function createChannelApprovalHandlerFromCapability(params: { } const log = createSubsystemLogger(params.label); const activeEntries = new Map(); + let stopped = false; const resolveApprovalKind = nativeRuntime.resolveApprovalKind ?? ((request: ApprovalRequest) => @@ -514,6 +515,15 @@ export async function createChannelApprovalHandlerFromCapability(params: { if (!entry) { return null; } + if (stopped) { + await nativeRuntime.interactions?.unbindPending?.({ + ...baseContext, + entry, + request, + approvalKind, + }); + return null; + } const binding = await nativeRuntime.interactions?.bindPending?.({ ...baseContext, entry, @@ -522,6 +532,16 @@ export async function createChannelApprovalHandlerFromCapability(params: { view: pendingContent.view, pendingPayload: pendingContent.payload, }); + if (stopped) { + await nativeRuntime.interactions?.unbindPending?.({ + ...baseContext, + entry, + ...(binding === undefined || binding === null ? {} : { binding }), + request, + approvalKind, + }); + return null; + } const wrapped: WrappedPendingEntry = { entry, ...(binding === undefined || binding === null ? {} : { binding }), @@ -654,6 +674,7 @@ export async function createChannelApprovalHandlerFromCapability(params: { }); }, onStopped: async () => { + stopped = true; if (activeEntries.size === 0) { activeEntries.clear(); return;