|
1 | 1 | import type { VatOneResolution } from '@agoric/swingset-liveslots'; |
2 | 2 | import type { Logger } from '@metamask/logger'; |
3 | | -import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 3 | +import { makeAbortSignalMock } from '@ocap/repo-tools/test-utils'; |
| 4 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
4 | 5 |
|
5 | 6 | import type { KernelQueue } from '../KernelQueue.ts'; |
6 | 7 | import { RemoteHandle } from './RemoteHandle.ts'; |
@@ -626,4 +627,119 @@ describe('RemoteHandle', () => { |
626 | 627 | // Verify they resolved independently (different values) |
627 | 628 | expect(kref1).not.toBe(kref2); |
628 | 629 | }); |
| 630 | + |
| 631 | + describe('redeemOcapURL timeout', () => { |
| 632 | + afterEach(() => { |
| 633 | + vi.restoreAllMocks(); |
| 634 | + }); |
| 635 | + |
| 636 | + it('sets up 30-second timeout using AbortSignal.timeout', async () => { |
| 637 | + const remote = makeRemote(); |
| 638 | + const mockOcapURL = 'ocap:test@peer'; |
| 639 | + |
| 640 | + let mockSignal: ReturnType<typeof makeAbortSignalMock> | undefined; |
| 641 | + vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms: number) => { |
| 642 | + mockSignal = makeAbortSignalMock(ms); |
| 643 | + return mockSignal; |
| 644 | + }); |
| 645 | + |
| 646 | + const urlPromise = remote.redeemOcapURL(mockOcapURL); |
| 647 | + |
| 648 | + // Verify AbortSignal.timeout was called with 30 seconds |
| 649 | + expect(AbortSignal.timeout).toHaveBeenCalledWith(30_000); |
| 650 | + expect(mockSignal?.timeoutMs).toBe(30_000); |
| 651 | + |
| 652 | + // Resolve the redemption to avoid hanging |
| 653 | + const sendCall = vi.mocked(mockRemoteComms.sendRemoteMessage).mock |
| 654 | + .calls[0]; |
| 655 | + const sentMessage = JSON.parse(sendCall?.[1] as string); |
| 656 | + const replyKey = sentMessage.params[1] as string; |
| 657 | + |
| 658 | + await remote.handleRemoteMessage( |
| 659 | + JSON.stringify({ |
| 660 | + method: 'redeemURLReply', |
| 661 | + params: [true, replyKey, 'ro+1'], |
| 662 | + }), |
| 663 | + ); |
| 664 | + |
| 665 | + await urlPromise; |
| 666 | + }); |
| 667 | + |
| 668 | + it('cleans up pending redemption when redemption succeeds before timeout', async () => { |
| 669 | + const remote = makeRemote(); |
| 670 | + const mockOcapURL = 'ocap:test@peer'; |
| 671 | + const mockURLResolutionRRef = 'ro+6'; |
| 672 | + const mockURLResolutionKRef = 'ko1'; |
| 673 | + const expectedReplyKey = '1'; |
| 674 | + |
| 675 | + let mockSignal: ReturnType<typeof makeAbortSignalMock> | undefined; |
| 676 | + vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms: number) => { |
| 677 | + mockSignal = makeAbortSignalMock(ms); |
| 678 | + return mockSignal; |
| 679 | + }); |
| 680 | + |
| 681 | + const urlPromise = remote.redeemOcapURL(mockOcapURL); |
| 682 | + |
| 683 | + // Send reply immediately (before timeout) |
| 684 | + const redeemURLReply = { |
| 685 | + method: 'redeemURLReply', |
| 686 | + params: [true, expectedReplyKey, mockURLResolutionRRef], |
| 687 | + }; |
| 688 | + await remote.handleRemoteMessage(JSON.stringify(redeemURLReply)); |
| 689 | + |
| 690 | + const kref = await urlPromise; |
| 691 | + expect(kref).toBe(mockURLResolutionKRef); |
| 692 | + |
| 693 | + // Verify timeout signal was not aborted |
| 694 | + expect(mockSignal?.aborted).toBe(false); |
| 695 | + |
| 696 | + // Verify cleanup happened - trying to handle another reply with the same key should fail |
| 697 | + await expect( |
| 698 | + remote.handleRemoteMessage(JSON.stringify(redeemURLReply)), |
| 699 | + ).rejects.toThrow(`unknown URL redemption reply key ${expectedReplyKey}`); |
| 700 | + }); |
| 701 | + |
| 702 | + it('cleans up pending redemption map entry on timeout', async () => { |
| 703 | + const remote = makeRemote(); |
| 704 | + const mockOcapURL = 'ocap:test@peer'; |
| 705 | + |
| 706 | + let mockSignal: ReturnType<typeof makeAbortSignalMock> | undefined; |
| 707 | + vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms: number) => { |
| 708 | + mockSignal = makeAbortSignalMock(ms); |
| 709 | + return mockSignal; |
| 710 | + }); |
| 711 | + |
| 712 | + // Start a redemption |
| 713 | + const urlPromise = remote.redeemOcapURL(mockOcapURL); |
| 714 | + |
| 715 | + // Get the reply key that was used |
| 716 | + const sendCall = vi.mocked(mockRemoteComms.sendRemoteMessage).mock |
| 717 | + .calls[0]; |
| 718 | + const sentMessage = JSON.parse(sendCall?.[1] as string); |
| 719 | + const replyKey = sentMessage.params[1] as string; |
| 720 | + |
| 721 | + // Wait for the promise to be set up and event listener registered |
| 722 | + await new Promise<void>((resolve) => queueMicrotask(() => resolve())); |
| 723 | + |
| 724 | + // Manually trigger the abort to simulate timeout |
| 725 | + mockSignal?.abort(); |
| 726 | + |
| 727 | + // Wait for the abort handler to execute |
| 728 | + await new Promise<void>((resolve) => queueMicrotask(() => resolve())); |
| 729 | + |
| 730 | + // Verify the promise rejects |
| 731 | + await expect(urlPromise).rejects.toThrow( |
| 732 | + 'URL redemption timed out after 30 seconds', |
| 733 | + ); |
| 734 | + |
| 735 | + // Verify cleanup happened - trying to handle a reply with the same key should fail |
| 736 | + const redeemURLReply = { |
| 737 | + method: 'redeemURLReply', |
| 738 | + params: [true, replyKey, 'ro+1'], |
| 739 | + }; |
| 740 | + await expect( |
| 741 | + remote.handleRemoteMessage(JSON.stringify(redeemURLReply)), |
| 742 | + ).rejects.toThrow(`unknown URL redemption reply key ${replyKey}`); |
| 743 | + }); |
| 744 | + }); |
629 | 745 | }); |
0 commit comments