Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions network/NetworkDevice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)) {
Expand Down
16 changes: 16 additions & 0 deletions network/StageLinqDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export declare interface StageLinqDevices {
on(event: 'message', listener: (connectionInfo: ConnectionInfo, message: ServiceMessage<StateData>) => 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;
}

//////////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
38 changes: 37 additions & 1 deletion test/network/NetworkDevice.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
});
48 changes: 48 additions & 0 deletions test/network/StageLinqDevices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) =>
Expand All @@ -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({});
},
}));
Expand Down Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions test/network/connectRetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ vi.mock("../../network", () => ({
NetworkDevice: class {
connect = connectMock;
disconnect = vi.fn();
onConnectionLost = vi.fn();
connectToService = vi.fn().mockResolvedValue({});
},
}));
Expand Down