From 91b696fab764bc8c917eed0af732f2f57967d5c7 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 14 Jan 2026 00:56:06 +0100 Subject: [PATCH 01/10] chore(remotes): move MessageQueue to platform/message-queue --- .../{MessageQueue.test.ts => platform/message-queue.test.ts} | 2 +- .../src/remotes/{MessageQueue.ts => platform/message-queue.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/ocap-kernel/src/remotes/{MessageQueue.test.ts => platform/message-queue.test.ts} (99%) rename packages/ocap-kernel/src/remotes/{MessageQueue.ts => platform/message-queue.ts} (100%) diff --git a/packages/ocap-kernel/src/remotes/MessageQueue.test.ts b/packages/ocap-kernel/src/remotes/platform/message-queue.test.ts similarity index 99% rename from packages/ocap-kernel/src/remotes/MessageQueue.test.ts rename to packages/ocap-kernel/src/remotes/platform/message-queue.test.ts index d08b46704..e13fcb4ae 100644 --- a/packages/ocap-kernel/src/remotes/MessageQueue.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/message-queue.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { MessageQueue } from './MessageQueue.ts'; +import { MessageQueue } from './message-queue.ts'; describe('MessageQueue', () => { let queue: MessageQueue; diff --git a/packages/ocap-kernel/src/remotes/MessageQueue.ts b/packages/ocap-kernel/src/remotes/platform/message-queue.ts similarity index 100% rename from packages/ocap-kernel/src/remotes/MessageQueue.ts rename to packages/ocap-kernel/src/remotes/platform/message-queue.ts From 57f7f77162682f51c78735351d4128c785c9aa09 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 14 Jan 2026 00:56:42 +0100 Subject: [PATCH 02/10] chore(remotes): move ReconnectionManager to platform/reconnection --- .../reconnection.test.ts} | 2 +- .../{ReconnectionManager.ts => platform/reconnection.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/ocap-kernel/src/remotes/{ReconnectionManager.test.ts => platform/reconnection.test.ts} (99%) rename packages/ocap-kernel/src/remotes/{ReconnectionManager.ts => platform/reconnection.ts} (100%) diff --git a/packages/ocap-kernel/src/remotes/ReconnectionManager.test.ts b/packages/ocap-kernel/src/remotes/platform/reconnection.test.ts similarity index 99% rename from packages/ocap-kernel/src/remotes/ReconnectionManager.test.ts rename to packages/ocap-kernel/src/remotes/platform/reconnection.test.ts index df216eb94..c4469e182 100644 --- a/packages/ocap-kernel/src/remotes/ReconnectionManager.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/reconnection.test.ts @@ -1,7 +1,7 @@ import * as kernelUtils from '@metamask/kernel-utils'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { ReconnectionManager } from './ReconnectionManager.ts'; +import { ReconnectionManager } from './reconnection.ts'; // Mock the calculateReconnectionBackoff function vi.mock('@metamask/kernel-utils', async () => { diff --git a/packages/ocap-kernel/src/remotes/ReconnectionManager.ts b/packages/ocap-kernel/src/remotes/platform/reconnection.ts similarity index 100% rename from packages/ocap-kernel/src/remotes/ReconnectionManager.ts rename to packages/ocap-kernel/src/remotes/platform/reconnection.ts From 775e959a489431dfa76c59cd5dac4f2bd24b7d2c Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 14 Jan 2026 00:57:34 +0100 Subject: [PATCH 03/10] chore(remotes): move ConnectionFactory to platform/connection-factory --- .../connection-factory.test.ts} | 16 +++++++++------- .../connection-factory.ts} | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) rename packages/ocap-kernel/src/remotes/{ConnectionFactory.test.ts => platform/connection-factory.test.ts} (98%) rename packages/ocap-kernel/src/remotes/{ConnectionFactory.ts => platform/connection-factory.ts} (99%) diff --git a/packages/ocap-kernel/src/remotes/ConnectionFactory.test.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts similarity index 98% rename from packages/ocap-kernel/src/remotes/ConnectionFactory.test.ts rename to packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts index fe9bb320a..054e0e6a2 100644 --- a/packages/ocap-kernel/src/remotes/ConnectionFactory.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.test.ts @@ -2,7 +2,7 @@ import { MuxerClosedError } from '@libp2p/interface'; import { AbortError } from '@metamask/kernel-errors'; import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import type { Channel } from './types.ts'; +import type { Channel } from '../types.ts'; // Mock heavy/libp2p related deps with minimal shims we can assert against. @@ -133,7 +133,7 @@ vi.mock('libp2p', () => ({ describe('ConnectionFactory', () => { let factory: Awaited< - ReturnType + ReturnType >; const keySeed = '0x1234567890abcdef'; const knownRelays = [ @@ -203,10 +203,12 @@ describe('ConnectionFactory', () => { maxRetryAttempts?: number, ): Promise< Awaited< - ReturnType + ReturnType< + typeof import('./connection-factory.ts').ConnectionFactory.make + > > > { - const { ConnectionFactory } = await import('./ConnectionFactory.ts'); + const { ConnectionFactory } = await import('./connection-factory.ts'); const { Logger } = await import('@metamask/logger'); return ConnectionFactory.make( keySeed, @@ -694,7 +696,7 @@ describe('ConnectionFactory', () => { // Re-import ConnectionFactory to use the new mock vi.resetModules(); - const { ConnectionFactory } = await import('./ConnectionFactory.ts'); + const { ConnectionFactory } = await import('./connection-factory.ts'); const { Logger } = await import('@metamask/logger'); factory = await ConnectionFactory.make( keySeed, @@ -742,7 +744,7 @@ describe('ConnectionFactory', () => { // Re-import ConnectionFactory to use the new mock vi.resetModules(); - const { ConnectionFactory } = await import('./ConnectionFactory.ts'); + const { ConnectionFactory } = await import('./connection-factory.ts'); const { Logger } = await import('@metamask/logger'); factory = await ConnectionFactory.make( keySeed, @@ -793,7 +795,7 @@ describe('ConnectionFactory', () => { // Re-import ConnectionFactory to use the new mock vi.resetModules(); - const { ConnectionFactory } = await import('./ConnectionFactory.ts'); + const { ConnectionFactory } = await import('./connection-factory.ts'); const { Logger } = await import('@metamask/logger'); factory = await ConnectionFactory.make( keySeed, diff --git a/packages/ocap-kernel/src/remotes/ConnectionFactory.ts b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts similarity index 99% rename from packages/ocap-kernel/src/remotes/ConnectionFactory.ts rename to packages/ocap-kernel/src/remotes/platform/connection-factory.ts index db3ffe2a0..e5c08eb0f 100644 --- a/packages/ocap-kernel/src/remotes/ConnectionFactory.ts +++ b/packages/ocap-kernel/src/remotes/platform/connection-factory.ts @@ -17,7 +17,7 @@ import { multiaddr } from '@multiformats/multiaddr'; import { byteStream } from 'it-byte-stream'; import { createLibp2p } from 'libp2p'; -import type { Channel, InboundConnectionHandler } from './types.ts'; +import type { Channel, InboundConnectionHandler } from '../types.ts'; /** * Connection factory for libp2p network operations. From 14e09488df74001df5bb3cb94d820b5ef61761e8 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 14 Jan 2026 01:04:30 +0100 Subject: [PATCH 04/10] chore(remotes): move network to platform/transport Co-Authored-By: Claude Opus 4.5 --- packages/ocap-kernel/src/index.ts | 2 +- .../transport.test.ts} | 14 +++++++------- .../remotes/{network.ts => platform/transport.ts} | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) rename packages/ocap-kernel/src/remotes/{network.test.ts => platform/transport.test.ts} (99%) rename packages/ocap-kernel/src/remotes/{network.ts => platform/transport.ts} (99%) diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index c9fe1413c..52b1dd705 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -1,7 +1,7 @@ export { Kernel } from './Kernel.ts'; export { VatHandle } from './vats/VatHandle.ts'; export { VatSupervisor } from './vats/VatSupervisor.ts'; -export { initNetwork } from './remotes/network.ts'; +export { initNetwork } from './remotes/platform/transport.ts'; export type { ClusterConfig, KRef, diff --git a/packages/ocap-kernel/src/remotes/network.test.ts b/packages/ocap-kernel/src/remotes/platform/transport.test.ts similarity index 99% rename from packages/ocap-kernel/src/remotes/network.test.ts rename to packages/ocap-kernel/src/remotes/platform/transport.test.ts index 5b20d4c66..72172d2a6 100644 --- a/packages/ocap-kernel/src/remotes/network.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/transport.test.ts @@ -11,7 +11,7 @@ import { } from 'vitest'; // Import the module we're testing - must be after mocks are set up -let initNetwork: typeof import('./network.ts').initNetwork; +let initNetwork: typeof import('./transport.ts').initNetwork; // Mock MessageQueue const mockMessageQueue = { @@ -24,7 +24,7 @@ const mockMessageQueue = { messages: [] as string[], }; -vi.mock('./MessageQueue.ts', () => { +vi.mock('./message-queue.ts', () => { class MockMessageQueue { enqueue = mockMessageQueue.enqueue; @@ -63,7 +63,7 @@ const mockReconnectionManager = { clearPeer: vi.fn(), }; -vi.mock('./ReconnectionManager.ts', () => { +vi.mock('./reconnection.ts', () => { class MockReconnectionManager { isReconnecting = mockReconnectionManager.isReconnecting; @@ -106,7 +106,7 @@ const mockConnectionFactory = { closeChannel: vi.fn().mockResolvedValue(undefined), }; -vi.mock('./ConnectionFactory.ts', () => { +vi.mock('./connection-factory.ts', () => { return { ConnectionFactory: { make: vi.fn(async () => Promise.resolve(mockConnectionFactory)), @@ -172,7 +172,7 @@ vi.mock('uint8arrays', () => ({ describe('network.initNetwork', () => { // Import after all mocks are set up beforeAll(async () => { - const networkModule = await import('./network.ts'); + const networkModule = await import('./transport.ts'); initNetwork = networkModule.initNetwork; }); @@ -273,7 +273,7 @@ describe('network.initNetwork', () => { describe('initialization', () => { it('passes correct parameters to ConnectionFactory.make', async () => { - const { ConnectionFactory } = await import('./ConnectionFactory.ts'); + const { ConnectionFactory } = await import('./connection-factory.ts'); const keySeed = '0xabcd'; const knownRelays = [ '/dns4/relay1.example/tcp/443/wss/p2p/relay1', @@ -292,7 +292,7 @@ describe('network.initNetwork', () => { }); it('passes maxRetryAttempts to ConnectionFactory.make', async () => { - const { ConnectionFactory } = await import('./ConnectionFactory.ts'); + const { ConnectionFactory } = await import('./connection-factory.ts'); const keySeed = '0xabcd'; const maxRetryAttempts = 5; diff --git a/packages/ocap-kernel/src/remotes/network.ts b/packages/ocap-kernel/src/remotes/platform/transport.ts similarity index 99% rename from packages/ocap-kernel/src/remotes/network.ts rename to packages/ocap-kernel/src/remotes/platform/transport.ts index be2355a90..0dc109b2c 100644 --- a/packages/ocap-kernel/src/remotes/network.ts +++ b/packages/ocap-kernel/src/remotes/platform/transport.ts @@ -11,9 +11,9 @@ import { import { Logger } from '@metamask/logger'; import { toString as bufToString, fromString } from 'uint8arrays'; -import { ConnectionFactory } from './ConnectionFactory.ts'; -import { MessageQueue } from './MessageQueue.ts'; -import { ReconnectionManager } from './ReconnectionManager.ts'; +import { ConnectionFactory } from './connection-factory.ts'; +import { MessageQueue } from './message-queue.ts'; +import { ReconnectionManager } from './reconnection.ts'; import type { RemoteMessageHandler, SendRemoteMessage, @@ -21,7 +21,7 @@ import type { Channel, OnRemoteGiveUp, RemoteCommsOptions, -} from './types.ts'; +} from '../types.ts'; /** Default upper bound for queued outbound messages while reconnecting */ const DEFAULT_MAX_QUEUE = 200; From a8da1b59666379519a02d860e902c1d6714d8e84 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 14 Jan 2026 01:10:01 +0100 Subject: [PATCH 05/10] chore(remotes): move kernel-level modules to kernel/ Move RemoteManager, RemoteHandle, OcapURLManager, and remote-comms to the kernel/ subdirectory. These modules interact with kernel concepts like KernelStore, KernelQueue, and krefs. Co-Authored-By: Claude Opus 4.5 --- packages/ocap-kernel/src/Kernel.test.ts | 2 +- packages/ocap-kernel/src/Kernel.ts | 4 ++-- .../src/remotes/{ => kernel}/OcapURLManager.test.ts | 8 ++++---- .../src/remotes/{ => kernel}/OcapURLManager.ts | 6 +++--- .../src/remotes/{ => kernel}/RemoteHandle.test.ts | 12 ++++++------ .../src/remotes/{ => kernel}/RemoteHandle.ts | 10 +++++----- .../src/remotes/{ => kernel}/RemoteManager.test.ts | 12 ++++++------ .../src/remotes/{ => kernel}/RemoteManager.ts | 10 +++++----- .../src/remotes/{ => kernel}/remote-comms.test.ts | 8 ++++---- .../src/remotes/{ => kernel}/remote-comms.ts | 6 +++--- packages/ocap-kernel/test/remotes-mocks.ts | 2 +- 11 files changed, 40 insertions(+), 40 deletions(-) rename packages/ocap-kernel/src/remotes/{ => kernel}/OcapURLManager.test.ts (97%) rename packages/ocap-kernel/src/remotes/{ => kernel}/OcapURLManager.ts (95%) rename packages/ocap-kernel/src/remotes/{ => kernel}/RemoteHandle.test.ts (98%) rename packages/ocap-kernel/src/remotes/{ => kernel}/RemoteHandle.ts (98%) rename packages/ocap-kernel/src/remotes/{ => kernel}/RemoteManager.test.ts (98%) rename packages/ocap-kernel/src/remotes/{ => kernel}/RemoteManager.ts (97%) rename packages/ocap-kernel/src/remotes/{ => kernel}/remote-comms.test.ts (98%) rename packages/ocap-kernel/src/remotes/{ => kernel}/remote-comms.ts (98%) diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index 6a2a18de6..2ed7ab2a2 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -66,7 +66,7 @@ vi.mock('./KernelQueue.ts', () => { return { KernelQueue: mocks.KernelQueue }; }); -vi.mock('./remotes/RemoteManager.ts', () => { +vi.mock('./remotes/kernel/RemoteManager.ts', () => { return { RemoteManager: mocks.RemoteManager }; }); diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 879b090d3..8868e578b 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -12,8 +12,8 @@ import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; import { KernelServiceManager } from './KernelServiceManager.ts'; import type { KernelService } from './KernelServiceManager.ts'; -import { OcapURLManager } from './remotes/OcapURLManager.ts'; -import { RemoteManager } from './remotes/RemoteManager.ts'; +import { OcapURLManager } from './remotes/kernel/OcapURLManager.ts'; +import { RemoteManager } from './remotes/kernel/RemoteManager.ts'; import type { RemoteCommsOptions } from './remotes/types.ts'; import { kernelHandlers } from './rpc/index.ts'; import type { PingVatResult } from './rpc/index.ts'; diff --git a/packages/ocap-kernel/src/remotes/OcapURLManager.test.ts b/packages/ocap-kernel/src/remotes/kernel/OcapURLManager.test.ts similarity index 97% rename from packages/ocap-kernel/src/remotes/OcapURLManager.test.ts rename to packages/ocap-kernel/src/remotes/kernel/OcapURLManager.test.ts index cbf3f9034..3d29ec6fc 100644 --- a/packages/ocap-kernel/src/remotes/OcapURLManager.test.ts +++ b/packages/ocap-kernel/src/remotes/kernel/OcapURLManager.test.ts @@ -4,10 +4,10 @@ import type { Mock } from 'vitest'; import { OcapURLManager } from './OcapURLManager.ts'; import type { RemoteHandle } from './RemoteHandle.ts'; import type { RemoteManager } from './RemoteManager.ts'; -import type { RemoteComms } from './types.ts'; -import { createMockRemotesFactory } from '../../test/remotes-mocks.ts'; -import type { SlotValue } from '../liveslots/kernel-marshal.ts'; -import { kslot } from '../liveslots/kernel-marshal.ts'; +import { createMockRemotesFactory } from '../../../test/remotes-mocks.ts'; +import type { SlotValue } from '../../liveslots/kernel-marshal.ts'; +import { kslot } from '../../liveslots/kernel-marshal.ts'; +import type { RemoteComms } from '../types.ts'; type RedeemService = { redeem: (url: string) => Promise; diff --git a/packages/ocap-kernel/src/remotes/OcapURLManager.ts b/packages/ocap-kernel/src/remotes/kernel/OcapURLManager.ts similarity index 95% rename from packages/ocap-kernel/src/remotes/OcapURLManager.ts rename to packages/ocap-kernel/src/remotes/kernel/OcapURLManager.ts index ab55f2813..cd7901989 100644 --- a/packages/ocap-kernel/src/remotes/OcapURLManager.ts +++ b/packages/ocap-kernel/src/remotes/kernel/OcapURLManager.ts @@ -1,10 +1,10 @@ import { Far } from '@endo/marshal'; -import { kslot, krefOf } from '../liveslots/kernel-marshal.ts'; -import type { SlotValue } from '../liveslots/kernel-marshal.ts'; -import type { KRef } from '../types.ts'; import { parseOcapURL } from './remote-comms.ts'; import type { RemoteManager } from './RemoteManager.ts'; +import { kslot, krefOf } from '../../liveslots/kernel-marshal.ts'; +import type { SlotValue } from '../../liveslots/kernel-marshal.ts'; +import type { KRef } from '../../types.ts'; type OcapURLManagerConstructorProps = { remoteManager: RemoteManager; diff --git a/packages/ocap-kernel/src/remotes/RemoteHandle.test.ts b/packages/ocap-kernel/src/remotes/kernel/RemoteHandle.test.ts similarity index 98% rename from packages/ocap-kernel/src/remotes/RemoteHandle.test.ts rename to packages/ocap-kernel/src/remotes/kernel/RemoteHandle.test.ts index 4018fbab4..c0ab82fe5 100644 --- a/packages/ocap-kernel/src/remotes/RemoteHandle.test.ts +++ b/packages/ocap-kernel/src/remotes/kernel/RemoteHandle.test.ts @@ -3,13 +3,13 @@ import type { Logger } from '@metamask/logger'; import { makeAbortSignalMock } from '@ocap/repo-tools/test-utils'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { KernelQueue } from '../KernelQueue.ts'; import { RemoteHandle } from './RemoteHandle.ts'; -import { createMockRemotesFactory } from '../../test/remotes-mocks.ts'; -import type { KernelStore } from '../store/index.ts'; -import { parseRef } from '../store/utils/parse-ref.ts'; -import type { Message, RRef } from '../types.ts'; -import type { RemoteComms } from './types.ts'; +import { createMockRemotesFactory } from '../../../test/remotes-mocks.ts'; +import type { KernelQueue } from '../../KernelQueue.ts'; +import type { KernelStore } from '../../store/index.ts'; +import { parseRef } from '../../store/utils/parse-ref.ts'; +import type { Message, RRef } from '../../types.ts'; +import type { RemoteComms } from '../types.ts'; let mockKernelStore: KernelStore; let mockRemoteComms: RemoteComms; diff --git a/packages/ocap-kernel/src/remotes/RemoteHandle.ts b/packages/ocap-kernel/src/remotes/kernel/RemoteHandle.ts similarity index 98% rename from packages/ocap-kernel/src/remotes/RemoteHandle.ts rename to packages/ocap-kernel/src/remotes/kernel/RemoteHandle.ts index 71e3f72a9..968b01ca7 100644 --- a/packages/ocap-kernel/src/remotes/RemoteHandle.ts +++ b/packages/ocap-kernel/src/remotes/kernel/RemoteHandle.ts @@ -7,17 +7,17 @@ import { performDropImports, performRetireImports, performExportCleanup, -} from '../garbage-collection/gc-handlers.ts'; -import type { KernelQueue } from '../KernelQueue.ts'; -import type { KernelStore } from '../store/index.ts'; +} from '../../garbage-collection/gc-handlers.ts'; +import type { KernelQueue } from '../../KernelQueue.ts'; +import type { KernelStore } from '../../store/index.ts'; import type { RemoteId, ERef, EndpointHandle, Message, CrankResults, -} from '../types.ts'; -import type { RemoteComms } from './types.ts'; +} from '../../types.ts'; +import type { RemoteComms } from '../types.ts'; type RemoteHandleConstructorProps = { remoteId: RemoteId; diff --git a/packages/ocap-kernel/src/remotes/RemoteManager.test.ts b/packages/ocap-kernel/src/remotes/kernel/RemoteManager.test.ts similarity index 98% rename from packages/ocap-kernel/src/remotes/RemoteManager.test.ts rename to packages/ocap-kernel/src/remotes/kernel/RemoteManager.test.ts index 8aaae7b3a..32de67508 100644 --- a/packages/ocap-kernel/src/remotes/RemoteManager.test.ts +++ b/packages/ocap-kernel/src/remotes/kernel/RemoteManager.test.ts @@ -1,14 +1,14 @@ import { Logger } from '@metamask/logger'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { makeMapKernelDatabase } from '../../test/storage.ts'; -import type { KernelQueue } from '../KernelQueue.ts'; import * as remoteComms from './remote-comms.ts'; -import type { RemoteComms } from './types.ts'; -import { makeKernelStore } from '../store/index.ts'; -import type { PlatformServices } from '../types.ts'; import { RemoteManager } from './RemoteManager.ts'; -import { createMockRemotesFactory } from '../../test/remotes-mocks.ts'; +import { createMockRemotesFactory } from '../../../test/remotes-mocks.ts'; +import { makeMapKernelDatabase } from '../../../test/storage.ts'; +import type { KernelQueue } from '../../KernelQueue.ts'; +import { makeKernelStore } from '../../store/index.ts'; +import type { PlatformServices } from '../../types.ts'; +import type { RemoteComms } from '../types.ts'; vi.mock('./remote-comms.ts', async () => { const actual = await vi.importActual('./remote-comms.ts'); diff --git a/packages/ocap-kernel/src/remotes/RemoteManager.ts b/packages/ocap-kernel/src/remotes/kernel/RemoteManager.ts similarity index 97% rename from packages/ocap-kernel/src/remotes/RemoteManager.ts rename to packages/ocap-kernel/src/remotes/kernel/RemoteManager.ts index 1711ffd12..d1abbf380 100644 --- a/packages/ocap-kernel/src/remotes/RemoteManager.ts +++ b/packages/ocap-kernel/src/remotes/kernel/RemoteManager.ts @@ -1,17 +1,17 @@ import type { Logger } from '@metamask/logger'; -import type { KernelQueue } from '../KernelQueue.ts'; import { initRemoteComms } from './remote-comms.ts'; import { RemoteHandle } from './RemoteHandle.ts'; -import { kser } from '../liveslots/kernel-marshal.ts'; -import type { PlatformServices, RemoteId } from '../types.ts'; +import type { KernelQueue } from '../../KernelQueue.ts'; +import { kser } from '../../liveslots/kernel-marshal.ts'; +import type { KernelStore } from '../../store/index.ts'; +import type { PlatformServices, RemoteId } from '../../types.ts'; import type { RemoteComms, RemoteMessageHandler, RemoteInfo, RemoteCommsOptions, -} from './types.ts'; -import type { KernelStore } from '../store/index.ts'; +} from '../types.ts'; type RemoteManagerConstructorProps = { platformServices: PlatformServices; diff --git a/packages/ocap-kernel/src/remotes/remote-comms.test.ts b/packages/ocap-kernel/src/remotes/kernel/remote-comms.test.ts similarity index 98% rename from packages/ocap-kernel/src/remotes/remote-comms.test.ts rename to packages/ocap-kernel/src/remotes/kernel/remote-comms.test.ts index 7b1f523aa..79edf6b78 100644 --- a/packages/ocap-kernel/src/remotes/remote-comms.test.ts +++ b/packages/ocap-kernel/src/remotes/kernel/remote-comms.test.ts @@ -9,10 +9,10 @@ import { parseOcapURL, getKnownRelays, } from './remote-comms.ts'; -import { createMockRemotesFactory } from '../../test/remotes-mocks.ts'; -import type { KernelStore } from '../store/index.ts'; -import type { PlatformServices } from '../types.ts'; -import type { RemoteMessageHandler } from './types.ts'; +import { createMockRemotesFactory } from '../../../test/remotes-mocks.ts'; +import type { KernelStore } from '../../store/index.ts'; +import type { PlatformServices } from '../../types.ts'; +import type { RemoteMessageHandler } from '../types.ts'; describe('remote-comms', () => { let mockKernelStore: KernelStore; diff --git a/packages/ocap-kernel/src/remotes/remote-comms.ts b/packages/ocap-kernel/src/remotes/kernel/remote-comms.ts similarity index 98% rename from packages/ocap-kernel/src/remotes/remote-comms.ts rename to packages/ocap-kernel/src/remotes/kernel/remote-comms.ts index 4d57e1b12..ead855682 100644 --- a/packages/ocap-kernel/src/remotes/remote-comms.ts +++ b/packages/ocap-kernel/src/remotes/kernel/remote-comms.ts @@ -6,14 +6,14 @@ import { toHex, fromHex } from '@metamask/kernel-utils'; import type { Logger } from '@metamask/logger'; import { base58btc } from 'multiformats/bases/base58'; -import type { KernelStore } from '../store/index.ts'; -import type { PlatformServices } from '../types.ts'; +import type { KernelStore } from '../../store/index.ts'; +import type { PlatformServices } from '../../types.ts'; import type { RemoteComms, RemoteMessageHandler, OnRemoteGiveUp, RemoteCommsOptions, -} from './types.ts'; +} from '../types.ts'; export type OcapURLParts = { oid: string; diff --git a/packages/ocap-kernel/test/remotes-mocks.ts b/packages/ocap-kernel/test/remotes-mocks.ts index c55dc3d77..e3678e8a5 100644 --- a/packages/ocap-kernel/test/remotes-mocks.ts +++ b/packages/ocap-kernel/test/remotes-mocks.ts @@ -3,7 +3,7 @@ import { vi } from 'vitest'; import { makeMapKernelDatabase } from './storage.ts'; import type { KernelQueue } from '../src/KernelQueue.ts'; -import { RemoteHandle } from '../src/remotes/RemoteHandle.ts'; +import { RemoteHandle } from '../src/remotes/kernel/RemoteHandle.ts'; import type { RemoteComms, RemoteMessageHandler, From 7a5b48541549e8515f7b21f9b531c7bc60d40fcb Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 14 Jan 2026 01:17:07 +0100 Subject: [PATCH 06/10] chore(remotes): refactor transport.ts into smaller modules Extract logical components from transport.ts (~1030 lines) into: - peer-registry.ts: Manages per-peer state (channels, queues, hints) - channel-reader.ts: Handles channel reading and message dispatch - reconnection-orchestrator.ts: Manages reconnection loop logic transport.ts now acts as a thin coordinator (~590 lines). Co-Authored-By: Claude Opus 4.5 --- .../src/remotes/platform/channel-reader.ts | 125 ++++ .../src/remotes/platform/peer-registry.ts | 237 +++++++ .../platform/reconnection-orchestrator.ts | 344 ++++++++++ .../src/remotes/platform/transport.ts | 649 +++--------------- 4 files changed, 811 insertions(+), 544 deletions(-) create mode 100644 packages/ocap-kernel/src/remotes/platform/channel-reader.ts create mode 100644 packages/ocap-kernel/src/remotes/platform/peer-registry.ts create mode 100644 packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.ts diff --git a/packages/ocap-kernel/src/remotes/platform/channel-reader.ts b/packages/ocap-kernel/src/remotes/platform/channel-reader.ts new file mode 100644 index 000000000..8e85f8295 --- /dev/null +++ b/packages/ocap-kernel/src/remotes/platform/channel-reader.ts @@ -0,0 +1,125 @@ +import { AbortError } from '@metamask/kernel-errors'; +import type { Logger } from '@metamask/logger'; +import { toString as bufToString } from 'uint8arrays'; + +import type { PeerRegistry } from './peer-registry.ts'; +import type { Channel, RemoteMessageHandler } from '../types.ts'; + +/** SCTP user-initiated abort code (RFC 4960) */ +const SCTP_USER_INITIATED_ABORT = 12; + +type ChannelReaderDeps = { + peerRegistry: PeerRegistry; + remoteMessageHandler: RemoteMessageHandler; + signal: AbortSignal; + logger: Logger; + onConnectionLoss: (peerId: string, channel?: Channel) => void; + onMessageReceived: (peerId: string) => void; + outputError: (peerId: string, task: string, problem: unknown) => void; +}; + +/** + * Creates a channel reader that processes incoming messages from peer channels. + * + * @param deps - Dependencies for the channel reader. + * @returns Object with methods for reading channels. + */ +export function makeChannelReader(deps: ChannelReaderDeps): { + readChannel: (channel: Channel) => Promise; +} { + const { + peerRegistry, + remoteMessageHandler, + signal, + logger, + onConnectionLoss, + onMessageReceived, + outputError, + } = deps; + + /** + * Receive a message from a peer. + * + * @param from - The peer ID that the message is from. + * @param message - The message to receive. + */ + async function receiveMessage(from: string, message: string): Promise { + logger.log(`${from}:: recv ${message}`); + await remoteMessageHandler(from, message); + } + + /** + * Start reading (and processing) messages arriving on a channel. + * + * @param channel - The channel to read from. + */ + async function readChannel(channel: Channel): Promise { + try { + for (;;) { + if (signal.aborted) { + logger.log(`reader abort: ${channel.peerId}`); + throw new AbortError(); + } + let readBuf; + try { + readBuf = await channel.msgStream.read(); + } catch (problem) { + const isCurrentChannel = + peerRegistry.getChannel(channel.peerId) === channel; + // Detect graceful disconnect + const rtcProblem = problem as { + errorDetail?: string; + sctpCauseCode?: number; + }; + if ( + rtcProblem?.errorDetail === 'sctp-failure' && + rtcProblem?.sctpCauseCode === SCTP_USER_INITIATED_ABORT + ) { + if (isCurrentChannel) { + logger.log( + `${channel.peerId}:: remote intentionally disconnected`, + ); + // Mark as intentionally closed and don't trigger reconnection + peerRegistry.markIntentionallyClosed(channel.peerId); + } else { + logger.log( + `${channel.peerId}:: stale channel intentionally disconnected`, + ); + } + } else if (isCurrentChannel) { + outputError( + channel.peerId, + `reading message from ${channel.peerId}`, + problem, + ); + // Only trigger reconnection for non-intentional disconnects + onConnectionLoss(channel.peerId, channel); + } else { + logger.log(`${channel.peerId}:: ignoring error from stale channel`); + } + logger.log(`closed channel to ${channel.peerId}`); + throw problem; + } + if (readBuf) { + onMessageReceived(channel.peerId); + peerRegistry.updateLastConnectionTime(channel.peerId); + await receiveMessage(channel.peerId, bufToString(readBuf.subarray())); + } else { + // Stream ended (returned undefined), exit the read loop + logger.log(`${channel.peerId}:: stream ended`); + break; + } + } + } finally { + // Always remove the channel when readChannel exits to prevent stale channels + // This ensures that subsequent sends will establish a new connection + if (peerRegistry.getChannel(channel.peerId) === channel) { + peerRegistry.removeChannel(channel.peerId); + } + } + } + + return { + readChannel, + }; +} diff --git a/packages/ocap-kernel/src/remotes/platform/peer-registry.ts b/packages/ocap-kernel/src/remotes/platform/peer-registry.ts new file mode 100644 index 000000000..96ab25490 --- /dev/null +++ b/packages/ocap-kernel/src/remotes/platform/peer-registry.ts @@ -0,0 +1,237 @@ +import { MessageQueue } from './message-queue.ts'; +import type { Channel } from '../types.ts'; + +/** + * Manages per-peer state including channels, message queues, location hints, + * and connection tracking. + */ +export class PeerRegistry { + /** Currently active channels, by peer ID */ + readonly #channels = new Map(); + + /** Per-peer message queues for when connections are unavailable */ + readonly #messageQueues = new Map(); + + /** Peers that have been intentionally closed (don't auto-reconnect) */ + readonly #intentionallyClosed = new Set(); + + /** Last connection/activity time per peer for stale cleanup */ + readonly #lastConnectionTime = new Map(); + + /** Location hints (multiaddrs) per peer */ + readonly #locationHints = new Map(); + + /** Maximum messages to queue per peer */ + readonly #maxQueue: number; + + /** + * Create a new PeerRegistry. + * + * @param maxQueue - Maximum number of messages to queue per peer. + */ + constructor(maxQueue: number) { + this.#maxQueue = maxQueue; + } + + /** + * Get the channel for a peer. + * + * @param peerId - The peer ID. + * @returns The channel, or undefined if not connected. + */ + getChannel(peerId: string): Channel | undefined { + return this.#channels.get(peerId); + } + + /** + * Check if a peer has an active channel. + * + * @param peerId - The peer ID. + * @returns True if the peer has a channel. + */ + hasChannel(peerId: string): boolean { + return this.#channels.has(peerId); + } + + /** + * Set the channel for a peer. + * + * @param peerId - The peer ID. + * @param channel - The channel to set. + * @returns The previous channel if one existed. + */ + setChannel(peerId: string, channel: Channel): Channel | undefined { + const previous = this.#channels.get(peerId); + this.#channels.set(peerId, channel); + this.#lastConnectionTime.set(peerId, Date.now()); + return previous; + } + + /** + * Remove the channel for a peer. + * + * @param peerId - The peer ID. + * @returns True if a channel was removed. + */ + removeChannel(peerId: string): boolean { + return this.#channels.delete(peerId); + } + + /** + * Get the number of active channels. + * + * @returns The number of active channels. + */ + get channelCount(): number { + return this.#channels.size; + } + + /** + * Get or create a message queue for a peer. + * + * @param peerId - The peer ID. + * @returns The message queue. + */ + getMessageQueue(peerId: string): MessageQueue { + let queue = this.#messageQueues.get(peerId); + if (!queue) { + queue = new MessageQueue(this.#maxQueue); + this.#messageQueues.set(peerId, queue); + if (!this.#lastConnectionTime.has(peerId)) { + this.#lastConnectionTime.set(peerId, Date.now()); + } + } + return queue; + } + + /** + * Check if a peer is marked as intentionally closed. + * + * @param peerId - The peer ID. + * @returns True if intentionally closed. + */ + isIntentionallyClosed(peerId: string): boolean { + return this.#intentionallyClosed.has(peerId); + } + + /** + * Mark a peer as intentionally closed. + * + * @param peerId - The peer ID. + */ + markIntentionallyClosed(peerId: string): void { + this.#intentionallyClosed.add(peerId); + } + + /** + * Clear the intentionally closed flag for a peer. + * + * @param peerId - The peer ID. + */ + clearIntentionallyClosed(peerId: string): void { + this.#intentionallyClosed.delete(peerId); + } + + /** + * Update the last connection time for a peer. + * + * @param peerId - The peer ID. + */ + updateLastConnectionTime(peerId: string): void { + this.#lastConnectionTime.set(peerId, Date.now()); + } + + /** + * Get location hints for a peer. + * + * @param peerId - The peer ID. + * @returns The location hints, or an empty array. + */ + getLocationHints(peerId: string): string[] { + return this.#locationHints.get(peerId) ?? []; + } + + /** + * Register location hints for a peer, merging with existing hints. + * + * @param peerId - The peer ID. + * @param hints - The hints to add. + */ + registerLocationHints(peerId: string, hints: string[]): void { + const oldHints = this.#locationHints.get(peerId); + if (oldHints) { + const newHints = new Set(oldHints); + for (const hint of hints) { + newHints.add(hint); + } + this.#locationHints.set(peerId, Array.from(newHints)); + } else { + this.#locationHints.set(peerId, Array.from(hints)); + } + } + + /** + * Find stale peers that should be cleaned up. + * + * @param stalePeerTimeoutMs - Time in ms before a peer is considered stale. + * @param isReconnecting - Function to check if a peer is reconnecting. + * @returns Array of stale peer IDs. + */ + findStalePeers( + stalePeerTimeoutMs: number, + isReconnecting: (peerId: string) => boolean, + ): string[] { + const now = Date.now(); + const stalePeers: string[] = []; + + for (const [peerId, lastTime] of this.#lastConnectionTime.entries()) { + const timeSinceLastActivity = now - lastTime; + const hasActiveChannel = this.#channels.has(peerId); + const reconnecting = isReconnecting(peerId); + + if ( + !hasActiveChannel && + !reconnecting && + timeSinceLastActivity > stalePeerTimeoutMs + ) { + stalePeers.push(peerId); + } + } + + return stalePeers; + } + + /** + * Get the last connection time for a peer. + * + * @param peerId - The peer ID. + * @returns The last connection time, or undefined. + */ + getLastConnectionTime(peerId: string): number | undefined { + return this.#lastConnectionTime.get(peerId); + } + + /** + * Remove all state for a peer. + * + * @param peerId - The peer ID. + */ + removePeer(peerId: string): void { + this.#channels.delete(peerId); + this.#messageQueues.delete(peerId); + this.#intentionallyClosed.delete(peerId); + this.#lastConnectionTime.delete(peerId); + this.#locationHints.delete(peerId); + } + + /** + * Clear all state. + */ + clear(): void { + this.#channels.clear(); + this.#messageQueues.clear(); + this.#intentionallyClosed.clear(); + this.#lastConnectionTime.clear(); + this.#locationHints.clear(); + } +} diff --git a/packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.ts b/packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.ts new file mode 100644 index 000000000..452f48dc4 --- /dev/null +++ b/packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.ts @@ -0,0 +1,344 @@ +import { isRetryableNetworkError } from '@metamask/kernel-errors'; +import { + abortableDelay, + DEFAULT_MAX_RETRY_ATTEMPTS, +} from '@metamask/kernel-utils'; +import type { Logger } from '@metamask/logger'; +import { fromString } from 'uint8arrays'; + +import type { ConnectionFactory } from './connection-factory.ts'; +import type { MessageQueue } from './message-queue.ts'; +import type { PeerRegistry } from './peer-registry.ts'; +import { ReconnectionManager } from './reconnection.ts'; +import type { Channel, OnRemoteGiveUp } from '../types.ts'; + +type ReconnectionOrchestratorDeps = { + peerRegistry: PeerRegistry; + connectionFactory: ConnectionFactory; + reconnectionManager: ReconnectionManager; + signal: AbortSignal; + logger: Logger; + maxRetryAttempts: number | undefined; + onRemoteGiveUp: OnRemoteGiveUp | undefined; + registerChannel: (peerId: string, channel: Channel) => void; + checkConnectionLimit: () => void; + writeWithTimeout: ( + channel: Channel, + message: Uint8Array, + timeoutMs?: number, + ) => Promise; + outputError: (peerId: string, task: string, problem: unknown) => void; +}; + +/** + * Creates a reconnection orchestrator that manages peer reconnection attempts. + * + * @param deps - Dependencies for the orchestrator. + * @returns Object with methods for handling connection loss and reconnection. + */ +export function makeReconnectionOrchestrator( + deps: ReconnectionOrchestratorDeps, +): { + handleConnectionLoss: (peerId: string, channel?: Channel) => void; + attemptReconnection: (peerId: string, maxAttempts?: number) => Promise; + flushQueuedMessages: ( + peerId: string, + channel: Channel, + queue: MessageQueue, + ) => Promise; +} { + const { + peerRegistry, + connectionFactory, + reconnectionManager, + signal, + logger, + maxRetryAttempts, + onRemoteGiveUp, + registerChannel, + checkConnectionLimit, + writeWithTimeout, + outputError, + } = deps; + + /** + * Give up on a peer after max retries or non-retryable error. + * + * @param peerId - The peer ID to give up on. + * @param queue - The message queue for the peer. + */ + function giveUpOnPeer(peerId: string, queue: MessageQueue): void { + reconnectionManager.stopReconnection(peerId); + queue.clear(); + onRemoteGiveUp?.(peerId); + } + + /** + * Flush queued messages after reconnection. + * + * @param peerId - The peer ID to flush messages for. + * @param channel - The channel to flush messages through. + * @param queue - The message queue to flush. + */ + async function flushQueuedMessages( + peerId: string, + channel: Channel, + queue: MessageQueue, + ): Promise { + logger.log(`${peerId}:: flushing ${queue.length} queued messages`); + + // Process queued messages + const failedMessages: string[] = []; + let queuedMsg: string | undefined; + + while ((queuedMsg = queue.dequeue()) !== undefined) { + try { + logger.log(`${peerId}:: send (queued) ${queuedMsg}`); + await writeWithTimeout(channel, fromString(queuedMsg), 10_000); + } catch (problem) { + outputError(peerId, `sending queued message`, problem); + // Preserve the failed message and all remaining messages + failedMessages.push(queuedMsg); + failedMessages.push(...queue.dequeueAll()); + break; + } + } + + // Re-queue any failed messages + if (failedMessages.length > 0) { + queue.replaceAll(failedMessages); + handleConnectionLoss(peerId, channel); + } + } + + /** + * Check if an existing channel exists for a peer, and if so, reuse it. + * Otherwise, return the dialed channel for the caller to register. + * + * @param peerId - The peer ID for the channel. + * @param dialedChannel - The newly dialed channel. + * @returns The channel to use, or null if existing channel died and dialed was closed. + */ + async function reuseOrReturnChannel( + peerId: string, + dialedChannel: Channel, + ): Promise { + const existingChannel = peerRegistry.getChannel(peerId); + if (existingChannel) { + if (dialedChannel !== existingChannel) { + await connectionFactory.closeChannel(dialedChannel, peerId); + const currentChannel = peerRegistry.getChannel(peerId); + if (currentChannel === existingChannel) { + return existingChannel; + } + if (currentChannel) { + return currentChannel; + } + return null; + } + const currentChannel = peerRegistry.getChannel(peerId); + if (currentChannel === existingChannel) { + return existingChannel; + } + if (currentChannel) { + return currentChannel; + } + return null; + } + return dialedChannel; + } + + /** + * Handle connection loss for a given peer ID. + * Skips reconnection if the peer was intentionally closed. + * + * @param peerId - The peer ID to handle the connection loss for. + * @param channel - Optional channel that experienced loss; used to ignore stale channels. + */ + function handleConnectionLoss(peerId: string, channel?: Channel): void { + const currentChannel = peerRegistry.getChannel(peerId); + // Ignore loss signals from stale channels if a different channel is active. + if (channel && currentChannel && currentChannel !== channel) { + logger.log(`${peerId}:: ignoring connection loss from stale channel`); + return; + } + // Don't reconnect if this peer intentionally closed the connection + if (peerRegistry.isIntentionallyClosed(peerId)) { + logger.log( + `${peerId}:: connection lost but peer intentionally closed, skipping reconnection`, + ); + return; + } + logger.log(`${peerId}:: connection lost, initiating reconnection`); + peerRegistry.removeChannel(peerId); + if (!reconnectionManager.isReconnecting(peerId)) { + reconnectionManager.startReconnection(peerId); + attemptReconnection(peerId).catch((problem) => { + outputError(peerId, 'reconnection error', problem); + reconnectionManager.stopReconnection(peerId); + }); + } + } + + /** + * Attempt to reconnect to a peer after connection loss. + * Single orchestration loop per peer; abortable. + * + * @param peerId - The peer ID to reconnect to. + * @param maxAttempts - The maximum number of reconnection attempts. 0 = infinite. + */ + async function attemptReconnection( + peerId: string, + maxAttempts = maxRetryAttempts ?? DEFAULT_MAX_RETRY_ATTEMPTS, + ): Promise { + let queue = peerRegistry.getMessageQueue(peerId); + + while (reconnectionManager.isReconnecting(peerId) && !signal.aborted) { + if (!reconnectionManager.shouldRetry(peerId, maxAttempts)) { + logger.log( + `${peerId}:: max reconnection attempts (${maxAttempts}) reached, giving up`, + ); + giveUpOnPeer(peerId, queue); + return; + } + + const nextAttempt = reconnectionManager.incrementAttempt(peerId); + const delayMs = reconnectionManager.calculateBackoff(peerId); + logger.log( + `${peerId}:: scheduling reconnection attempt ${nextAttempt}${maxAttempts ? `/${maxAttempts}` : ''} in ${delayMs}ms`, + ); + + try { + await abortableDelay(delayMs, signal); + } catch (error) { + if (signal.aborted) { + reconnectionManager.stopReconnection(peerId); + return; + } + throw error; + } + + // Re-fetch queue after delay in case cleanupStalePeers deleted it + queue = peerRegistry.getMessageQueue(peerId); + + if (!reconnectionManager.isReconnecting(peerId) || signal.aborted) { + return; + } + + if (peerRegistry.isIntentionallyClosed(peerId)) { + reconnectionManager.stopReconnection(peerId); + return; + } + + logger.log( + `${peerId}:: reconnection attempt ${nextAttempt}${maxAttempts ? `/${maxAttempts}` : ''}`, + ); + + try { + const hints = peerRegistry.getLocationHints(peerId); + let channel: Channel | null = await connectionFactory.dialIdempotent( + peerId, + hints, + false, + ); + + queue = peerRegistry.getMessageQueue(peerId); + + channel = await reuseOrReturnChannel(peerId, channel); + if (channel === null) { + logger.log( + `${peerId}:: existing channel died during reuse check, continuing reconnection loop`, + ); + continue; + } + + const registeredChannel = peerRegistry.getChannel(peerId); + if (registeredChannel) { + if (channel !== registeredChannel) { + await connectionFactory.closeChannel(channel, peerId); + } + channel = registeredChannel; + logger.log( + `${peerId}:: reconnection: channel already exists, reusing existing channel`, + ); + } else { + try { + checkConnectionLimit(); + } catch (limitError) { + logger.log( + `${peerId}:: reconnection blocked by connection limit, will retry`, + ); + outputError( + peerId, + `reconnection attempt ${nextAttempt}`, + limitError, + ); + await connectionFactory.closeChannel(channel, peerId); + continue; + } + + if (peerRegistry.isIntentionallyClosed(peerId)) { + logger.log( + `${peerId}:: peer intentionally closed during dial, closing channel`, + ); + await connectionFactory.closeChannel(channel, peerId); + reconnectionManager.stopReconnection(peerId); + return; + } + + registerChannel(peerId, channel); + } + + logger.log(`${peerId}:: reconnection successful`); + + await flushQueuedMessages(peerId, channel, queue); + + if (!peerRegistry.hasChannel(peerId)) { + logger.log( + `${peerId}:: channel deleted during flush, continuing loop`, + ); + continue; + } + + const newChannel = peerRegistry.getChannel(peerId); + if (newChannel && newChannel !== channel) { + logger.log( + `${peerId}:: stale channel replaced during flush, flushing queue on new channel`, + ); + await flushQueuedMessages(peerId, newChannel, queue); + if (!peerRegistry.hasChannel(peerId)) { + logger.log( + `${peerId}:: new channel also failed during flush, continuing loop`, + ); + continue; + } + } + + reconnectionManager.resetBackoff(peerId); + reconnectionManager.stopReconnection(peerId); + return; + } catch (problem) { + if (signal.aborted) { + reconnectionManager.stopReconnection(peerId); + return; + } + if (!isRetryableNetworkError(problem)) { + outputError(peerId, `non-retryable failure`, problem); + giveUpOnPeer(peerId, queue); + return; + } + outputError(peerId, `reconnection attempt ${nextAttempt}`, problem); + } + } + + if (reconnectionManager.isReconnecting(peerId)) { + reconnectionManager.stopReconnection(peerId); + } + } + + return { + handleConnectionLoss, + attemptReconnection, + flushQueuedMessages, + }; +} diff --git a/packages/ocap-kernel/src/remotes/platform/transport.ts b/packages/ocap-kernel/src/remotes/platform/transport.ts index 0dc109b2c..361a7f774 100644 --- a/packages/ocap-kernel/src/remotes/platform/transport.ts +++ b/packages/ocap-kernel/src/remotes/platform/transport.ts @@ -1,18 +1,12 @@ -import { - AbortError, - isRetryableNetworkError, - ResourceLimitError, -} from '@metamask/kernel-errors'; -import { - abortableDelay, - DEFAULT_MAX_RETRY_ATTEMPTS, - installWakeDetector, -} from '@metamask/kernel-utils'; +import { ResourceLimitError } from '@metamask/kernel-errors'; +import { installWakeDetector } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; -import { toString as bufToString, fromString } from 'uint8arrays'; +import { fromString } from 'uint8arrays'; +import { makeChannelReader } from './channel-reader.ts'; import { ConnectionFactory } from './connection-factory.ts'; -import { MessageQueue } from './message-queue.ts'; +import { PeerRegistry } from './peer-registry.ts'; +import { makeReconnectionOrchestrator } from './reconnection-orchestrator.ts'; import { ReconnectionManager } from './reconnection.ts'; import type { RemoteMessageHandler, @@ -76,15 +70,17 @@ export async function initNetwork( cleanupIntervalMs = DEFAULT_CLEANUP_INTERVAL_MS, stalePeerTimeoutMs = DEFAULT_STALE_PEER_TIMEOUT_MS, } = options; + let cleanupWakeDetector: (() => void) | undefined; const stopController = new AbortController(); const { signal } = stopController; const logger = new Logger(); - const channels = new Map(); + const messageEncoder = new TextEncoder(); + let cleanupIntervalId: ReturnType | undefined; + + // Initialize components + const peerRegistry = new PeerRegistry(maxQueue); const reconnectionManager = new ReconnectionManager(); - const messageQueues = new Map(); // One queue per peer - const intentionallyClosed = new Set(); // Track peers that intentionally closed connections - const lastConnectionTime = new Map(); // Track last connection time for cleanup const connectionFactory = await ConnectionFactory.make( keySeed, relays, @@ -92,9 +88,6 @@ export async function initNetwork( signal, maxRetryAttempts, ); - const locationHints = new Map(); - const messageEncoder = new TextEncoder(); // Reused for message size validation - let cleanupIntervalId: ReturnType | undefined; /** * Output an error message. @@ -112,26 +105,6 @@ export async function initNetwork( } } - /** - * Get or create a message queue for a peer. - * - * @param peerId - The peer ID to get the queue for. - * @returns The message queue for the peer. - */ - function getMessageQueue(peerId: string): MessageQueue { - let queue = messageQueues.get(peerId); - if (!queue) { - queue = new MessageQueue(maxQueue); - messageQueues.set(peerId, queue); - // Initialize lastConnectionTime if not set to enable stale peer cleanup - // even for peers that never successfully connect - if (!lastConnectionTime.has(peerId)) { - lastConnectionTime.set(peerId, Date.now()); - } - } - return queue; - } - /** * Write a message to a channel stream with a timeout. * @@ -161,8 +134,6 @@ export async function initNetwork( timeoutPromise, ]); } finally { - // Clean up event listener to prevent unhandled rejection if operation - // completes before timeout if (abortHandler) { timeoutSignal.removeEventListener('abort', abortHandler); } @@ -170,336 +141,23 @@ export async function initNetwork( } /** - * Receive a message from a peer. - * - * @param from - The peer ID that the message is from. - * @param message - The message to receive. - */ - async function receiveMessage(from: string, message: string): Promise { - logger.log(`${from}:: recv ${message}`); - await remoteMessageHandler(from, message); - } - - /** - * Start reading (and processing) messages arriving on a channel. - * - * @param channel - The channel to read from. - */ - async function readChannel(channel: Channel): Promise { - const SCTP_USER_INITIATED_ABORT = 12; // RFC 4960 - try { - for (;;) { - if (signal.aborted) { - logger.log(`reader abort: ${channel.peerId}`); - throw new AbortError(); - } - let readBuf; - try { - readBuf = await channel.msgStream.read(); - } catch (problem) { - const isCurrentChannel = channels.get(channel.peerId) === channel; - // Detect graceful disconnect - const rtcProblem = problem as { - errorDetail?: string; - sctpCauseCode?: number; - }; - if ( - rtcProblem?.errorDetail === 'sctp-failure' && - rtcProblem?.sctpCauseCode === SCTP_USER_INITIATED_ABORT - ) { - if (isCurrentChannel) { - logger.log( - `${channel.peerId}:: remote intentionally disconnected`, - ); - // Mark as intentionally closed and don't trigger reconnection - intentionallyClosed.add(channel.peerId); - } else { - logger.log( - `${channel.peerId}:: stale channel intentionally disconnected`, - ); - } - } else if (isCurrentChannel) { - outputError( - channel.peerId, - `reading message from ${channel.peerId}`, - problem, - ); - // Only trigger reconnection for non-intentional disconnects - handleConnectionLoss(channel.peerId, channel); - } else { - logger.log(`${channel.peerId}:: ignoring error from stale channel`); - } - logger.log(`closed channel to ${channel.peerId}`); - throw problem; - } - if (readBuf) { - reconnectionManager.resetBackoff(channel.peerId); // successful inbound traffic - await receiveMessage(channel.peerId, bufToString(readBuf.subarray())); - lastConnectionTime.set(channel.peerId, Date.now()); // update timestamp on inbound activity - } else { - // Stream ended (returned undefined), exit the read loop - logger.log(`${channel.peerId}:: stream ended`); - break; - } - } - } finally { - // Always remove the channel when readChannel exits to prevent stale channels - // This ensures that subsequent sends will establish a new connection - if (channels.get(channel.peerId) === channel) { - channels.delete(channel.peerId); - } - } - } - - /** - * Handle connection loss for a given peer ID. - * Skips reconnection if the peer was intentionally closed. + * Check if we can establish a new connection (within connection limit). * - * @param peerId - The peer ID to handle the connection loss for. - * @param channel - Optional channel that experienced loss; used to ignore stale channels. + * @throws ResourceLimitError if connection limit is reached. */ - function handleConnectionLoss(peerId: string, channel?: Channel): void { - const currentChannel = channels.get(peerId); - // Ignore loss signals from stale channels if a different channel is active. - if (channel && currentChannel && currentChannel !== channel) { - logger.log(`${peerId}:: ignoring connection loss from stale channel`); - return; - } - // Don't reconnect if this peer intentionally closed the connection - if (intentionallyClosed.has(peerId)) { - logger.log( - `${peerId}:: connection lost but peer intentionally closed, skipping reconnection`, + function checkConnectionLimit(): void { + const currentConnections = peerRegistry.channelCount; + if (currentConnections >= maxConcurrentConnections) { + throw new ResourceLimitError( + `Connection limit reached: ${currentConnections}/${maxConcurrentConnections} concurrent connections`, + { + data: { + limitType: 'connection', + current: currentConnections, + limit: maxConcurrentConnections, + }, + }, ); - return; - } - logger.log(`${peerId}:: connection lost, initiating reconnection`); - channels.delete(peerId); - if (!reconnectionManager.isReconnecting(peerId)) { - reconnectionManager.startReconnection(peerId); - attemptReconnection(peerId).catch((problem) => { - outputError(peerId, 'reconnection error', problem); - reconnectionManager.stopReconnection(peerId); - }); - } - } - - /** - * Attempt to reconnect to a peer after connection loss. - * Single orchestration loop per peer; abortable. - * - * @param peerId - The peer ID to reconnect to. - * @param maxAttempts - The maximum number of reconnection attempts. 0 = infinite. - */ - async function attemptReconnection( - peerId: string, - maxAttempts = maxRetryAttempts ?? DEFAULT_MAX_RETRY_ATTEMPTS, - ): Promise { - // Get queue reference - will re-fetch after long awaits to handle cleanup race conditions - let queue = getMessageQueue(peerId); - - while (reconnectionManager.isReconnecting(peerId) && !signal.aborted) { - if (!reconnectionManager.shouldRetry(peerId, maxAttempts)) { - logger.log( - `${peerId}:: max reconnection attempts (${maxAttempts}) reached, giving up`, - ); - giveUpOnPeer(peerId, queue); - return; - } - - const nextAttempt = reconnectionManager.incrementAttempt(peerId); - const delayMs = reconnectionManager.calculateBackoff(peerId); - logger.log( - `${peerId}:: scheduling reconnection attempt ${nextAttempt}${maxAttempts ? `/${maxAttempts}` : ''} in ${delayMs}ms`, - ); - - try { - await abortableDelay(delayMs, signal); - } catch (error) { - if (signal.aborted) { - reconnectionManager.stopReconnection(peerId); - return; - } - throw error; - } - - // Re-fetch queue after delay in case cleanupStalePeers deleted it during the await - queue = getMessageQueue(peerId); - - // Re-check reconnection state after the await; it may have been stopped concurrently - if (!reconnectionManager.isReconnecting(peerId) || signal.aborted) { - return; - } - - // If peer was intentionally closed while reconnecting, stop and exit - if (intentionallyClosed.has(peerId)) { - reconnectionManager.stopReconnection(peerId); - return; - } - - logger.log( - `${peerId}:: reconnection attempt ${nextAttempt}${maxAttempts ? `/${maxAttempts}` : ''}`, - ); - - try { - const hints = locationHints.get(peerId) ?? []; - let channel: Channel | null = await connectionFactory.dialIdempotent( - peerId, - hints, - false, // No retry here, we're already in a retry loop - ); - - // Re-fetch queue after dial in case cleanupStalePeers deleted it during the await - queue = getMessageQueue(peerId); - - // Check if a concurrent call already registered a channel for this peer - // (e.g., an inbound connection or another reconnection attempt) - channel = await reuseOrReturnChannel(peerId, channel); - // Handle case where existing channel died during await and dialed channel was closed - if (channel === null) { - logger.log( - `${peerId}:: existing channel died during reuse check, continuing reconnection loop`, - ); - // Channel died and dialed channel was already closed, continue loop to re-dial - continue; - } - // Re-check after await to handle race condition where a channel was registered - // concurrently during the microtask delay - const registeredChannel = channels.get(peerId); - if (registeredChannel) { - // A channel was registered concurrently, use it instead - if (channel !== registeredChannel) { - // Close the dialed channel to prevent resource leak - await connectionFactory.closeChannel(channel, peerId); - } - channel = registeredChannel; - logger.log( - `${peerId}:: reconnection: channel already exists, reusing existing channel`, - ); - } else { - // Re-check connection limit after reuseOrReturnChannel to prevent race conditions - // Other connections (inbound or outbound) could be established during the await - try { - checkConnectionLimit(); - } catch (limitError) { - // Connection limit reached - treat as retryable and continue loop - // The limit might free up when other connections close - logger.log( - `${peerId}:: reconnection blocked by connection limit, will retry`, - ); - outputError( - peerId, - `reconnection attempt ${nextAttempt}`, - limitError, - ); - // Explicitly close the channel to release network resources - await connectionFactory.closeChannel(channel, peerId); - // Continue the reconnection loop - continue; - } - - // Check if peer was intentionally closed during dial - if (intentionallyClosed.has(peerId)) { - logger.log( - `${peerId}:: peer intentionally closed during dial, closing channel`, - ); - await connectionFactory.closeChannel(channel, peerId); - reconnectionManager.stopReconnection(peerId); - return; - } - - // Register the new channel and start reading - registerChannel(peerId, channel); - } - - logger.log(`${peerId}:: reconnection successful`); - - // Flush queued messages - await flushQueuedMessages(peerId, channel, queue); - - // Check if channel was deleted during flush (e.g., due to flush errors) - if (!channels.has(peerId)) { - logger.log( - `${peerId}:: channel deleted during flush, continuing loop`, - ); - continue; // Continue the reconnection loop - } - - // If a new channel is active (stale channel was replaced by inbound connection), - // flush the queue on it to prevent messages from being stuck indefinitely - const newChannel = channels.get(peerId); - if (newChannel && newChannel !== channel) { - logger.log( - `${peerId}:: stale channel replaced during flush, flushing queue on new channel`, - ); - await flushQueuedMessages(peerId, newChannel, queue); - // Check again if the new flush succeeded - if (!channels.has(peerId)) { - logger.log( - `${peerId}:: new channel also failed during flush, continuing loop`, - ); - continue; - } - } - - // Only reset backoff and stop reconnection after successful flush - reconnectionManager.resetBackoff(peerId); - reconnectionManager.stopReconnection(peerId); - return; // success - } catch (problem) { - if (signal.aborted) { - reconnectionManager.stopReconnection(peerId); - return; - } - if (!isRetryableNetworkError(problem)) { - outputError(peerId, `non-retryable failure`, problem); - giveUpOnPeer(peerId, queue); - return; - } - outputError(peerId, `reconnection attempt ${nextAttempt}`, problem); - // loop to next attempt - } - } - // Loop exited - clean up reconnection state - if (reconnectionManager.isReconnecting(peerId)) { - reconnectionManager.stopReconnection(peerId); - } - } - - /** - * Flush queued messages after reconnection. - * - * @param peerId - The peer ID to flush messages for. - * @param channel - The channel to flush messages through. - * @param queue - The message queue to flush. - */ - async function flushQueuedMessages( - peerId: string, - channel: Channel, - queue: MessageQueue, - ): Promise { - logger.log(`${peerId}:: flushing ${queue.length} queued messages`); - - // Process queued messages - const failedMessages: string[] = []; - let queuedMsg: string | undefined; - - while ((queuedMsg = queue.dequeue()) !== undefined) { - try { - logger.log(`${peerId}:: send (queued) ${queuedMsg}`); - await writeWithTimeout(channel, fromString(queuedMsg), 10_000); - } catch (problem) { - outputError(peerId, `sending queued message`, problem); - // Preserve the failed message and all remaining messages - failedMessages.push(queuedMsg); - failedMessages.push(...queue.dequeueAll()); - break; - } - } - - // Re-queue any failed messages - if (failedMessages.length > 0) { - queue.replaceAll(failedMessages); - handleConnectionLoss(peerId, channel); } } @@ -525,26 +183,11 @@ export async function initNetwork( } } - /** - * Check if we can establish a new connection (within connection limit). - * - * @throws ResourceLimitError if connection limit is reached. - */ - function checkConnectionLimit(): void { - const currentConnections = channels.size; - if (currentConnections >= maxConcurrentConnections) { - throw new ResourceLimitError( - `Connection limit reached: ${currentConnections}/${maxConcurrentConnections} concurrent connections`, - { - data: { - limitType: 'connection', - current: currentConnections, - limit: maxConcurrentConnections, - }, - }, - ); - } - } + // Late-bound references for circular dependencies + // eslint-disable-next-line prefer-const + let channelReader: ReturnType; + // eslint-disable-next-line prefer-const + let reconnectionOrchestrator: ReturnType; /** * Register a channel and start reading from it. @@ -558,14 +201,11 @@ export async function initNetwork( channel: Channel, errorContext = 'reading channel to', ): void { - const previousChannel = channels.get(peerId); - channels.set(peerId, channel); - lastConnectionTime.set(peerId, Date.now()); - readChannel(channel).catch((problem) => { + const previousChannel = peerRegistry.setChannel(peerId, channel); + channelReader.readChannel(channel).catch((problem) => { outputError(peerId, errorContext, problem); }); - // If we replaced an existing channel, close it to avoid leaks and stale readers. if (previousChannel && previousChannel !== channel) { const closePromise = connectionFactory.closeChannel( previousChannel, @@ -579,113 +219,88 @@ export async function initNetwork( } } + // Create channel reader + channelReader = makeChannelReader({ + peerRegistry, + remoteMessageHandler, + signal, + logger, + onConnectionLoss: (peerId, channel) => + reconnectionOrchestrator.handleConnectionLoss(peerId, channel), + onMessageReceived: (peerId) => reconnectionManager.resetBackoff(peerId), + outputError, + }); + + // Create reconnection orchestrator + reconnectionOrchestrator = makeReconnectionOrchestrator({ + peerRegistry, + connectionFactory, + reconnectionManager, + signal, + logger, + maxRetryAttempts, + onRemoteGiveUp, + registerChannel, + checkConnectionLimit, + writeWithTimeout, + outputError, + }); + /** * Check if an existing channel exists for a peer, and if so, reuse it. - * Otherwise, return the dialed channel for the caller to register. * * @param peerId - The peer ID for the channel. * @param dialedChannel - The newly dialed channel. - * @returns The channel to use (either existing or the dialed one), or null if - * the existing channel died during the await and the dialed channel was already closed. + * @returns The channel to use, or null if existing channel died and dialed was closed. */ async function reuseOrReturnChannel( peerId: string, dialedChannel: Channel, ): Promise { - const existingChannel = channels.get(peerId); + const existingChannel = peerRegistry.getChannel(peerId); if (existingChannel) { - // Close the dialed channel if it's different from the existing one if (dialedChannel !== existingChannel) { await connectionFactory.closeChannel(dialedChannel, peerId); - // Re-check if existing channel is still valid after await - // It may have been removed if readChannel exited during the close, - // or a new channel may have been registered concurrently - const currentChannel = channels.get(peerId); + const currentChannel = peerRegistry.getChannel(peerId); if (currentChannel === existingChannel) { - // Existing channel is still valid, use it return existingChannel; } if (currentChannel) { - // A different channel was registered concurrently, use that instead return currentChannel; } - // Existing channel died during await, but we already closed dialed channel - // Return null to signal caller needs to handle this (re-dial or fail) return null; } - // Same channel, check if it's still valid - const currentChannel = channels.get(peerId); + const currentChannel = peerRegistry.getChannel(peerId); if (currentChannel === existingChannel) { - // Still the same channel, use it return existingChannel; } if (currentChannel) { - // A different channel was registered concurrently, use that instead return currentChannel; } - // Channel died, but we can't close dialed channel since it's the same - // Return null to signal caller needs to handle this return null; } - // No existing channel, return the dialed one for caller to register return dialedChannel; } - /** - * Give up on a peer after max retries or non-retryable error. - * - * @param peerId - The peer ID to give up on. - * @param queue - The message queue for the peer. - */ - function giveUpOnPeer(peerId: string, queue: MessageQueue): void { - reconnectionManager.stopReconnection(peerId); - queue.clear(); - onRemoteGiveUp?.(peerId); - } - /** * Clean up stale peer data for peers inactive for more than stalePeerTimeoutMs. - * This includes peers that never successfully connected (e.g., dial failures). */ function cleanupStalePeers(): void { - const now = Date.now(); - const stalePeers: string[] = []; - - // Check all tracked peers (includes peers that never connected successfully) - for (const [peerId, lastTime] of lastConnectionTime.entries()) { - const timeSinceLastActivity = now - lastTime; - const hasActiveChannel = channels.has(peerId); - const isReconnecting = reconnectionManager.isReconnecting(peerId); + const stalePeers = peerRegistry.findStalePeers( + stalePeerTimeoutMs, + (peerId) => reconnectionManager.isReconnecting(peerId), + ); - // Consider peer stale if: - // - No active channel - // - Not currently reconnecting - // - Inactive for more than stalePeerTimeoutMs - if ( - !hasActiveChannel && - !isReconnecting && - timeSinceLastActivity > stalePeerTimeoutMs - ) { - stalePeers.push(peerId); - } - } - - // Clean up stale peer data + const now = Date.now(); for (const peerId of stalePeers) { - const lastTime = lastConnectionTime.get(peerId); + const lastTime = peerRegistry.getLastConnectionTime(peerId); if (lastTime !== undefined) { const minutesSinceActivity = Math.round((now - lastTime) / 1000 / 60); logger.log( `${peerId}:: cleaning up stale peer data (inactive for ${minutesSinceActivity} minutes)`, ); } - - // Remove from all tracking structures - lastConnectionTime.delete(peerId); - messageQueues.delete(peerId); - locationHints.delete(peerId); - intentionallyClosed.delete(peerId); - // Clear reconnection state + peerRegistry.removePeer(peerId); reconnectionManager.clearPeer(peerId); } } @@ -704,15 +319,13 @@ export async function initNetwork( return; } - // Validate message size before processing validateMessageSize(message); - // Check if peer is intentionally closed - if (intentionallyClosed.has(targetPeerId)) { + if (peerRegistry.isIntentionallyClosed(targetPeerId)) { throw new Error('Message delivery failed after intentional close'); } - const queue = getMessageQueue(targetPeerId); + const queue = peerRegistry.getMessageQueue(targetPeerId); if (reconnectionManager.isReconnecting(targetPeerId)) { queue.enqueue(message); @@ -723,79 +336,59 @@ export async function initNetwork( return; } - let channel: Channel | null | undefined = channels.get(targetPeerId); + let channel: Channel | null | undefined = + peerRegistry.getChannel(targetPeerId); if (!channel) { - // Check connection limit before dialing new connection - // (Early check to fail fast, but we'll check again after dial to prevent race conditions) checkConnectionLimit(); try { - const hints = locationHints.get(targetPeerId) ?? []; + const hints = peerRegistry.getLocationHints(targetPeerId); channel = await connectionFactory.dialIdempotent( targetPeerId, hints, - true, // With retry for initial connection + true, ); - // Re-fetch queue after dial in case cleanupStalePeers deleted it during the await - // This prevents orphaned messages in a stale queue reference - const currentQueue = getMessageQueue(targetPeerId); + const currentQueue = peerRegistry.getMessageQueue(targetPeerId); - // Check if reconnection started while we were dialing (race condition protection) if (reconnectionManager.isReconnecting(targetPeerId)) { currentQueue.enqueue(message); logger.log( `${targetPeerId}:: reconnection started during dial, queueing message ` + `(${currentQueue.length}/${maxQueue}): ${message}`, ); - // Explicitly close the channel to release network resources - // The reconnection loop will dial its own new channel await connectionFactory.closeChannel(channel, targetPeerId); return; } - // Check if a concurrent call already registered a channel for this peer channel = await reuseOrReturnChannel(targetPeerId, channel); - // Handle case where existing channel died during await and dialed channel was closed if (channel === null) { - // Existing channel died and dialed channel was already closed - // Trigger reconnection to re-dial logger.log( `${targetPeerId}:: existing channel died during reuse check, triggering reconnection`, ); currentQueue.enqueue(message); - handleConnectionLoss(targetPeerId); + reconnectionOrchestrator.handleConnectionLoss(targetPeerId); return; } - // Re-check after await to handle race condition where a channel was registered - // concurrently during the microtask delay - const registeredChannel = channels.get(targetPeerId); + + const registeredChannel = peerRegistry.getChannel(targetPeerId); if (registeredChannel) { - // A channel was registered concurrently, use it instead if (channel !== registeredChannel) { - // Close the dialed channel to prevent resource leak await connectionFactory.closeChannel(channel, targetPeerId); } channel = registeredChannel; - // Existing channel reused, nothing more to do } else { - // Re-check connection limit after dial completes to prevent race conditions - // Multiple concurrent dials could all pass the initial check, then all add channels try { checkConnectionLimit(); } catch (limitError) { - // Connection limit reached - close the dialed channel and propagate error to caller logger.log( `${targetPeerId}:: connection limit reached after dial, rejecting send`, ); - // Explicitly close the channel to release network resources await connectionFactory.closeChannel(channel, targetPeerId); - // Re-throw to let caller know the send failed throw limitError; } - // Check if peer was intentionally closed during dial - if (intentionallyClosed.has(targetPeerId)) { + if (peerRegistry.isIntentionallyClosed(targetPeerId)) { logger.log( `${targetPeerId}:: peer intentionally closed during dial, closing channel`, ); @@ -803,15 +396,12 @@ export async function initNetwork( throw new Error('Message delivery failed after intentional close'); } - // Register the new channel and start reading registerChannel(targetPeerId, channel); } } catch (problem) { - // Re-throw ResourceLimitError to propagate to caller if (problem instanceof ResourceLimitError) { throw problem; } - // Re-throw intentional close errors to propagate to caller if ( problem instanceof Error && problem.message === 'Message delivery failed after intentional close' @@ -819,9 +409,8 @@ export async function initNetwork( throw problem; } outputError(targetPeerId, `opening connection`, problem); - handleConnectionLoss(targetPeerId); - // Re-fetch queue in case cleanupStalePeers deleted it during the dial await - const currentQueue = getMessageQueue(targetPeerId); + reconnectionOrchestrator.handleConnectionLoss(targetPeerId); + const currentQueue = peerRegistry.getMessageQueue(targetPeerId); currentQueue.enqueue(message); return; } @@ -831,22 +420,23 @@ export async function initNetwork( logger.log(`${targetPeerId}:: send ${message}`); await writeWithTimeout(channel, fromString(message), 10_000); reconnectionManager.resetBackoff(targetPeerId); - lastConnectionTime.set(targetPeerId, Date.now()); + peerRegistry.updateLastConnectionTime(targetPeerId); } catch (problem) { outputError(targetPeerId, `sending message`, problem); - handleConnectionLoss(targetPeerId, channel); - // Re-fetch queue in case cleanupStalePeers deleted it during the await - const currentQueue = getMessageQueue(targetPeerId); + reconnectionOrchestrator.handleConnectionLoss(targetPeerId, channel); + const currentQueue = peerRegistry.getMessageQueue(targetPeerId); currentQueue.enqueue(message); - // If a new channel is active (stale channel was replaced by inbound connection), - // flush the queue on it to prevent messages from being stuck indefinitely - const newChannel = channels.get(targetPeerId); + const newChannel = peerRegistry.getChannel(targetPeerId); if (newChannel && newChannel !== channel) { logger.log( `${targetPeerId}:: stale channel replaced, flushing queue on new channel`, ); - await flushQueuedMessages(targetPeerId, newChannel, currentQueue); + await reconnectionOrchestrator.flushQueuedMessages( + targetPeerId, + newChannel, + currentQueue, + ); } } } @@ -861,12 +451,10 @@ export async function initNetwork( // Set up inbound connection handler connectionFactory.onInboundConnection((channel) => { - // Reject inbound connections from intentionally closed peers - if (intentionallyClosed.has(channel.peerId)) { + if (peerRegistry.isIntentionallyClosed(channel.peerId)) { logger.log( `${channel.peerId}:: rejecting inbound connection from intentionally closed peer`, ); - // Explicitly close the channel to release network resources const closePromise = connectionFactory.closeChannel( channel, channel.peerId, @@ -883,16 +471,13 @@ export async function initNetwork( return; } - // Check connection limit for inbound connections only if no existing channel - // If a channel already exists, this is likely a reconnection and the peer already has a slot - if (!channels.has(channel.peerId)) { + if (!peerRegistry.hasChannel(channel.peerId)) { try { checkConnectionLimit(); } catch { logger.log( `${channel.peerId}:: rejecting inbound connection due to connection limit`, ); - // Explicitly close the channel to release network resources const closePromise = connectionFactory.closeChannel( channel, channel.peerId, @@ -925,26 +510,19 @@ export async function initNetwork( /** * Explicitly close a connection to a peer. - * Marks the peer as intentionally closed to prevent automatic reconnection. * * @param peerId - The peer ID to close the connection for. */ async function closeConnection(peerId: string): Promise { logger.log(`${peerId}:: explicitly closing connection`); - intentionallyClosed.add(peerId); - // Get the channel before removing from map - const channel = channels.get(peerId); - channels.delete(peerId); - // Stop any ongoing reconnection attempts + peerRegistry.markIntentionallyClosed(peerId); + const channel = peerRegistry.getChannel(peerId); + peerRegistry.removeChannel(peerId); if (reconnectionManager.isReconnecting(peerId)) { reconnectionManager.stopReconnection(peerId); } - // Clear any queued messages - const queue = messageQueues.get(peerId); - if (queue) { - queue.clear(); - } - // Actually close the underlying network connection + const queue = peerRegistry.getMessageQueue(peerId); + queue.clear(); if (channel) { try { await connectionFactory.closeChannel(channel, peerId); @@ -961,21 +539,11 @@ export async function initNetwork( * @param hints - Location hints for the peer. */ function registerLocationHints(peerId: string, hints: string[]): void { - const oldHints = locationHints.get(peerId); - if (oldHints) { - const newHints = new Set(oldHints); - for (const hint of hints) { - newHints.add(hint); - } - locationHints.set(peerId, Array.from(newHints)); - } else { - locationHints.set(peerId, Array.from(hints)); - } + peerRegistry.registerLocationHints(peerId, hints); } /** * Manually reconnect to a peer after intentional close. - * Clears the intentional close flag and initiates reconnection. * * @param peerId - The peer ID to reconnect to. * @param hints - The hints to use for the reconnection. @@ -985,13 +553,12 @@ export async function initNetwork( hints: string[] = [], ): Promise { logger.log(`${peerId}:: manually reconnecting after intentional close`); - intentionallyClosed.delete(peerId); - // If already reconnecting, don't start another attempt + peerRegistry.clearIntentionallyClosed(peerId); if (reconnectionManager.isReconnecting(peerId)) { return; } registerLocationHints(peerId, hints); - handleConnectionLoss(peerId); + reconnectionOrchestrator.handleConnectionLoss(peerId); } /** @@ -999,26 +566,20 @@ export async function initNetwork( */ async function stop(): Promise { logger.log('Stopping kernel network...'); - // Stop wake detector if (cleanupWakeDetector) { cleanupWakeDetector(); cleanupWakeDetector = undefined; } - // Stop cleanup interval if (cleanupIntervalId) { clearInterval(cleanupIntervalId); cleanupIntervalId = undefined; } - stopController.abort(); // cancels all delays and dials + stopController.abort(); await connectionFactory.stop(); - channels.clear(); + peerRegistry.clear(); reconnectionManager.clear(); - messageQueues.clear(); - intentionallyClosed.clear(); - lastConnectionTime.clear(); } - // Return the sender with a stop handle and connection management functions return { sendRemoteMessage, stop, From 7f69b204bbcca819e378c1dcff6aec38a6e68f01 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 14 Jan 2026 01:25:50 +0100 Subject: [PATCH 07/10] chore(remotes): extract reuseOrReturnChannel to shared utility Deduplicate the reuseOrReturnChannel function that existed in both transport.ts and reconnection-orchestrator.ts by extracting it to a new channel-utils.ts module. Co-Authored-By: Claude Opus 4.5 --- .../src/remotes/platform/channel-utils.ts | 44 ++++++++++++++++++ .../platform/reconnection-orchestrator.ts | 45 +++---------------- .../src/remotes/platform/transport.ts | 44 +++--------------- 3 files changed, 58 insertions(+), 75 deletions(-) create mode 100644 packages/ocap-kernel/src/remotes/platform/channel-utils.ts diff --git a/packages/ocap-kernel/src/remotes/platform/channel-utils.ts b/packages/ocap-kernel/src/remotes/platform/channel-utils.ts new file mode 100644 index 000000000..92b4c2b7a --- /dev/null +++ b/packages/ocap-kernel/src/remotes/platform/channel-utils.ts @@ -0,0 +1,44 @@ +import type { ConnectionFactory } from './connection-factory.ts'; +import type { PeerRegistry } from './peer-registry.ts'; +import type { Channel } from '../types.ts'; + +/** + * Check if an existing channel exists for a peer, and if so, reuse it. + * Otherwise, return the dialed channel for the caller to register. + * + * @param peerId - The peer ID for the channel. + * @param dialedChannel - The newly dialed channel. + * @param peerRegistry - The peer registry to check for existing channels. + * @param connectionFactory - The connection factory to close channels. + * @returns The channel to use, or null if existing channel died and dialed was closed. + */ +export async function reuseOrReturnChannel( + peerId: string, + dialedChannel: Channel, + peerRegistry: PeerRegistry, + connectionFactory: ConnectionFactory, +): Promise { + const existingChannel = peerRegistry.getChannel(peerId); + if (existingChannel) { + if (dialedChannel !== existingChannel) { + await connectionFactory.closeChannel(dialedChannel, peerId); + const currentChannel = peerRegistry.getChannel(peerId); + if (currentChannel === existingChannel) { + return existingChannel; + } + if (currentChannel) { + return currentChannel; + } + return null; + } + const currentChannel = peerRegistry.getChannel(peerId); + if (currentChannel === existingChannel) { + return existingChannel; + } + if (currentChannel) { + return currentChannel; + } + return null; + } + return dialedChannel; +} diff --git a/packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.ts b/packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.ts index 452f48dc4..f45a46abb 100644 --- a/packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.ts +++ b/packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.ts @@ -6,6 +6,7 @@ import { import type { Logger } from '@metamask/logger'; import { fromString } from 'uint8arrays'; +import { reuseOrReturnChannel } from './channel-utils.ts'; import type { ConnectionFactory } from './connection-factory.ts'; import type { MessageQueue } from './message-queue.ts'; import type { PeerRegistry } from './peer-registry.ts'; @@ -111,43 +112,6 @@ export function makeReconnectionOrchestrator( } } - /** - * Check if an existing channel exists for a peer, and if so, reuse it. - * Otherwise, return the dialed channel for the caller to register. - * - * @param peerId - The peer ID for the channel. - * @param dialedChannel - The newly dialed channel. - * @returns The channel to use, or null if existing channel died and dialed was closed. - */ - async function reuseOrReturnChannel( - peerId: string, - dialedChannel: Channel, - ): Promise { - const existingChannel = peerRegistry.getChannel(peerId); - if (existingChannel) { - if (dialedChannel !== existingChannel) { - await connectionFactory.closeChannel(dialedChannel, peerId); - const currentChannel = peerRegistry.getChannel(peerId); - if (currentChannel === existingChannel) { - return existingChannel; - } - if (currentChannel) { - return currentChannel; - } - return null; - } - const currentChannel = peerRegistry.getChannel(peerId); - if (currentChannel === existingChannel) { - return existingChannel; - } - if (currentChannel) { - return currentChannel; - } - return null; - } - return dialedChannel; - } - /** * Handle connection loss for a given peer ID. * Skips reconnection if the peer was intentionally closed. @@ -244,7 +208,12 @@ export function makeReconnectionOrchestrator( queue = peerRegistry.getMessageQueue(peerId); - channel = await reuseOrReturnChannel(peerId, channel); + channel = await reuseOrReturnChannel( + peerId, + channel, + peerRegistry, + connectionFactory, + ); if (channel === null) { logger.log( `${peerId}:: existing channel died during reuse check, continuing reconnection loop`, diff --git a/packages/ocap-kernel/src/remotes/platform/transport.ts b/packages/ocap-kernel/src/remotes/platform/transport.ts index 361a7f774..aaa1a8d6c 100644 --- a/packages/ocap-kernel/src/remotes/platform/transport.ts +++ b/packages/ocap-kernel/src/remotes/platform/transport.ts @@ -4,6 +4,7 @@ import { Logger } from '@metamask/logger'; import { fromString } from 'uint8arrays'; import { makeChannelReader } from './channel-reader.ts'; +import { reuseOrReturnChannel } from './channel-utils.ts'; import { ConnectionFactory } from './connection-factory.ts'; import { PeerRegistry } from './peer-registry.ts'; import { makeReconnectionOrchestrator } from './reconnection-orchestrator.ts'; @@ -246,42 +247,6 @@ export async function initNetwork( outputError, }); - /** - * Check if an existing channel exists for a peer, and if so, reuse it. - * - * @param peerId - The peer ID for the channel. - * @param dialedChannel - The newly dialed channel. - * @returns The channel to use, or null if existing channel died and dialed was closed. - */ - async function reuseOrReturnChannel( - peerId: string, - dialedChannel: Channel, - ): Promise { - const existingChannel = peerRegistry.getChannel(peerId); - if (existingChannel) { - if (dialedChannel !== existingChannel) { - await connectionFactory.closeChannel(dialedChannel, peerId); - const currentChannel = peerRegistry.getChannel(peerId); - if (currentChannel === existingChannel) { - return existingChannel; - } - if (currentChannel) { - return currentChannel; - } - return null; - } - const currentChannel = peerRegistry.getChannel(peerId); - if (currentChannel === existingChannel) { - return existingChannel; - } - if (currentChannel) { - return currentChannel; - } - return null; - } - return dialedChannel; - } - /** * Clean up stale peer data for peers inactive for more than stalePeerTimeoutMs. */ @@ -361,7 +326,12 @@ export async function initNetwork( return; } - channel = await reuseOrReturnChannel(targetPeerId, channel); + channel = await reuseOrReturnChannel( + targetPeerId, + channel, + peerRegistry, + connectionFactory, + ); if (channel === null) { logger.log( `${targetPeerId}:: existing channel died during reuse check, triggering reconnection`, From 664222139e3854bb79d42a4eedd1aa7d10fa2a12 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 14 Jan 2026 17:07:28 +0100 Subject: [PATCH 08/10] test(remotes): add unit tests for platform modules Add comprehensive unit tests for the extracted platform modules: - peer-registry.test.ts: Tests for PeerRegistry class - channel-utils.test.ts: Tests for reuseOrReturnChannel utility - channel-reader.test.ts: Tests for makeChannelReader function - reconnection-orchestrator.test.ts: Tests for makeReconnectionOrchestrator function Co-Authored-By: Claude Opus 4.5 --- .../remotes/platform/channel-reader.test.ts | 377 ++++++++++++++ .../remotes/platform/channel-utils.test.ts | 216 ++++++++ .../remotes/platform/peer-registry.test.ts | 488 ++++++++++++++++++ .../reconnection-orchestrator.test.ts | 382 ++++++++++++++ 4 files changed, 1463 insertions(+) create mode 100644 packages/ocap-kernel/src/remotes/platform/channel-reader.test.ts create mode 100644 packages/ocap-kernel/src/remotes/platform/channel-utils.test.ts create mode 100644 packages/ocap-kernel/src/remotes/platform/peer-registry.test.ts create mode 100644 packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.test.ts diff --git a/packages/ocap-kernel/src/remotes/platform/channel-reader.test.ts b/packages/ocap-kernel/src/remotes/platform/channel-reader.test.ts new file mode 100644 index 000000000..5db4230a9 --- /dev/null +++ b/packages/ocap-kernel/src/remotes/platform/channel-reader.test.ts @@ -0,0 +1,377 @@ +import { AbortError } from '@metamask/kernel-errors'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeChannelReader } from './channel-reader.ts'; +import type { PeerRegistry } from './peer-registry.ts'; +import type { Channel, RemoteMessageHandler } from '../types.ts'; + +function createMockChannel( + peerId: string, + readBehavior: () => Promise, +): Channel { + return { + peerId, + msgStream: { + read: vi.fn().mockImplementation(readBehavior), + write: vi.fn(), + }, + } as unknown as Channel; +} + +function createMockPeerRegistry(): { + peerRegistry: PeerRegistry; + getChannel: ReturnType; + updateLastConnectionTime: ReturnType; + markIntentionallyClosed: ReturnType; + removeChannel: ReturnType; +} { + const getChannel = vi.fn(); + const updateLastConnectionTime = vi.fn(); + const markIntentionallyClosed = vi.fn(); + const removeChannel = vi.fn(); + return { + peerRegistry: { + getChannel, + updateLastConnectionTime, + markIntentionallyClosed, + removeChannel, + } as unknown as PeerRegistry, + getChannel, + updateLastConnectionTime, + markIntentionallyClosed, + removeChannel, + }; +} + +function createMockLogger(): { log: ReturnType } { + return { log: vi.fn() }; +} + +describe('makeChannelReader', () => { + const peerId = 'peer1'; + let peerRegistry: PeerRegistry; + let getChannel: ReturnType; + let updateLastConnectionTime: ReturnType; + let markIntentionallyClosed: ReturnType; + let removeChannel: ReturnType; + let remoteMessageHandler: RemoteMessageHandler; + let onConnectionLoss: ReturnType; + let onMessageReceived: ReturnType; + let outputError: ReturnType; + let logger: { log: ReturnType }; + let abortController: AbortController; + + beforeEach(() => { + const mockRegistry = createMockPeerRegistry(); + peerRegistry = mockRegistry.peerRegistry; + getChannel = mockRegistry.getChannel; + updateLastConnectionTime = mockRegistry.updateLastConnectionTime; + markIntentionallyClosed = mockRegistry.markIntentionallyClosed; + removeChannel = mockRegistry.removeChannel; + + remoteMessageHandler = vi.fn().mockResolvedValue(undefined); + onConnectionLoss = vi.fn(); + onMessageReceived = vi.fn(); + outputError = vi.fn(); + logger = createMockLogger(); + abortController = new AbortController(); + }); + + function createReader() { + return makeChannelReader({ + peerRegistry, + remoteMessageHandler, + signal: abortController.signal, + logger: logger as unknown as Parameters< + typeof makeChannelReader + >[0]['logger'], + onConnectionLoss, + onMessageReceived, + outputError, + }); + } + + describe('readChannel', () => { + describe('message processing', () => { + it('reads and processes messages from channel', async () => { + let readCount = 0; + const channel = createMockChannel(peerId, async () => { + readCount += 1; + if (readCount === 1) { + return new TextEncoder().encode('message1'); + } + if (readCount === 2) { + return new TextEncoder().encode('message2'); + } + return undefined; // Stream end + }); + getChannel.mockReturnValue(channel); + + const reader = createReader(); + await reader.readChannel(channel); + + expect(remoteMessageHandler).toHaveBeenCalledTimes(2); + expect(remoteMessageHandler).toHaveBeenCalledWith(peerId, 'message1'); + expect(remoteMessageHandler).toHaveBeenCalledWith(peerId, 'message2'); + }); + + it('calls onMessageReceived for each message', async () => { + let readCount = 0; + const channel = createMockChannel(peerId, async () => { + readCount += 1; + if (readCount <= 2) { + return new TextEncoder().encode(`msg${readCount}`); + } + return undefined; + }); + getChannel.mockReturnValue(channel); + + const reader = createReader(); + await reader.readChannel(channel); + + expect(onMessageReceived).toHaveBeenCalledTimes(2); + expect(onMessageReceived).toHaveBeenCalledWith(peerId); + }); + + it('updates last connection time for each message', async () => { + let readCount = 0; + const channel = createMockChannel(peerId, async () => { + readCount += 1; + if (readCount === 1) { + return new TextEncoder().encode('msg'); + } + return undefined; + }); + getChannel.mockReturnValue(channel); + + const reader = createReader(); + await reader.readChannel(channel); + + expect(updateLastConnectionTime).toHaveBeenCalledWith(peerId); + }); + + it('exits loop when stream returns undefined', async () => { + const channel = createMockChannel(peerId, async () => undefined); + getChannel.mockReturnValue(channel); + + const reader = createReader(); + await reader.readChannel(channel); + + expect(logger.log).toHaveBeenCalledWith(`${peerId}:: stream ended`); + }); + }); + + describe('abort handling', () => { + it('throws AbortError when signal is aborted', async () => { + const channel = createMockChannel(peerId, async () => { + return new TextEncoder().encode('msg'); + }); + getChannel.mockReturnValue(channel); + abortController.abort(); + + const reader = createReader(); + + await expect(reader.readChannel(channel)).rejects.toThrow(AbortError); + expect(logger.log).toHaveBeenCalledWith(`reader abort: ${peerId}`); + }); + + it('checks abort signal before each read', async () => { + let readCount = 0; + const channel = createMockChannel(peerId, async () => { + readCount += 1; + if (readCount === 2) { + // Abort during the second read, before returning data + abortController.abort(); + } + return new TextEncoder().encode(`msg${readCount}`); + }); + getChannel.mockReturnValue(channel); + + const reader = createReader(); + + await expect(reader.readChannel(channel)).rejects.toThrow(AbortError); + // First message processed, second message processed, then abort checked on third iteration + expect(remoteMessageHandler).toHaveBeenCalledTimes(2); + }); + }); + + describe('error handling', () => { + it('triggers connection loss on read error for current channel', async () => { + const error = new Error('Read failed'); + const channel = createMockChannel(peerId, async () => { + throw error; + }); + getChannel.mockReturnValue(channel); + + const reader = createReader(); + + await expect(reader.readChannel(channel)).rejects.toThrow(error); + expect(onConnectionLoss).toHaveBeenCalledWith(peerId, channel); + expect(outputError).toHaveBeenCalledWith( + peerId, + `reading message from ${peerId}`, + error, + ); + }); + + it('ignores errors from stale channels', async () => { + const error = new Error('Read failed'); + const channel = createMockChannel(peerId, async () => { + throw error; + }); + const differentChannel = createMockChannel( + peerId, + async () => undefined, + ); + getChannel.mockReturnValue(differentChannel); // Different channel is current + + const reader = createReader(); + + await expect(reader.readChannel(channel)).rejects.toThrow(error); + expect(onConnectionLoss).not.toHaveBeenCalled(); + expect(outputError).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith( + `${peerId}:: ignoring error from stale channel`, + ); + }); + }); + + describe('graceful disconnect (SCTP abort)', () => { + it('marks peer as intentionally closed on SCTP user-initiated abort', async () => { + const sctpError = Object.assign(new Error('SCTP failure'), { + errorDetail: 'sctp-failure', + sctpCauseCode: 12, + }); + const channel = createMockChannel(peerId, async () => { + throw sctpError; + }); + getChannel.mockReturnValue(channel); + + const reader = createReader(); + + await expect(reader.readChannel(channel)).rejects.toThrow('SCTP'); + expect(markIntentionallyClosed).toHaveBeenCalledWith(peerId); + expect(onConnectionLoss).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith( + `${peerId}:: remote intentionally disconnected`, + ); + }); + + it('does not mark as intentionally closed for stale channel SCTP abort', async () => { + const sctpError = Object.assign(new Error('SCTP failure'), { + errorDetail: 'sctp-failure', + sctpCauseCode: 12, + }); + const channel = createMockChannel(peerId, async () => { + throw sctpError; + }); + const differentChannel = createMockChannel( + peerId, + async () => undefined, + ); + getChannel.mockReturnValue(differentChannel); + + const reader = createReader(); + + await expect(reader.readChannel(channel)).rejects.toThrow('SCTP'); + expect(markIntentionallyClosed).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith( + `${peerId}:: stale channel intentionally disconnected`, + ); + }); + }); + + describe('cleanup', () => { + it('removes channel on normal exit', async () => { + const channel = createMockChannel(peerId, async () => undefined); + getChannel.mockReturnValue(channel); + + const reader = createReader(); + await reader.readChannel(channel); + + expect(removeChannel).toHaveBeenCalledWith(peerId); + }); + + it('removes channel on error', async () => { + const channel = createMockChannel(peerId, async () => { + throw new Error('Read failed'); + }); + getChannel.mockReturnValue(channel); + + const reader = createReader(); + + await expect(reader.readChannel(channel)).rejects.toThrow( + 'Read failed', + ); + expect(removeChannel).toHaveBeenCalledWith(peerId); + }); + + it('does not remove different channel', async () => { + const channel = createMockChannel(peerId, async () => undefined); + const differentChannel = createMockChannel( + peerId, + async () => undefined, + ); + getChannel.mockReturnValue(differentChannel); + + const reader = createReader(); + await reader.readChannel(channel); + + expect(removeChannel).not.toHaveBeenCalled(); + }); + + it('removes channel on abort', async () => { + const channel = createMockChannel(peerId, async () => { + return new TextEncoder().encode('msg'); + }); + getChannel.mockReturnValue(channel); + abortController.abort(); + + const reader = createReader(); + + await expect(reader.readChannel(channel)).rejects.toThrow(AbortError); + expect(removeChannel).toHaveBeenCalledWith(peerId); + }); + }); + }); + + describe('integration scenarios', () => { + it('handles multiple messages then graceful close', async () => { + let readCount = 0; + const channel = createMockChannel(peerId, async () => { + readCount += 1; + if (readCount <= 3) { + return new TextEncoder().encode(`message${readCount}`); + } + return undefined; + }); + getChannel.mockReturnValue(channel); + + const reader = createReader(); + await reader.readChannel(channel); + + expect(remoteMessageHandler).toHaveBeenCalledTimes(3); + expect(onMessageReceived).toHaveBeenCalledTimes(3); + expect(removeChannel).toHaveBeenCalled(); + }); + + it('handles error mid-stream', async () => { + let readCount = 0; + const error = new Error('Connection lost'); + const channel = createMockChannel(peerId, async () => { + readCount += 1; + if (readCount === 1) { + return new TextEncoder().encode('msg1'); + } + throw error; + }); + getChannel.mockReturnValue(channel); + + const reader = createReader(); + + await expect(reader.readChannel(channel)).rejects.toThrow(error); + expect(remoteMessageHandler).toHaveBeenCalledTimes(1); + expect(onConnectionLoss).toHaveBeenCalledWith(peerId, channel); + }); + }); +}); diff --git a/packages/ocap-kernel/src/remotes/platform/channel-utils.test.ts b/packages/ocap-kernel/src/remotes/platform/channel-utils.test.ts new file mode 100644 index 000000000..ca89d50cc --- /dev/null +++ b/packages/ocap-kernel/src/remotes/platform/channel-utils.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { reuseOrReturnChannel } from './channel-utils.ts'; +import type { ConnectionFactory } from './connection-factory.ts'; +import type { PeerRegistry } from './peer-registry.ts'; +import type { Channel } from '../types.ts'; + +function createMockChannel(peerId: string): Channel { + return { + peerId, + msgStream: { + read: vi.fn(), + write: vi.fn(), + }, + } as unknown as Channel; +} + +function createMockPeerRegistry(): { + peerRegistry: PeerRegistry; + getChannel: ReturnType; +} { + const getChannel = vi.fn(); + return { + peerRegistry: { getChannel } as unknown as PeerRegistry, + getChannel, + }; +} + +function createMockConnectionFactory(): { + connectionFactory: ConnectionFactory; + closeChannel: ReturnType; +} { + const closeChannel = vi.fn().mockResolvedValue(undefined); + return { + connectionFactory: { closeChannel } as unknown as ConnectionFactory, + closeChannel, + }; +} + +describe('reuseOrReturnChannel', () => { + const peerId = 'peer1'; + let dialedChannel: Channel; + let peerRegistry: PeerRegistry; + let getChannel: ReturnType; + let connectionFactory: ConnectionFactory; + let closeChannel: ReturnType; + + beforeEach(() => { + dialedChannel = createMockChannel(peerId); + const mockRegistry = createMockPeerRegistry(); + peerRegistry = mockRegistry.peerRegistry; + getChannel = mockRegistry.getChannel; + const mockFactory = createMockConnectionFactory(); + connectionFactory = mockFactory.connectionFactory; + closeChannel = mockFactory.closeChannel; + }); + + describe('when no existing channel', () => { + it('returns the dialed channel', async () => { + getChannel.mockReturnValue(undefined); + + const result = await reuseOrReturnChannel( + peerId, + dialedChannel, + peerRegistry, + connectionFactory, + ); + + expect(result).toBe(dialedChannel); + expect(closeChannel).not.toHaveBeenCalled(); + }); + }); + + describe('when existing channel is different from dialed', () => { + it('closes dialed channel and returns existing if still present', async () => { + const existingChannel = createMockChannel(peerId); + getChannel + .mockReturnValueOnce(existingChannel) // First check + .mockReturnValueOnce(existingChannel); // After close check + + const result = await reuseOrReturnChannel( + peerId, + dialedChannel, + peerRegistry, + connectionFactory, + ); + + expect(closeChannel).toHaveBeenCalledWith(dialedChannel, peerId); + expect(result).toBe(existingChannel); + }); + + it('returns new channel if existing was replaced during close', async () => { + const existingChannel = createMockChannel(peerId); + const newChannel = createMockChannel(peerId); + getChannel + .mockReturnValueOnce(existingChannel) // First check + .mockReturnValueOnce(newChannel); // After close - different channel + + const result = await reuseOrReturnChannel( + peerId, + dialedChannel, + peerRegistry, + connectionFactory, + ); + + expect(closeChannel).toHaveBeenCalledWith(dialedChannel, peerId); + expect(result).toBe(newChannel); + }); + + it('returns null if existing channel died during close', async () => { + const existingChannel = createMockChannel(peerId); + getChannel + .mockReturnValueOnce(existingChannel) // First check + .mockReturnValueOnce(undefined); // After close - no channel + + const result = await reuseOrReturnChannel( + peerId, + dialedChannel, + peerRegistry, + connectionFactory, + ); + + expect(closeChannel).toHaveBeenCalledWith(dialedChannel, peerId); + expect(result).toBeNull(); + }); + }); + + describe('when existing channel is same as dialed', () => { + it('returns existing channel if still present', async () => { + // Same channel for both dialed and existing + getChannel + .mockReturnValueOnce(dialedChannel) // First check - same as dialed + .mockReturnValueOnce(dialedChannel); // Second check - still same + + const result = await reuseOrReturnChannel( + peerId, + dialedChannel, + peerRegistry, + connectionFactory, + ); + + expect(closeChannel).not.toHaveBeenCalled(); + expect(result).toBe(dialedChannel); + }); + + it('returns new channel if original was replaced', async () => { + const newChannel = createMockChannel(peerId); + getChannel + .mockReturnValueOnce(dialedChannel) // First check - same as dialed + .mockReturnValueOnce(newChannel); // Second check - different + + const result = await reuseOrReturnChannel( + peerId, + dialedChannel, + peerRegistry, + connectionFactory, + ); + + expect(closeChannel).not.toHaveBeenCalled(); + expect(result).toBe(newChannel); + }); + + it('returns null if channel was removed', async () => { + getChannel + .mockReturnValueOnce(dialedChannel) // First check - same as dialed + .mockReturnValueOnce(undefined); // Second check - removed + + const result = await reuseOrReturnChannel( + peerId, + dialedChannel, + peerRegistry, + connectionFactory, + ); + + expect(closeChannel).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('handles close channel error gracefully', async () => { + const existingChannel = createMockChannel(peerId); + getChannel + .mockReturnValueOnce(existingChannel) + .mockReturnValueOnce(existingChannel); + closeChannel.mockRejectedValue(new Error('Close failed')); + + await expect( + reuseOrReturnChannel( + peerId, + dialedChannel, + peerRegistry, + connectionFactory, + ), + ).rejects.toThrow('Close failed'); + }); + + it('makes correct sequence of calls', async () => { + const existingChannel = createMockChannel(peerId); + getChannel + .mockReturnValueOnce(existingChannel) + .mockReturnValueOnce(existingChannel); + + await reuseOrReturnChannel( + peerId, + dialedChannel, + peerRegistry, + connectionFactory, + ); + + expect(getChannel).toHaveBeenCalledTimes(2); + expect(getChannel).toHaveBeenNthCalledWith(1, peerId); + expect(getChannel).toHaveBeenNthCalledWith(2, peerId); + }); + }); +}); diff --git a/packages/ocap-kernel/src/remotes/platform/peer-registry.test.ts b/packages/ocap-kernel/src/remotes/platform/peer-registry.test.ts new file mode 100644 index 000000000..95ac3d1ef --- /dev/null +++ b/packages/ocap-kernel/src/remotes/platform/peer-registry.test.ts @@ -0,0 +1,488 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { PeerRegistry } from './peer-registry.ts'; +import type { Channel } from '../types.ts'; + +function createMockChannel(peerId: string): Channel { + return { + peerId, + msgStream: { + read: vi.fn(), + write: vi.fn(), + }, + } as unknown as Channel; +} + +describe('PeerRegistry', () => { + let registry: PeerRegistry; + + beforeEach(() => { + registry = new PeerRegistry(100); + }); + + describe('constructor', () => { + it('creates empty registry with specified max queue', () => { + expect(registry.channelCount).toBe(0); + }); + }); + + describe('channel management', () => { + describe('getChannel', () => { + it('returns undefined for unknown peer', () => { + expect(registry.getChannel('unknown')).toBeUndefined(); + }); + + it('returns channel for known peer', () => { + const channel = createMockChannel('peer1'); + registry.setChannel('peer1', channel); + + expect(registry.getChannel('peer1')).toBe(channel); + }); + }); + + describe('hasChannel', () => { + it('returns false for unknown peer', () => { + expect(registry.hasChannel('unknown')).toBe(false); + }); + + it('returns true for peer with channel', () => { + const channel = createMockChannel('peer1'); + registry.setChannel('peer1', channel); + + expect(registry.hasChannel('peer1')).toBe(true); + }); + }); + + describe('setChannel', () => { + it('sets channel for peer', () => { + const channel = createMockChannel('peer1'); + + registry.setChannel('peer1', channel); + + expect(registry.getChannel('peer1')).toBe(channel); + }); + + it('returns undefined when no previous channel', () => { + const channel = createMockChannel('peer1'); + + const previous = registry.setChannel('peer1', channel); + + expect(previous).toBeUndefined(); + }); + + it('returns previous channel when replacing', () => { + const channel1 = createMockChannel('peer1'); + const channel2 = createMockChannel('peer1'); + + registry.setChannel('peer1', channel1); + const previous = registry.setChannel('peer1', channel2); + + expect(previous).toBe(channel1); + expect(registry.getChannel('peer1')).toBe(channel2); + }); + + it('updates last connection time', () => { + const channel = createMockChannel('peer1'); + const before = Date.now(); + + registry.setChannel('peer1', channel); + + const lastTime = registry.getLastConnectionTime('peer1'); + expect(lastTime).toBeDefined(); + expect(lastTime).toBeGreaterThanOrEqual(before); + expect(lastTime).toBeLessThanOrEqual(Date.now()); + }); + }); + + describe('removeChannel', () => { + it('returns false for unknown peer', () => { + expect(registry.removeChannel('unknown')).toBe(false); + }); + + it('removes channel and returns true', () => { + const channel = createMockChannel('peer1'); + registry.setChannel('peer1', channel); + + const result = registry.removeChannel('peer1'); + + expect(result).toBe(true); + expect(registry.getChannel('peer1')).toBeUndefined(); + expect(registry.hasChannel('peer1')).toBe(false); + }); + }); + + describe('channelCount', () => { + it('returns 0 for empty registry', () => { + expect(registry.channelCount).toBe(0); + }); + + it('tracks active channels', () => { + registry.setChannel('peer1', createMockChannel('peer1')); + expect(registry.channelCount).toBe(1); + + registry.setChannel('peer2', createMockChannel('peer2')); + expect(registry.channelCount).toBe(2); + + registry.removeChannel('peer1'); + expect(registry.channelCount).toBe(1); + }); + }); + }); + + describe('message queue management', () => { + describe('getMessageQueue', () => { + it('creates new queue for unknown peer', () => { + const queue = registry.getMessageQueue('peer1'); + + expect(queue).toBeDefined(); + expect(queue).toHaveLength(0); + }); + + it('returns same queue on subsequent calls', () => { + const queue1 = registry.getMessageQueue('peer1'); + queue1.enqueue('message'); + + const queue2 = registry.getMessageQueue('peer1'); + + expect(queue2).toBe(queue1); + expect(queue2).toHaveLength(1); + }); + + it('sets last connection time for new peer', () => { + const before = Date.now(); + + registry.getMessageQueue('peer1'); + + const lastTime = registry.getLastConnectionTime('peer1'); + expect(lastTime).toBeDefined(); + expect(lastTime).toBeGreaterThanOrEqual(before); + }); + + it('does not override existing last connection time', () => { + const channel = createMockChannel('peer1'); + registry.setChannel('peer1', channel); + const originalTime = registry.getLastConnectionTime('peer1'); + + // Small delay to ensure time difference + registry.getMessageQueue('peer1'); + + expect(registry.getLastConnectionTime('peer1')).toBe(originalTime); + }); + + it('respects maxQueue setting', () => { + const smallRegistry = new PeerRegistry(3); + const queue = smallRegistry.getMessageQueue('peer1'); + + queue.enqueue('msg1'); + queue.enqueue('msg2'); + queue.enqueue('msg3'); + queue.enqueue('msg4'); + + expect(queue).toHaveLength(3); + }); + }); + }); + + describe('intentionally closed management', () => { + describe('isIntentionallyClosed', () => { + it('returns false for unknown peer', () => { + expect(registry.isIntentionallyClosed('unknown')).toBe(false); + }); + + it('returns true after marking', () => { + registry.markIntentionallyClosed('peer1'); + + expect(registry.isIntentionallyClosed('peer1')).toBe(true); + }); + }); + + describe('markIntentionallyClosed', () => { + it('marks peer as intentionally closed', () => { + registry.markIntentionallyClosed('peer1'); + + expect(registry.isIntentionallyClosed('peer1')).toBe(true); + }); + + it('is idempotent', () => { + registry.markIntentionallyClosed('peer1'); + registry.markIntentionallyClosed('peer1'); + + expect(registry.isIntentionallyClosed('peer1')).toBe(true); + }); + }); + + describe('clearIntentionallyClosed', () => { + it('clears intentionally closed flag', () => { + registry.markIntentionallyClosed('peer1'); + expect(registry.isIntentionallyClosed('peer1')).toBe(true); + + registry.clearIntentionallyClosed('peer1'); + + expect(registry.isIntentionallyClosed('peer1')).toBe(false); + }); + + it('handles unknown peer', () => { + expect(() => + registry.clearIntentionallyClosed('unknown'), + ).not.toThrow(); + }); + }); + }); + + describe('last connection time management', () => { + describe('updateLastConnectionTime', () => { + it('updates time for peer', () => { + const before = Date.now(); + + registry.updateLastConnectionTime('peer1'); + + const lastTime = registry.getLastConnectionTime('peer1'); + expect(lastTime).toBeDefined(); + expect(lastTime).toBeGreaterThanOrEqual(before); + expect(lastTime).toBeLessThanOrEqual(Date.now()); + }); + + it('overwrites previous time', async () => { + registry.updateLastConnectionTime('peer1'); + const firstTime = registry.getLastConnectionTime('peer1'); + + // Small delay + await new Promise((resolve) => setTimeout(resolve, 10)); + + registry.updateLastConnectionTime('peer1'); + const secondTime = registry.getLastConnectionTime('peer1'); + + expect(secondTime).toBeGreaterThan(firstTime as number); + }); + }); + + describe('getLastConnectionTime', () => { + it('returns undefined for unknown peer', () => { + expect(registry.getLastConnectionTime('unknown')).toBeUndefined(); + }); + }); + }); + + describe('location hints management', () => { + describe('getLocationHints', () => { + it('returns empty array for unknown peer', () => { + expect(registry.getLocationHints('unknown')).toStrictEqual([]); + }); + + it('returns registered hints', () => { + registry.registerLocationHints('peer1', ['/ip4/127.0.0.1/tcp/4001']); + + expect(registry.getLocationHints('peer1')).toStrictEqual([ + '/ip4/127.0.0.1/tcp/4001', + ]); + }); + }); + + describe('registerLocationHints', () => { + it('registers hints for new peer', () => { + registry.registerLocationHints('peer1', ['/ip4/127.0.0.1/tcp/4001']); + + expect(registry.getLocationHints('peer1')).toStrictEqual([ + '/ip4/127.0.0.1/tcp/4001', + ]); + }); + + it('merges with existing hints', () => { + registry.registerLocationHints('peer1', ['/ip4/127.0.0.1/tcp/4001']); + registry.registerLocationHints('peer1', ['/ip4/192.168.1.1/tcp/4001']); + + const hints = registry.getLocationHints('peer1'); + expect(hints).toContain('/ip4/127.0.0.1/tcp/4001'); + expect(hints).toContain('/ip4/192.168.1.1/tcp/4001'); + expect(hints).toHaveLength(2); + }); + + it('deduplicates hints', () => { + registry.registerLocationHints('peer1', ['/ip4/127.0.0.1/tcp/4001']); + registry.registerLocationHints('peer1', ['/ip4/127.0.0.1/tcp/4001']); + + expect(registry.getLocationHints('peer1')).toHaveLength(1); + }); + + it('handles multiple hints at once', () => { + registry.registerLocationHints('peer1', [ + '/ip4/127.0.0.1/tcp/4001', + '/ip4/192.168.1.1/tcp/4001', + ]); + + expect(registry.getLocationHints('peer1')).toHaveLength(2); + }); + }); + }); + + describe('stale peer detection', () => { + describe('findStalePeers', () => { + it('returns empty array when no peers', () => { + const stalePeers = registry.findStalePeers(1000, () => false); + + expect(stalePeers).toStrictEqual([]); + }); + + it('identifies stale peers without channel or reconnection', () => { + // Create a peer with only a message queue (no channel) + registry.getMessageQueue('peer1'); + + // Use -1 timeout so any time since last activity makes it stale + // (timeSinceLastActivity > -1 is always true for non-negative values) + const stalePeers = registry.findStalePeers(-1, () => false); + + expect(stalePeers).toContain('peer1'); + }); + + it('excludes peers with active channel', () => { + registry.setChannel('peer1', createMockChannel('peer1')); + + // Even with 0 timeout, peer with active channel should not be stale + const stalePeers = registry.findStalePeers(0, () => false); + + expect(stalePeers).not.toContain('peer1'); + }); + + it('excludes reconnecting peers', () => { + registry.getMessageQueue('peer1'); + + // Even with 0 timeout, reconnecting peer should not be stale + const stalePeers = registry.findStalePeers( + 0, + (peerId) => peerId === 'peer1', + ); + + expect(stalePeers).not.toContain('peer1'); + }); + + it('excludes peers within timeout', () => { + registry.getMessageQueue('peer1'); + + // Use a large timeout so peer is within timeout + const stalePeers = registry.findStalePeers( + Number.MAX_SAFE_INTEGER, + () => false, + ); + + expect(stalePeers).not.toContain('peer1'); + }); + }); + }); + + describe('peer removal', () => { + describe('removePeer', () => { + it('removes all state for peer', () => { + const channel = createMockChannel('peer1'); + registry.setChannel('peer1', channel); + registry.getMessageQueue('peer1').enqueue('msg'); + registry.markIntentionallyClosed('peer1'); + registry.registerLocationHints('peer1', ['/ip4/127.0.0.1/tcp/4001']); + + registry.removePeer('peer1'); + + expect(registry.getChannel('peer1')).toBeUndefined(); + expect(registry.isIntentionallyClosed('peer1')).toBe(false); + // After removePeer, lastConnectionTime should be undefined + // (calling getMessageQueue would create new state) + expect(registry.getLastConnectionTime('peer1')).toBeUndefined(); + expect(registry.getLocationHints('peer1')).toStrictEqual([]); + }); + + it('handles unknown peer', () => { + expect(() => registry.removePeer('unknown')).not.toThrow(); + }); + + it('does not affect other peers', () => { + registry.setChannel('peer1', createMockChannel('peer1')); + registry.setChannel('peer2', createMockChannel('peer2')); + + registry.removePeer('peer1'); + + expect(registry.hasChannel('peer1')).toBe(false); + expect(registry.hasChannel('peer2')).toBe(true); + }); + }); + + describe('clear', () => { + it('removes all state', () => { + registry.setChannel('peer1', createMockChannel('peer1')); + registry.setChannel('peer2', createMockChannel('peer2')); + registry.getMessageQueue('peer1').enqueue('msg'); + registry.markIntentionallyClosed('peer1'); + registry.registerLocationHints('peer1', ['/ip4/127.0.0.1/tcp/4001']); + + registry.clear(); + + expect(registry.channelCount).toBe(0); + expect(registry.getChannel('peer1')).toBeUndefined(); + expect(registry.getChannel('peer2')).toBeUndefined(); + expect(registry.isIntentionallyClosed('peer1')).toBe(false); + expect(registry.getLocationHints('peer1')).toStrictEqual([]); + }); + + it('handles empty registry', () => { + expect(() => registry.clear()).not.toThrow(); + }); + }); + }); + + describe('integration scenarios', () => { + it('handles typical peer lifecycle', () => { + const peerId = 'peer1'; + + // Initial connection + const channel = createMockChannel(peerId); + registry.setChannel(peerId, channel); + registry.registerLocationHints(peerId, ['/ip4/127.0.0.1/tcp/4001']); + + expect(registry.hasChannel(peerId)).toBe(true); + + // Receive messages (updates activity time) + registry.updateLastConnectionTime(peerId); + + // Connection lost + registry.removeChannel(peerId); + const queue = registry.getMessageQueue(peerId); + queue.enqueue('pending-message'); + + expect(registry.hasChannel(peerId)).toBe(false); + expect(queue).toHaveLength(1); + + // Reconnect + const newChannel = createMockChannel(peerId); + registry.setChannel(peerId, newChannel); + + expect(registry.hasChannel(peerId)).toBe(true); + }); + + it('handles intentional close flow', () => { + const peerId = 'peer1'; + const channel = createMockChannel(peerId); + + registry.setChannel(peerId, channel); + registry.markIntentionallyClosed(peerId); + registry.removeChannel(peerId); + registry.getMessageQueue(peerId).clear(); + + expect(registry.hasChannel(peerId)).toBe(false); + expect(registry.isIntentionallyClosed(peerId)).toBe(true); + + // Later reconnect + registry.clearIntentionallyClosed(peerId); + expect(registry.isIntentionallyClosed(peerId)).toBe(false); + }); + + it('handles multiple peers independently', () => { + registry.setChannel('peer1', createMockChannel('peer1')); + registry.setChannel('peer2', createMockChannel('peer2')); + registry.markIntentionallyClosed('peer1'); + registry.getMessageQueue('peer2').enqueue('msg'); + + expect(registry.hasChannel('peer1')).toBe(true); + expect(registry.hasChannel('peer2')).toBe(true); + expect(registry.isIntentionallyClosed('peer1')).toBe(true); + expect(registry.isIntentionallyClosed('peer2')).toBe(false); + expect(registry.getMessageQueue('peer2')).toHaveLength(1); + }); + }); +}); diff --git a/packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.test.ts b/packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.test.ts new file mode 100644 index 000000000..fb241e563 --- /dev/null +++ b/packages/ocap-kernel/src/remotes/platform/reconnection-orchestrator.test.ts @@ -0,0 +1,382 @@ +import * as kernelErrors from '@metamask/kernel-errors'; +import * as kernelUtils from '@metamask/kernel-utils'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { ConnectionFactory } from './connection-factory.ts'; +import { MessageQueue } from './message-queue.ts'; +import type { PeerRegistry } from './peer-registry.ts'; +import { makeReconnectionOrchestrator } from './reconnection-orchestrator.ts'; +import { ReconnectionManager } from './reconnection.ts'; +import type { Channel, OnRemoteGiveUp } from '../types.ts'; + +// Mock abortableDelay to avoid real delays +vi.mock('@metamask/kernel-utils', async () => { + const actual = await vi.importActual( + '@metamask/kernel-utils', + ); + return { + ...actual, + abortableDelay: vi.fn().mockResolvedValue(undefined), + }; +}); + +// Mock isRetryableNetworkError +vi.mock('@metamask/kernel-errors', async () => { + const actual = await vi.importActual( + '@metamask/kernel-errors', + ); + return { + ...actual, + isRetryableNetworkError: vi.fn().mockReturnValue(true), + }; +}); + +function createMockChannel(peerId: string): Channel { + return { + peerId, + msgStream: { + read: vi.fn(), + write: vi.fn(), + }, + } as unknown as Channel; +} + +function createMockLogger(): { log: ReturnType } { + return { log: vi.fn() }; +} + +describe('makeReconnectionOrchestrator', () => { + const peerId = 'peer1'; + let peerRegistry: PeerRegistry; + let connectionFactory: ConnectionFactory; + let reconnectionManager: ReconnectionManager; + let registerChannel: ReturnType; + let checkConnectionLimit: ReturnType; + let writeWithTimeout: ReturnType; + let outputError: ReturnType; + let onRemoteGiveUp: OnRemoteGiveUp; + let logger: { log: ReturnType }; + let abortController: AbortController; + let queue: MessageQueue; + + // Mocked registry functions + let getChannel: ReturnType; + let hasChannel: ReturnType; + let removeChannel: ReturnType; + let isIntentionallyClosed: ReturnType; + let getMessageQueue: ReturnType; + let getLocationHints: ReturnType; + + // Mocked factory functions + let dialIdempotent: ReturnType; + let closeChannel: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + // Setup mocks + getChannel = vi.fn().mockReturnValue(undefined); + hasChannel = vi.fn().mockReturnValue(true); + removeChannel = vi.fn(); + isIntentionallyClosed = vi.fn().mockReturnValue(false); + getMessageQueue = vi.fn(); + getLocationHints = vi.fn().mockReturnValue([]); + + peerRegistry = { + getChannel, + hasChannel, + removeChannel, + isIntentionallyClosed, + getMessageQueue, + getLocationHints, + } as unknown as PeerRegistry; + + dialIdempotent = vi.fn(); + closeChannel = vi.fn().mockResolvedValue(undefined); + + connectionFactory = { + dialIdempotent, + closeChannel, + } as unknown as ConnectionFactory; + + reconnectionManager = new ReconnectionManager(); + registerChannel = vi.fn(); + checkConnectionLimit = vi.fn(); + writeWithTimeout = vi.fn().mockResolvedValue(undefined); + outputError = vi.fn(); + onRemoteGiveUp = vi.fn(); + logger = createMockLogger(); + abortController = new AbortController(); + + queue = new MessageQueue(100); + getMessageQueue.mockReturnValue(queue); + }); + + function createOrchestrator(maxRetryAttempts: number | undefined = 5) { + return makeReconnectionOrchestrator({ + peerRegistry, + connectionFactory, + reconnectionManager, + signal: abortController.signal, + logger: logger as unknown as Parameters< + typeof makeReconnectionOrchestrator + >[0]['logger'], + maxRetryAttempts, + onRemoteGiveUp, + registerChannel, + checkConnectionLimit, + writeWithTimeout, + outputError, + }); + } + + describe('handleConnectionLoss', () => { + it('initiates reconnection for peer', () => { + const orchestrator = createOrchestrator(); + + orchestrator.handleConnectionLoss(peerId); + + expect(removeChannel).toHaveBeenCalledWith(peerId); + expect(reconnectionManager.isReconnecting(peerId)).toBe(true); + }); + + it('skips reconnection for intentionally closed peer', () => { + const orchestrator = createOrchestrator(); + isIntentionallyClosed.mockReturnValue(true); + + orchestrator.handleConnectionLoss(peerId); + + expect(removeChannel).not.toHaveBeenCalled(); + expect(reconnectionManager.isReconnecting(peerId)).toBe(false); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining('intentionally closed'), + ); + }); + + it('ignores loss from stale channel', () => { + const orchestrator = createOrchestrator(); + const currentChannel = createMockChannel(peerId); + const staleChannel = createMockChannel(peerId); + getChannel.mockReturnValue(currentChannel); + + orchestrator.handleConnectionLoss(peerId, staleChannel); + + expect(removeChannel).not.toHaveBeenCalled(); + expect(reconnectionManager.isReconnecting(peerId)).toBe(false); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining('stale channel'), + ); + }); + + it('does not start reconnection if already reconnecting', () => { + const orchestrator = createOrchestrator(); + reconnectionManager.startReconnection(peerId); + + orchestrator.handleConnectionLoss(peerId); + + // Should still be reconnecting but not double-start + expect(reconnectionManager.isReconnecting(peerId)).toBe(true); + }); + }); + + describe('attemptReconnection', () => { + beforeEach(() => { + reconnectionManager.startReconnection(peerId); + }); + + it('successfully reconnects and registers channel', async () => { + const orchestrator = createOrchestrator(); + const channel = createMockChannel(peerId); + dialIdempotent.mockResolvedValue(channel); + + await orchestrator.attemptReconnection(peerId); + + expect(dialIdempotent).toHaveBeenCalled(); + expect(registerChannel).toHaveBeenCalledWith(peerId, channel); + expect(reconnectionManager.isReconnecting(peerId)).toBe(false); + }); + + it('gives up after max retry attempts', async () => { + const orchestrator = createOrchestrator(2); + const error = new Error('Connection failed'); + dialIdempotent.mockRejectedValue(error); + queue.enqueue('pending-msg'); + + await orchestrator.attemptReconnection(peerId, 2); + + expect(onRemoteGiveUp).toHaveBeenCalledWith(peerId); + expect(queue).toHaveLength(0); // Queue cleared + expect(reconnectionManager.isReconnecting(peerId)).toBe(false); + }); + + it('gives up on non-retryable error', async () => { + const orchestrator = createOrchestrator(); + const error = new Error('Non-retryable'); + dialIdempotent.mockRejectedValue(error); + vi.mocked(kernelErrors.isRetryableNetworkError).mockReturnValue(false); + queue.enqueue('pending-msg'); + + await orchestrator.attemptReconnection(peerId); + + expect(onRemoteGiveUp).toHaveBeenCalledWith(peerId); + expect(queue).toHaveLength(0); + }); + + it('stops reconnection when signal is aborted during delay', async () => { + const orchestrator = createOrchestrator(); + vi.mocked(kernelUtils.abortableDelay).mockImplementation(async () => { + if (abortController.signal.aborted) { + throw new Error('Aborted'); + } + }); + abortController.abort(); + + await orchestrator.attemptReconnection(peerId); + + expect(reconnectionManager.isReconnecting(peerId)).toBe(false); + expect(dialIdempotent).not.toHaveBeenCalled(); + }); + + it('reuses existing channel when one appears during reconnection', async () => { + const orchestrator = createOrchestrator(); + const dialedChannel = createMockChannel(peerId); + const existingChannel = createMockChannel(peerId); + dialIdempotent.mockResolvedValue(dialedChannel); + + // No channel initially, then existing channel after dial + getChannel + .mockReturnValueOnce(undefined) // Before dial + .mockReturnValueOnce(existingChannel) // reuseOrReturnChannel first + .mockReturnValueOnce(existingChannel) // reuseOrReturnChannel second + .mockReturnValue(existingChannel); // After + + await orchestrator.attemptReconnection(peerId); + + expect(closeChannel).toHaveBeenCalledWith(dialedChannel, peerId); + expect(registerChannel).not.toHaveBeenCalled(); + }); + }); + + describe('flushQueuedMessages', () => { + it('sends all queued messages', async () => { + const orchestrator = createOrchestrator(); + const channel = createMockChannel(peerId); + queue.enqueue('msg1'); + queue.enqueue('msg2'); + queue.enqueue('msg3'); + + await orchestrator.flushQueuedMessages(peerId, channel, queue); + + expect(writeWithTimeout).toHaveBeenCalledTimes(3); + expect(queue).toHaveLength(0); + }); + + it('preserves failed and remaining messages on error', async () => { + const orchestrator = createOrchestrator(); + const channel = createMockChannel(peerId); + queue.enqueue('msg1'); + queue.enqueue('msg2'); + queue.enqueue('msg3'); + + writeWithTimeout + .mockResolvedValueOnce(undefined) // msg1 succeeds + .mockRejectedValueOnce(new Error('Send failed')); // msg2 fails + + await orchestrator.flushQueuedMessages(peerId, channel, queue); + + expect(queue).toHaveLength(2); // msg2 and msg3 preserved + expect(queue.messages).toContain('msg2'); + expect(queue.messages).toContain('msg3'); + }); + + it('triggers reconnection on flush failure', async () => { + const orchestrator = createOrchestrator(); + const channel = createMockChannel(peerId); + queue.enqueue('msg1'); + writeWithTimeout.mockRejectedValue(new Error('Send failed')); + + await orchestrator.flushQueuedMessages(peerId, channel, queue); + + // handleConnectionLoss should have been called + expect(removeChannel).toHaveBeenCalled(); + }); + + it('handles empty queue', async () => { + const orchestrator = createOrchestrator(); + const channel = createMockChannel(peerId); + + await orchestrator.flushQueuedMessages(peerId, channel, queue); + + expect(writeWithTimeout).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith( + `${peerId}:: flushing 0 queued messages`, + ); + }); + }); + + describe('edge cases', () => { + it('handles connection limit rejection during reconnection', async () => { + const orchestrator = createOrchestrator(); + const channel = createMockChannel(peerId); + reconnectionManager.startReconnection(peerId); + dialIdempotent.mockResolvedValue(channel); + + // First attempt blocked by limit, second succeeds + let attemptCount = 0; + checkConnectionLimit.mockImplementation(() => { + attemptCount += 1; + if (attemptCount === 1) { + throw new Error('Limit reached'); + } + }); + + await orchestrator.attemptReconnection(peerId); + + expect(closeChannel).toHaveBeenCalledWith(channel, peerId); + expect(outputError).toHaveBeenCalled(); + // Eventually should succeed + expect(registerChannel).toHaveBeenCalled(); + }); + + it('stops reconnection when peer is intentionally closed during dial', async () => { + const orchestrator = createOrchestrator(); + const channel = createMockChannel(peerId); + reconnectionManager.startReconnection(peerId); + dialIdempotent.mockResolvedValue(channel); + + // Mark as intentionally closed after dial + isIntentionallyClosed.mockReturnValueOnce(false).mockReturnValue(true); + + await orchestrator.attemptReconnection(peerId); + + expect(closeChannel).toHaveBeenCalledWith(channel, peerId); + expect(registerChannel).not.toHaveBeenCalled(); + expect(reconnectionManager.isReconnecting(peerId)).toBe(false); + }); + + it('supports infinite retries with maxAttempts 0', async () => { + // Ensure retryable errors are treated as retryable + vi.mocked(kernelErrors.isRetryableNetworkError).mockReturnValue(true); + + const orchestrator = createOrchestrator(0); + const channel = createMockChannel(peerId); + reconnectionManager.startReconnection(peerId); + + // Fail many times then succeed + let attempts = 0; + // eslint-disable-next-line @typescript-eslint/no-misused-promises + dialIdempotent.mockImplementation(async () => { + attempts += 1; + if (attempts < 10) { + throw new Error('Failed'); + } + return channel; + }); + + await orchestrator.attemptReconnection(peerId, 0); + + expect(attempts).toBe(10); + expect(registerChannel).toHaveBeenCalled(); + }); + }); +}); From 03e602a939905c019b39ea9857f61c63220ec085 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 14 Jan 2026 17:23:05 +0100 Subject: [PATCH 09/10] chore(remotes): rename initNetwork to initTransport Align the function name with the file name (transport.ts). Also clean up circular dependency pattern by reordering creation, reducing from 2 eslint-disable comments to 1. Co-Authored-By: Claude Opus 4.5 --- packages/ocap-kernel/src/index.test.ts | 2 +- packages/ocap-kernel/src/index.ts | 2 +- .../src/remotes/platform/transport.test.ts | 174 +++++++++--------- .../src/remotes/platform/transport.ts | 38 ++-- 4 files changed, 106 insertions(+), 110 deletions(-) diff --git a/packages/ocap-kernel/src/index.test.ts b/packages/ocap-kernel/src/index.test.ts index 0d056bbf2..c7d68c1f4 100644 --- a/packages/ocap-kernel/src/index.test.ts +++ b/packages/ocap-kernel/src/index.test.ts @@ -14,7 +14,7 @@ describe('index', () => { 'VatHandle', 'VatIdStruct', 'VatSupervisor', - 'initNetwork', + 'initTransport', 'isVatConfig', 'isVatId', 'krefOf', diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index 52b1dd705..43fa08a69 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -1,7 +1,7 @@ export { Kernel } from './Kernel.ts'; export { VatHandle } from './vats/VatHandle.ts'; export { VatSupervisor } from './vats/VatSupervisor.ts'; -export { initNetwork } from './remotes/platform/transport.ts'; +export { initTransport } from './remotes/platform/transport.ts'; export type { ClusterConfig, KRef, diff --git a/packages/ocap-kernel/src/remotes/platform/transport.test.ts b/packages/ocap-kernel/src/remotes/platform/transport.test.ts index 72172d2a6..c474dfa52 100644 --- a/packages/ocap-kernel/src/remotes/platform/transport.test.ts +++ b/packages/ocap-kernel/src/remotes/platform/transport.test.ts @@ -11,7 +11,7 @@ import { } from 'vitest'; // Import the module we're testing - must be after mocks are set up -let initNetwork: typeof import('./transport.ts').initNetwork; +let initTransport: typeof import('./transport.ts').initTransport; // Mock MessageQueue const mockMessageQueue = { @@ -169,11 +169,11 @@ vi.mock('uint8arrays', () => ({ fromString: vi.fn((str: string) => new TextEncoder().encode(str)), })); -describe('network.initNetwork', () => { +describe('network.initTransport', () => { // Import after all mocks are set up beforeAll(async () => { const networkModule = await import('./transport.ts'); - initNetwork = networkModule.initNetwork; + initTransport = networkModule.initTransport; }); beforeEach(() => { @@ -280,7 +280,7 @@ describe('network.initNetwork', () => { '/dns4/relay2.example/tcp/443/wss/p2p/relay2', ]; - await initNetwork(keySeed, { relays: knownRelays }, vi.fn()); + await initTransport(keySeed, { relays: knownRelays }, vi.fn()); expect(ConnectionFactory.make).toHaveBeenCalledWith( keySeed, @@ -296,7 +296,7 @@ describe('network.initNetwork', () => { const keySeed = '0xabcd'; const maxRetryAttempts = 5; - await initNetwork(keySeed, { relays: [], maxRetryAttempts }, vi.fn()); + await initTransport(keySeed, { relays: [], maxRetryAttempts }, vi.fn()); expect(ConnectionFactory.make).toHaveBeenCalledWith( keySeed, @@ -314,7 +314,7 @@ describe('network.initNetwork', () => { mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); mockReconnectionManager.isReconnecting.mockReturnValue(true); - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', { maxQueue }, vi.fn(), @@ -327,7 +327,7 @@ describe('network.initNetwork', () => { }); it('returns sendRemoteMessage, stop, closeConnection, registerLocationHints, and reconnectPeer', async () => { - const result = await initNetwork('0x1234', {}, vi.fn()); + const result = await initTransport('0x1234', {}, vi.fn()); expect(result).toHaveProperty('sendRemoteMessage'); expect(result).toHaveProperty('stop'); @@ -347,7 +347,7 @@ describe('network.initNetwork', () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', { relays: ['/dns4/relay.example/tcp/443/wss/p2p/relay1'], @@ -371,7 +371,7 @@ describe('network.initNetwork', () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); await sendRemoteMessage('peer-1', 'msg1'); await sendRemoteMessage('peer-1', 'msg2'); @@ -387,7 +387,7 @@ describe('network.initNetwork', () => { .mockResolvedValueOnce(mockChannel1) .mockResolvedValueOnce(mockChannel2); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); await sendRemoteMessage('peer-1', 'hello'); await sendRemoteMessage('peer-2', 'world'); @@ -400,7 +400,7 @@ describe('network.initNetwork', () => { mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); const hints = ['/dns4/hint.example/tcp/443/wss/p2p/hint']; - const { sendRemoteMessage, registerLocationHints } = await initNetwork( + const { sendRemoteMessage, registerLocationHints } = await initTransport( '0x1234', {}, vi.fn(), @@ -419,7 +419,7 @@ describe('network.initNetwork', () => { describe('inbound connections', () => { it('registers inbound connection handler', async () => { - await initNetwork('0x1234', {}, vi.fn()); + await initTransport('0x1234', {}, vi.fn()); expect(mockConnectionFactory.onInboundConnection).toHaveBeenCalledWith( expect.any(Function), @@ -436,7 +436,7 @@ describe('network.initNetwork', () => { }, ); - await initNetwork('0x1234', {}, remoteHandler); + await initTransport('0x1234', {}, remoteHandler); const mockChannel = createMockChannel('inbound-peer'); const messageBuffer = new TextEncoder().encode('test-message'); @@ -469,7 +469,7 @@ describe('network.initNetwork', () => { }, ); - await initNetwork('0x1234', {}, remoteHandler); + await initTransport('0x1234', {}, remoteHandler); const mockChannel = createMockChannel('inbound-peer'); const messageBuffer = new TextEncoder().encode('test-message'); @@ -500,7 +500,7 @@ describe('network.initNetwork', () => { mockMessageQueue.length = 1; mockReconnectionManager.isReconnecting.mockReturnValue(true); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); await sendRemoteMessage('peer-1', 'queued-msg'); @@ -515,7 +515,7 @@ describe('network.initNetwork', () => { ); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); await sendRemoteMessage('peer-1', 'msg1'); @@ -538,7 +538,7 @@ describe('network.initNetwork', () => { }, ); - await initNetwork('0x1234', {}, vi.fn()); + await initTransport('0x1234', {}, vi.fn()); const mockChannel = createMockChannel('peer-1'); mockChannel.msgStream.read.mockRejectedValue(new Error('Read failed')); @@ -560,7 +560,7 @@ describe('network.initNetwork', () => { }, ); - await initNetwork('0x1234', {}, vi.fn()); + await initTransport('0x1234', {}, vi.fn()); const mockChannel = createMockChannel('peer-1'); const gracefulDisconnectError = Object.assign(new Error('SCTP failure'), { @@ -592,7 +592,7 @@ describe('network.initNetwork', () => { }, ); - const { stop } = await initNetwork('0x1234', {}, vi.fn()); + const { stop } = await initTransport('0x1234', {}, vi.fn()); const mockChannel = createMockChannel('peer-1'); // Make read resolve after stop so loop continues and checks signal.aborted @@ -639,7 +639,7 @@ describe('network.initNetwork', () => { ); const remoteHandler = vi.fn().mockResolvedValue('ok'); - await initNetwork('0x1234', {}, remoteHandler); + await initTransport('0x1234', {}, remoteHandler); const mockChannel = createMockChannel('peer-1'); // First read returns undefined, which means stream ended - loop should break @@ -676,7 +676,7 @@ describe('network.initNetwork', () => { .mockResolvedValueOnce(mockChannel) // Initial connection .mockResolvedValueOnce(mockChannel); // Reconnection succeeds - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // First send establishes channel await sendRemoteMessage('peer-1', 'initial-msg'); @@ -755,7 +755,7 @@ describe('network.initNetwork', () => { return mockChannel; }, ); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Establish channel await sendRemoteMessage(peerId, 'initial-msg'); @@ -878,7 +878,7 @@ describe('network.initNetwork', () => { }, ); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Establish initial channel await sendRemoteMessage(peerId, 'initial-msg'); @@ -908,13 +908,13 @@ describe('network.initNetwork', () => { describe('stop functionality', () => { it('returns a stop function', async () => { - const { stop } = await initNetwork('0x1234', {}, vi.fn()); + const { stop } = await initTransport('0x1234', {}, vi.fn()); expect(typeof stop).toBe('function'); }); it('cleans up resources on stop', async () => { - const { stop } = await initNetwork('0x1234', {}, vi.fn()); + const { stop } = await initTransport('0x1234', {}, vi.fn()); await stop(); @@ -923,7 +923,7 @@ describe('network.initNetwork', () => { }); it('does not send messages after stop', async () => { - const { sendRemoteMessage, stop } = await initNetwork( + const { sendRemoteMessage, stop } = await initTransport( '0x1234', {}, vi.fn(), @@ -960,7 +960,7 @@ describe('network.initNetwork', () => { ); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage, stop } = await initNetwork( + const { sendRemoteMessage, stop } = await initTransport( '0x1234', {}, vi.fn(), @@ -985,7 +985,7 @@ describe('network.initNetwork', () => { }); it('can be called multiple times safely', async () => { - const { stop } = await initNetwork('0x1234', {}, vi.fn()); + const { stop } = await initTransport('0x1234', {}, vi.fn()); // Multiple calls should not throw await stop(); @@ -1000,7 +1000,7 @@ describe('network.initNetwork', () => { describe('closeConnection', () => { it('returns a closeConnection function', async () => { - const { closeConnection } = await initNetwork('0x1234', {}, vi.fn()); + const { closeConnection } = await initTransport('0x1234', {}, vi.fn()); expect(typeof closeConnection).toBe('function'); }); @@ -1009,7 +1009,7 @@ describe('network.initNetwork', () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage, closeConnection } = await initNetwork( + const { sendRemoteMessage, closeConnection } = await initTransport( '0x1234', {}, vi.fn(), @@ -1031,7 +1031,7 @@ describe('network.initNetwork', () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage, closeConnection } = await initNetwork( + const { sendRemoteMessage, closeConnection } = await initTransport( '0x1234', {}, vi.fn(), @@ -1054,7 +1054,7 @@ describe('network.initNetwork', () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage, closeConnection } = await initNetwork( + const { sendRemoteMessage, closeConnection } = await initTransport( '0x1234', {}, vi.fn(), @@ -1076,7 +1076,7 @@ describe('network.initNetwork', () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage, closeConnection } = await initNetwork( + const { sendRemoteMessage, closeConnection } = await initTransport( '0x1234', {}, vi.fn(), @@ -1105,7 +1105,7 @@ describe('network.initNetwork', () => { }, ); - const { closeConnection } = await initNetwork('0x1234', {}, vi.fn()); + const { closeConnection } = await initTransport('0x1234', {}, vi.fn()); // Close connection first await closeConnection('peer-1'); @@ -1127,7 +1127,7 @@ describe('network.initNetwork', () => { describe('registerLocationHints', () => { it('returns a registerLocationHints function', async () => { - const { registerLocationHints } = await initNetwork( + const { registerLocationHints } = await initTransport( '0x1234', {}, vi.fn(), @@ -1139,7 +1139,7 @@ describe('network.initNetwork', () => { describe('reconnectPeer', () => { it('returns a reconnectPeer function', async () => { - const { reconnectPeer } = await initNetwork('0x1234', {}, vi.fn()); + const { reconnectPeer } = await initTransport('0x1234', {}, vi.fn()); expect(typeof reconnectPeer).toBe('function'); }); @@ -1149,7 +1149,7 @@ describe('network.initNetwork', () => { mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); const { sendRemoteMessage, closeConnection, reconnectPeer } = - await initNetwork('0x1234', {}, vi.fn()); + await initTransport('0x1234', {}, vi.fn()); // Establish and close connection await sendRemoteMessage('peer-1', 'msg1'); @@ -1191,7 +1191,7 @@ describe('network.initNetwork', () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { closeConnection, reconnectPeer } = await initNetwork( + const { closeConnection, reconnectPeer } = await initTransport( '0x1234', {}, vi.fn(), @@ -1239,7 +1239,7 @@ describe('network.initNetwork', () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { closeConnection, reconnectPeer } = await initNetwork( + const { closeConnection, reconnectPeer } = await initTransport( '0x1234', {}, vi.fn(), @@ -1262,7 +1262,7 @@ describe('network.initNetwork', () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { closeConnection, reconnectPeer } = await initNetwork( + const { closeConnection, reconnectPeer } = await initTransport( '0x1234', {}, vi.fn(), @@ -1284,7 +1284,7 @@ describe('network.initNetwork', () => { mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); const { sendRemoteMessage, closeConnection, reconnectPeer } = - await initNetwork('0x1234', {}, vi.fn()); + await initTransport('0x1234', {}, vi.fn()); // Establish, close, and reconnect await sendRemoteMessage('peer-1', 'msg1'); @@ -1317,7 +1317,7 @@ describe('network.initNetwork', () => { }, ); - await initNetwork('0x1234', {}, vi.fn()); + await initTransport('0x1234', {}, vi.fn()); expect(installWakeDetector).toHaveBeenCalled(); @@ -1334,7 +1334,7 @@ describe('network.initNetwork', () => { cleanupFn, ); - const { stop } = await initNetwork('0x1234', {}, vi.fn()); + const { stop } = await initTransport('0x1234', {}, vi.fn()); await stop(); @@ -1356,7 +1356,7 @@ describe('network.initNetwork', () => { return mockChannel; }); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); await sendRemoteMessage('peer-1', 'msg'); @@ -1409,7 +1409,7 @@ describe('network.initNetwork', () => { return reconChannel; }); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Trigger first connection loss (this starts reconnection) await sendRemoteMessage('peer-1', 'msg-1'); @@ -1471,7 +1471,7 @@ describe('network.initNetwork', () => { }), ); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Set up initial connection that will fail on write const initialChannel = createMockChannel('peer-1'); @@ -1545,7 +1545,7 @@ describe('network.initNetwork', () => { new Error('Dial failed'), ); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); await sendRemoteMessage('peer-1', 'msg'); @@ -1573,7 +1573,7 @@ describe('network.initNetwork', () => { .mockResolvedValueOnce(mockChannel) // initial connection .mockRejectedValueOnce(new Error('Permanent failure')); // non-retryable during reconnection - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Establish channel await sendRemoteMessage('peer-1', 'msg1'); @@ -1632,7 +1632,7 @@ describe('network.initNetwork', () => { .mockResolvedValueOnce(mockChannel) // initial connection .mockResolvedValue(mockChannel); // reconnection attempts (dial succeeds, flush fails) - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Establish channel await sendRemoteMessage('peer-1', 'msg1'); @@ -1684,7 +1684,7 @@ describe('network.initNetwork', () => { .mockResolvedValueOnce(mockChannel) .mockResolvedValue(mockChannel); - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', {}, vi.fn(), @@ -1759,7 +1759,7 @@ describe('network.initNetwork', () => { mockMessageQueue.messages = [...messages]; mockMessageQueue.length = messages.length; }); - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', { maxRetryAttempts }, vi.fn(), @@ -1821,7 +1821,7 @@ describe('network.initNetwork', () => { .mockResolvedValueOnce(mockChannel) .mockRejectedValueOnce(new Error('Non-retryable error')); - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', {}, vi.fn(), @@ -1841,7 +1841,7 @@ describe('network.initNetwork', () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); await sendRemoteMessage('peer-1', 'msg'); @@ -1858,7 +1858,7 @@ describe('network.initNetwork', () => { }, ); - await initNetwork('0x1234', {}, vi.fn()); + await initTransport('0x1234', {}, vi.fn()); const mockChannel = createMockChannel('inbound-peer'); const messageBuffer = new TextEncoder().encode('inbound-msg'); @@ -1910,7 +1910,7 @@ describe('network.initNetwork', () => { .mockResolvedValueOnce(mockChannel) // initial connection .mockResolvedValueOnce(mockChannel); // reconnection - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Establish channel await sendRemoteMessage('peer-1', 'msg1'); @@ -1980,7 +1980,7 @@ describe('network.initNetwork', () => { .mockResolvedValueOnce(mockChannel1) // initial connection .mockResolvedValueOnce(mockChannel2); // reconnection after flush failure - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Establish channel await sendRemoteMessage('peer-1', 'msg1'); @@ -2025,7 +2025,7 @@ describe('network.initNetwork', () => { return mockSignal; }); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); const sendPromise = sendRemoteMessage('peer-1', 'test message'); @@ -2060,7 +2060,7 @@ describe('network.initNetwork', () => { return mockSignal; }); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); const sendPromise = sendRemoteMessage('peer-1', 'test message'); @@ -2091,7 +2091,7 @@ describe('network.initNetwork', () => { return mockSignal; }); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); const sendPromise = sendRemoteMessage('peer-1', 'test message'); @@ -2121,7 +2121,7 @@ describe('network.initNetwork', () => { mockChannel.msgStream.write.mockRejectedValue(writeError); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); const sendPromise = sendRemoteMessage('peer-1', 'test message'); @@ -2146,7 +2146,7 @@ describe('network.initNetwork', () => { return mockSignal; }); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); await sendRemoteMessage('peer-1', 'test message'); @@ -2175,7 +2175,7 @@ describe('network.initNetwork', () => { return mockSignal; }); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); const sendPromise = sendRemoteMessage('peer-1', 'test message'); @@ -2217,7 +2217,7 @@ describe('network.initNetwork', () => { return signal; }); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); const sendPromise1 = sendRemoteMessage('peer-1', 'message 1'); const sendPromise2 = sendRemoteMessage('peer-1', 'message 2'); @@ -2252,7 +2252,7 @@ describe('network.initNetwork', () => { mockChannels.push(mockChannel); mockConnectionFactory.dialIdempotent.mockResolvedValueOnce(mockChannel); } - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Establish 100 connections for (let i = 0; i < 100; i += 1) { await sendRemoteMessage(`peer-${i}`, 'msg'); @@ -2273,7 +2273,7 @@ describe('network.initNetwork', () => { mockChannels.push(mockChannel); mockConnectionFactory.dialIdempotent.mockResolvedValueOnce(mockChannel); } - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', { maxConcurrentConnections: customLimit }, vi.fn(), @@ -2305,7 +2305,7 @@ describe('network.initNetwork', () => { mockChannels.push(mockChannel); mockConnectionFactory.dialIdempotent.mockResolvedValueOnce(mockChannel); } - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Establish 100 outbound connections for (let i = 0; i < 100; i += 1) { await sendRemoteMessage(`peer-${i}`, 'msg'); @@ -2322,7 +2322,7 @@ describe('network.initNetwork', () => { describe('message size limit', () => { it('rejects messages exceeding 1MB size limit', async () => { - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Create a message larger than 1MB const largeMessage = 'x'.repeat(1024 * 1024 + 1); // 1MB + 1 byte await expect(sendRemoteMessage('peer-1', largeMessage)).rejects.toThrow( @@ -2335,7 +2335,7 @@ describe('network.initNetwork', () => { it('allows messages at exactly 1MB size limit', async () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Create a message exactly 1MB const exactSizeMessage = 'x'.repeat(1024 * 1024); await sendRemoteMessage('peer-1', exactSizeMessage); @@ -2345,7 +2345,7 @@ describe('network.initNetwork', () => { it('validates message size before queueing during reconnection', async () => { mockReconnectionManager.isReconnecting.mockReturnValue(true); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Create a message larger than 1MB const largeMessage = 'x'.repeat(1024 * 1024 + 1); await expect(sendRemoteMessage('peer-1', largeMessage)).rejects.toThrow( @@ -2357,7 +2357,7 @@ describe('network.initNetwork', () => { it('respects custom maxMessageSizeBytes option', async () => { const customLimit = 500 * 1024; // 500KB - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', { maxMessageSizeBytes: customLimit }, vi.fn(), @@ -2385,7 +2385,7 @@ describe('network.initNetwork', () => { intervalFn = fn; return 1 as unknown as NodeJS.Timeout; }); - await initNetwork('0x1234', {}, vi.fn()); + await initTransport('0x1234', {}, vi.fn()); expect(setIntervalSpy).toHaveBeenCalledWith( expect.any(Function), 15 * 60 * 1000, @@ -2401,7 +2401,7 @@ describe('network.initNetwork', () => { .mockImplementation((_fn: () => void, _ms?: number) => { return 42 as unknown as NodeJS.Timeout; }); - const { stop } = await initNetwork('0x1234', {}, vi.fn()); + const { stop } = await initTransport('0x1234', {}, vi.fn()); await stop(); expect(clearIntervalSpy).toHaveBeenCalledWith(42); setIntervalSpy.mockRestore(); @@ -2418,7 +2418,7 @@ describe('network.initNetwork', () => { }); const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); // Establish connection (sets lastConnectionTime) await sendRemoteMessage('peer-1', 'msg'); // Run cleanup immediately; should not remove active peer @@ -2439,7 +2439,7 @@ describe('network.initNetwork', () => { const mockChannel = createMockChannel('peer-1'); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); mockReconnectionManager.isReconnecting.mockReturnValue(true); - const { sendRemoteMessage } = await initNetwork('0x1234', {}, vi.fn()); + const { sendRemoteMessage } = await initTransport('0x1234', {}, vi.fn()); await sendRemoteMessage('peer-1', 'msg'); // Run cleanup immediately; reconnecting peer should not be cleaned intervalFn?.(); @@ -2503,7 +2503,7 @@ describe('network.initNetwork', () => { .mockResolvedValueOnce(reconnectChannel); // reconnection const stalePeerTimeoutMs = 1; // Very short timeout - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', { stalePeerTimeoutMs }, vi.fn(), @@ -2564,7 +2564,7 @@ describe('network.initNetwork', () => { mockChannel.msgStream.read.mockResolvedValueOnce(undefined); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); const stalePeerTimeoutMs = 1; - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', { stalePeerTimeoutMs }, vi.fn(), @@ -2595,7 +2595,7 @@ describe('network.initNetwork', () => { .mockImplementation((_fn: () => void, _ms?: number) => { return 1 as unknown as NodeJS.Timeout; }); - await initNetwork( + await initTransport( '0x1234', { cleanupIntervalMs: customInterval }, vi.fn(), @@ -2620,7 +2620,7 @@ describe('network.initNetwork', () => { // End the inbound stream so the channel is removed from the active channels map. mockChannel.msgStream.read.mockResolvedValueOnce(undefined); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', { stalePeerTimeoutMs: customTimeout, @@ -2658,7 +2658,7 @@ describe('network.initNetwork', () => { mockChannel.msgStream.read.mockResolvedValueOnce(undefined); mockConnectionFactory.dialIdempotent.mockResolvedValue(mockChannel); const stalePeerTimeoutMs = 1; - const { sendRemoteMessage, closeConnection } = await initNetwork( + const { sendRemoteMessage, closeConnection } = await initTransport( '0x1234', { stalePeerTimeoutMs }, vi.fn(), @@ -2735,7 +2735,7 @@ describe('network.initNetwork', () => { mockConnectionFactory.dialIdempotent .mockResolvedValueOnce(mockChannels[0]) // peer-0 .mockResolvedValueOnce(mockChannels[1]); // peer-1 - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', { maxConcurrentConnections: customLimit }, vi.fn(), @@ -2823,7 +2823,7 @@ describe('network.initNetwork', () => { }, ); - const { sendRemoteMessage } = await initNetwork( + const { sendRemoteMessage } = await initTransport( '0x1234', { maxConcurrentConnections: customLimit }, vi.fn(), @@ -2860,7 +2860,7 @@ describe('network.initNetwork', () => { }); it('registerLocationHints merges with existing hints', async () => { - const { registerLocationHints, sendRemoteMessage } = await initNetwork( + const { registerLocationHints, sendRemoteMessage } = await initTransport( '0x1234', {}, vi.fn(), @@ -2885,7 +2885,7 @@ describe('network.initNetwork', () => { }); it('registerLocationHints creates new set when no existing hints', async () => { - const { registerLocationHints, sendRemoteMessage } = await initNetwork( + const { registerLocationHints, sendRemoteMessage } = await initTransport( '0x1234', {}, vi.fn(), @@ -2911,7 +2911,7 @@ describe('network.initNetwork', () => { inboundHandler = handler; }); - await initNetwork('0x1234', {}, vi.fn()); + await initTransport('0x1234', {}, vi.fn()); const channel1 = createMockChannel('peer-1'); const channel2 = createMockChannel('peer-1'); @@ -2942,7 +2942,7 @@ describe('network.initNetwork', () => { new Error('Close failed'), ); - await initNetwork('0x1234', {}, vi.fn()); + await initTransport('0x1234', {}, vi.fn()); const channel1 = createMockChannel('peer-1'); const channel2 = createMockChannel('peer-1'); @@ -2968,7 +2968,7 @@ describe('network.initNetwork', () => { inboundHandler = handler; }); - const { closeConnection } = await initNetwork('0x1234', {}, vi.fn()); + const { closeConnection } = await initTransport('0x1234', {}, vi.fn()); await closeConnection('peer-1'); @@ -2996,7 +2996,7 @@ describe('network.initNetwork', () => { new Error('Close failed'), ); - const { closeConnection } = await initNetwork('0x1234', {}, vi.fn()); + const { closeConnection } = await initTransport('0x1234', {}, vi.fn()); await closeConnection('peer-1'); diff --git a/packages/ocap-kernel/src/remotes/platform/transport.ts b/packages/ocap-kernel/src/remotes/platform/transport.ts index aaa1a8d6c..8051b503a 100644 --- a/packages/ocap-kernel/src/remotes/platform/transport.ts +++ b/packages/ocap-kernel/src/remotes/platform/transport.ts @@ -50,7 +50,7 @@ const DEFAULT_STALE_PEER_TIMEOUT_MS = 60 * 60 * 1000; * * @returns a function to send messages **and** a `stop()` to cancel/release everything. */ -export async function initNetwork( +export async function initTransport( keySeed: string, options: RemoteCommsOptions, remoteMessageHandler: RemoteMessageHandler, @@ -184,14 +184,10 @@ export async function initNetwork( } } - // Late-bound references for circular dependencies - // eslint-disable-next-line prefer-const - let channelReader: ReturnType; - // eslint-disable-next-line prefer-const - let reconnectionOrchestrator: ReturnType; - /** * Register a channel and start reading from it. + * Note: This function references channelReader via closure. It's defined before + * channelReader is created, but only called at runtime after initialization. * * @param peerId - The peer ID for the channel. * @param channel - The channel to register. @@ -203,6 +199,7 @@ export async function initNetwork( errorContext = 'reading channel to', ): void { const previousChannel = peerRegistry.setChannel(peerId, channel); + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- channelReader is assigned before this function is called channelReader.readChannel(channel).catch((problem) => { outputError(peerId, errorContext, problem); }); @@ -220,20 +217,8 @@ export async function initNetwork( } } - // Create channel reader - channelReader = makeChannelReader({ - peerRegistry, - remoteMessageHandler, - signal, - logger, - onConnectionLoss: (peerId, channel) => - reconnectionOrchestrator.handleConnectionLoss(peerId, channel), - onMessageReceived: (peerId) => reconnectionManager.resetBackoff(peerId), - outputError, - }); - - // Create reconnection orchestrator - reconnectionOrchestrator = makeReconnectionOrchestrator({ + // Create reconnection orchestrator first - it uses registerChannel which is hoisted + const reconnectionOrchestrator = makeReconnectionOrchestrator({ peerRegistry, connectionFactory, reconnectionManager, @@ -247,6 +232,17 @@ export async function initNetwork( outputError, }); + // Create channel reader - can now reference reconnectionOrchestrator directly + const channelReader = makeChannelReader({ + peerRegistry, + remoteMessageHandler, + signal, + logger, + onConnectionLoss: reconnectionOrchestrator.handleConnectionLoss, + onMessageReceived: (peerId) => reconnectionManager.resetBackoff(peerId), + outputError, + }); + /** * Clean up stale peer data for peers inactive for more than stalePeerTimeoutMs. */ From cc6edbbcddfa40fbea81540c25db645703d0c73e Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 14 Jan 2026 17:33:53 +0100 Subject: [PATCH 10/10] chore(remotes): rename initNetwork to initTransport --- .../src/PlatformServicesServer.test.ts | 24 +++++------ .../src/PlatformServicesServer.ts | 4 +- .../src/kernel/PlatformServices.test.ts | 42 +++++++++---------- .../nodejs/src/kernel/PlatformServices.ts | 4 +- vitest.config.ts | 8 ++-- 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/kernel-browser-runtime/src/PlatformServicesServer.test.ts b/packages/kernel-browser-runtime/src/PlatformServicesServer.test.ts index daad608b1..dd7b12914 100644 --- a/packages/kernel-browser-runtime/src/PlatformServicesServer.test.ts +++ b/packages/kernel-browser-runtime/src/PlatformServicesServer.test.ts @@ -18,7 +18,7 @@ import type { PlatformServicesStream, } from './PlatformServicesServer.ts'; -// Mock initNetwork from ocap-kernel +// Mock initTransport from ocap-kernel const mockSendRemoteMessage = vi.fn(async () => undefined); const mockStop = vi.fn(async () => undefined); const mockCloseConnection = vi.fn(async () => undefined); @@ -35,7 +35,7 @@ vi.mock('@metamask/ocap-kernel', () => ({ terminate: 'terminate', terminateAll: 'terminateAll', }, - initNetwork: vi.fn( + initTransport: vi.fn( async ( _keySeed: string, _options: unknown, @@ -400,8 +400,8 @@ describe('PlatformServicesServer', () => { ); await delay(10); - const { initNetwork } = await import('@metamask/ocap-kernel'); - expect(initNetwork).toHaveBeenCalledWith( + const { initTransport } = await import('@metamask/ocap-kernel'); + expect(initTransport).toHaveBeenCalledWith( keySeed, { relays }, expect.any(Function), @@ -422,8 +422,8 @@ describe('PlatformServicesServer', () => { ); await delay(10); - const { initNetwork } = await import('@metamask/ocap-kernel'); - expect(initNetwork).toHaveBeenCalledWith( + const { initTransport } = await import('@metamask/ocap-kernel'); + expect(initTransport).toHaveBeenCalledWith( keySeed, options, expect.any(Function), @@ -458,7 +458,7 @@ describe('PlatformServicesServer', () => { }); describe('handleRemoteMessage', () => { - it('captures handler from initNetwork', async () => { + it('captures handler from initTransport', async () => { const keySeed = '0xabcd'; const relays = ['/dns4/relay.example/tcp/443/wss/p2p/relayPeer']; @@ -529,7 +529,7 @@ describe('PlatformServicesServer', () => { }); describe('handleRemoteGiveUp', () => { - it('captures handler from initNetwork', async () => { + it('captures handler from initTransport', async () => { const keySeed = '0xabcd'; const relays = ['/dns4/relay.example/tcp/443/wss/p2p/relayPeer']; @@ -658,8 +658,8 @@ describe('PlatformServicesServer', () => { await stream.receiveInput(makeStopRemoteCommsMessageEvent('m1')); await delay(10); - const { initNetwork } = await import('@metamask/ocap-kernel'); - const firstCallCount = (initNetwork as Mock).mock.calls.length; + const { initTransport } = await import('@metamask/ocap-kernel'); + const firstCallCount = (initTransport as Mock).mock.calls.length; // Re-initialize should work await stream.receiveInput( @@ -667,8 +667,8 @@ describe('PlatformServicesServer', () => { ); await delay(10); - // Should have called initNetwork again - expect((initNetwork as Mock).mock.calls).toHaveLength( + // Should have called initTransport again + expect((initTransport as Mock).mock.calls).toHaveLength( firstCallCount + 1, ); }); diff --git a/packages/kernel-browser-runtime/src/PlatformServicesServer.ts b/packages/kernel-browser-runtime/src/PlatformServicesServer.ts index b4f0cd857..61a57e584 100644 --- a/packages/kernel-browser-runtime/src/PlatformServicesServer.ts +++ b/packages/kernel-browser-runtime/src/PlatformServicesServer.ts @@ -13,7 +13,7 @@ import type { StopRemoteComms, RemoteCommsOptions, } from '@metamask/ocap-kernel'; -import { initNetwork } from '@metamask/ocap-kernel'; +import { initTransport } from '@metamask/ocap-kernel'; import { kernelRemoteMethodSpecs, platformServicesHandlers, @@ -288,7 +288,7 @@ export class PlatformServicesServer { closeConnection, registerLocationHints, reconnectPeer, - } = await initNetwork( + } = await initTransport( keySeed, options, this.#handleRemoteMessage.bind(this), diff --git a/packages/nodejs/src/kernel/PlatformServices.test.ts b/packages/nodejs/src/kernel/PlatformServices.test.ts index 609613990..b922647b0 100644 --- a/packages/nodejs/src/kernel/PlatformServices.test.ts +++ b/packages/nodejs/src/kernel/PlatformServices.test.ts @@ -79,7 +79,7 @@ vi.mock('@metamask/ocap-kernel', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - initNetwork: vi.fn(async () => ({ + initTransport: vi.fn(async () => ({ sendRemoteMessage: mockSendRemoteMessage, stop: mockStop, closeConnection: mockCloseConnection, @@ -245,8 +245,8 @@ describe('NodejsPlatformServices', () => { await service.initializeRemoteComms(keySeed, { relays }, remoteHandler); - const { initNetwork } = await import('@metamask/ocap-kernel'); - expect(initNetwork).toHaveBeenCalledWith( + const { initTransport } = await import('@metamask/ocap-kernel'); + expect(initTransport).toHaveBeenCalledWith( keySeed, { relays }, expect.any(Function), @@ -266,8 +266,8 @@ describe('NodejsPlatformServices', () => { await service.initializeRemoteComms(keySeed, options, remoteHandler); - const { initNetwork } = await import('@metamask/ocap-kernel'); - expect(initNetwork).toHaveBeenCalledWith( + const { initTransport } = await import('@metamask/ocap-kernel'); + expect(initTransport).toHaveBeenCalledWith( keySeed, options, expect.any(Function), @@ -289,8 +289,8 @@ describe('NodejsPlatformServices', () => { giveUpHandler, ); - const { initNetwork } = await import('@metamask/ocap-kernel'); - expect(initNetwork).toHaveBeenCalledWith( + const { initTransport } = await import('@metamask/ocap-kernel'); + expect(initTransport).toHaveBeenCalledWith( keySeed, { relays }, expect.any(Function), @@ -332,17 +332,17 @@ describe('NodejsPlatformServices', () => { await service.initializeRemoteComms('0xtest', {}, remoteHandler); - // Simulate handleRemoteMessage being called (via initNetwork callback) + // Simulate handleRemoteMessage being called (via initTransport callback) // The handler should call sendRemoteMessage if reply is non-empty mockSendRemoteMessage.mockClear(); - // Call the handler that was passed to initNetwork - const { initNetwork } = await import('@metamask/ocap-kernel'); - const initNetworkMock = initNetwork as unknown as ReturnType< + // Call the handler that was passed to initTransport + const { initTransport } = await import('@metamask/ocap-kernel'); + const initTransportMock = initTransport as unknown as ReturnType< typeof vi.fn >; const lastCall = - initNetworkMock.mock.calls[initNetworkMock.mock.calls.length - 1]; + initTransportMock.mock.calls[initTransportMock.mock.calls.length - 1]; const handleRemoteMessage = lastCall?.[2] as ( from: string, message: string, @@ -365,13 +365,13 @@ describe('NodejsPlatformServices', () => { mockSendRemoteMessage.mockClear(); - // Call the handler that was passed to initNetwork - const { initNetwork } = await import('@metamask/ocap-kernel'); - const initNetworkMock = initNetwork as unknown as ReturnType< + // Call the handler that was passed to initTransport + const { initTransport } = await import('@metamask/ocap-kernel'); + const initTransportMock = initTransport as unknown as ReturnType< typeof vi.fn >; const lastCall = - initNetworkMock.mock.calls[initNetworkMock.mock.calls.length - 1]; + initTransportMock.mock.calls[initTransportMock.mock.calls.length - 1]; const handleRemoteMessage = lastCall?.[2] as ( from: string, message: string, @@ -440,11 +440,11 @@ describe('NodejsPlatformServices', () => { // Initialize await service.initializeRemoteComms(keySeed, { relays }, remoteHandler); - const { initNetwork } = await import('@metamask/ocap-kernel'); - const initNetworkMock = initNetwork as unknown as ReturnType< + const { initTransport } = await import('@metamask/ocap-kernel'); + const initTransportMock = initTransport as unknown as ReturnType< typeof vi.fn >; - const firstCallCount = initNetworkMock.mock.calls.length; + const firstCallCount = initTransportMock.mock.calls.length; // Stop await service.stopRemoteComms(); @@ -453,8 +453,8 @@ describe('NodejsPlatformServices', () => { // Re-initialize should work await service.initializeRemoteComms(keySeed, { relays }, remoteHandler); - // Should have called initNetwork again - expect(initNetworkMock.mock.calls).toHaveLength(firstCallCount + 1); + // Should have called initTransport again + expect(initTransportMock.mock.calls).toHaveLength(firstCallCount + 1); }); it('clears internal state after stop', async () => { diff --git a/packages/nodejs/src/kernel/PlatformServices.ts b/packages/nodejs/src/kernel/PlatformServices.ts index 4008fcff7..6863314a5 100644 --- a/packages/nodejs/src/kernel/PlatformServices.ts +++ b/packages/nodejs/src/kernel/PlatformServices.ts @@ -10,7 +10,7 @@ import type { StopRemoteComms, RemoteCommsOptions, } from '@metamask/ocap-kernel'; -import { initNetwork } from '@metamask/ocap-kernel'; +import { initTransport } from '@metamask/ocap-kernel'; import { NodeWorkerDuplexStream } from '@metamask/streams'; import type { DuplexStream } from '@metamask/streams'; import { strict as assert } from 'node:assert'; @@ -249,7 +249,7 @@ export class NodejsPlatformServices implements PlatformServices { closeConnection, registerLocationHints, reconnectPeer, - } = await initNetwork( + } = await initTransport( keySeed, options, this.#handleRemoteMessage.bind(this), diff --git a/vitest.config.ts b/vitest.config.ts index 740a0991b..ac3230a03 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -159,10 +159,10 @@ export default defineConfig({ lines: 25, }, 'packages/ocap-kernel/**': { - statements: 95.12, - functions: 97.69, - branches: 86.95, - lines: 95.1, + statements: 95.93, + functions: 97.77, + branches: 88.4, + lines: 95.91, }, 'packages/omnium-gatherum/**': { statements: 5.26,