diff --git a/network/NetworkDevice.ts b/network/NetworkDevice.ts index 630230a..5d04b7f 100644 --- a/network/NetworkDevice.ts +++ b/network/NetworkDevice.ts @@ -39,6 +39,9 @@ export class NetworkDevice { private connectionInfo: ConnectionInfo; private logger: Logger; + /** Called when the device closes the main connection (not when we disconnect). */ + private lostHandler: (() => void) | null = null; + /** Trailing bytes of a partially-received message, held for the next read. */ private queue: Buffer | null = null; @@ -76,12 +79,32 @@ export class NetworkDevice { this.logger.debug(`Attempting to connect to ${info.address}:${info.port}`); this.connection = await tcp.connect(info.address, info.port, this.logger); this.queue = null; + this.watchForClose(); this.connection.socket.on('data', (p_message: Buffer) => { this.messageHandler(p_message); }); await this.requestAllServicePorts(); } + /** + * Call `handler` when the device closes the main connection (switched off, rebooted, cable + * pulled). A disconnect() of ours does not call it. + */ + onConnectionLost(handler: () => void): void { + this.lostHandler = handler; + } + + private watchForClose(): void { + const connection = this.connection; + connection?.socket.once('close', () => { + // disconnect() clears this.connection before the socket's close event fires. + if (this.connection !== connection) return; + this.connection = null; + this.queue = null; + this.lostHandler?.(); + }); + } + disconnect(): void { // Disconnect all services for (const [key, service] of Object.entries(this.services)) { diff --git a/network/StageLinqDevices.ts b/network/StageLinqDevices.ts index 1e9aae6..8c8f696 100644 --- a/network/StageLinqDevices.ts +++ b/network/StageLinqDevices.ts @@ -38,6 +38,7 @@ export declare interface StageLinqDevices { on(event: 'message', listener: (connectionInfo: ConnectionInfo, message: ServiceMessage) => void): this; on(event: 'beatMessage', listener: (connectionInfo: ConnectionInfo, data: BeatData) => void): this; on(event: 'ready', listener: () => void): this; + on(event: 'disconnect', listener: (connectionInfo: ConnectionInfo) => void): this; } ////////////////////////////////////////////////////////////////////////////// @@ -237,6 +238,7 @@ export class StageLinqDevices extends EventEmitter { `Attempt ${attempt}/${maxRetries}`); const networkDevice = new NetworkDevice(connectionInfo, this.logger); await networkDevice.connect(); + networkDevice.onConnectionLost(() => this.deviceLost(connectionInfo)); // Setup file transfer service await this.setupFileTransferService(networkDevice, connectionInfo); @@ -325,6 +327,20 @@ export class StageLinqDevices extends EventEmitter { this.logger.debug(`Database download complete for ${connectionInfo.source}`); } + /** + * The device closed its main connection: forget it, so its next announcement connects it again, + * and tell listeners ('disconnect'). + */ + private deviceLost(connectionInfo: ConnectionInfo) { + const id = this.deviceId(connectionInfo); + this.logger.info(`Lost connection to ${id}`); + this.discoveryStatus.delete(id); + this.devices.delete(`net://${this.sourceId(connectionInfo)}`); + this.stateMapCallback = this.stateMapCallback.filter( + (cb) => cb.connectionInfo !== connectionInfo); + this.emit('disconnect', connectionInfo); + } + private sourceId(connectionInfo: ConnectionInfo) { return formatToken(connectionInfo.token); } diff --git a/test/network/NetworkDevice.test.ts b/test/network/NetworkDevice.test.ts index 5710e2a..8ef36d7 100644 --- a/test/network/NetworkDevice.test.ts +++ b/test/network/NetworkDevice.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { EventEmitter } from 'events'; +import { describe, it, expect, vi } from 'vitest'; import { NetworkDevice } from '../../network/NetworkDevice'; import { MessageId } from '../../types'; import type { ConnectionInfo, ServicePorts } from '../../types'; @@ -171,3 +172,38 @@ describe('NetworkDevice message framing', () => { expect(d.ports()).toEqual({ TimeSynchronization: 36859 }); }); }); + +describe('NetworkDevice connection loss', () => { + const info = { address: '192.168.1.10', port: 51337 } as ConnectionInfo; + + function withFakeConnection() { + const device = new NetworkDevice(info); + const socket = new EventEmitter(); + // A real socket emits 'close' after destroy(), asynchronously. + const connection = { socket, destroy: () => queueMicrotask(() => socket.emit('close')) }; + (device as any).connection = connection; + (device as any).watchForClose(); + return { device, socket }; + } + + it('calls the handler when the device closes the connection', () => { + const { device, socket } = withFakeConnection(); + const lost = vi.fn(); + device.onConnectionLost(lost); + + socket.emit('close'); + + expect(lost).toHaveBeenCalledTimes(1); + }); + + it('does not call it when we disconnect', async () => { + const { device } = withFakeConnection(); + const lost = vi.fn(); + device.onConnectionLost(lost); + + device.disconnect(); + await Promise.resolve(); + + expect(lost).not.toHaveBeenCalled(); + }); +}); diff --git a/test/network/StageLinqDevices.test.ts b/test/network/StageLinqDevices.test.ts index 66b59df..bab9ea3 100644 --- a/test/network/StageLinqDevices.test.ts +++ b/test/network/StageLinqDevices.test.ts @@ -12,6 +12,8 @@ vi.mock("../../services", () => ({ })); const mockDownloadSourcesFromDevice = vi.fn().mockResolvedValue([]); +/** onConnectionLost handlers registered by connectToDevice, in order. */ +const mockLostHandlers: Array<() => void> = []; vi.mock("../../Databases", () => ({ Databases: class { downloadSourcesFromDevice = (...args: unknown[]) => @@ -31,6 +33,9 @@ vi.mock("../../network", () => ({ NetworkDevice: class { connect = vi.fn().mockResolvedValue(undefined); disconnect = vi.fn(); + onConnectionLost = vi.fn((handler: () => void) => { + mockLostHandlers.push(handler); + }); connectToService = vi.fn().mockResolvedValue({}); }, })); @@ -222,3 +227,46 @@ describe("StageLinqDevices StateMap setup failure (NP3-447)", () => { expect(logger.warn).toHaveBeenCalled(); }); }); + +describe("StageLinqDevices when a device goes away", () => { + // A player that is switched off (or rebooted, or unplugged) closes its main + // connection. Listeners need a device-level event for it, and the device must + // be forgotten so its next announcement connects it again. + + const connectionInfo = { + address: "192.168.1.10", + port: 51337, + source: "JP13", + token: new Uint8Array(16), + software: { name: "SC6000" }, + } as any; + + beforeEach(() => { + vi.useFakeTimers(); + mockLostHandlers.length = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("emits 'disconnect' and forgets the device when its connection is lost", async () => { + const devices = new StageLinqDevices({ + actingAs: { source: "test-app" }, + enableFileTranfer: false, + maxRetries: 1, + } as any); + const lost = vi.fn(); + devices.on("disconnect", lost); + + await (devices as any).connectToDevice(connectionInfo); + expect((devices as any).isConnected(connectionInfo)).toBe(true); + expect(mockLostHandlers).toHaveLength(1); + + mockLostHandlers[0](); + + expect(lost).toHaveBeenCalledWith(connectionInfo); + expect((devices as any).isConnected(connectionInfo)).toBe(false); + expect((devices as any).isConnecting(connectionInfo)).toBe(false); + }); +}); diff --git a/test/network/connectRetry.test.ts b/test/network/connectRetry.test.ts index 69bc080..2032371 100644 --- a/test/network/connectRetry.test.ts +++ b/test/network/connectRetry.test.ts @@ -36,6 +36,7 @@ vi.mock("../../network", () => ({ NetworkDevice: class { connect = connectMock; disconnect = vi.fn(); + onConnectionLost = vi.fn(); connectToService = vi.fn().mockResolvedValue({}); }, }));