/** * Client device manager - manages devices from multiple servers */ import type { ServerConnection } from '../config/schema.js'; import type { DeviceBindMessage, DeviceUnbindMessage, Message } from '../protocol/types.js'; import { MessageType } from '../protocol/types.js'; import type { DeviceInfo } from '../types/common.js'; import { DeviceState } from '../types/common.js'; import { attachDevice, detachDevice, listAttachedDevices, loadModules, matchesPathPattern, } from '../usbip/commands.js'; import { logger } from '../utils/logger.js'; interface ManagedDevice { serverName: string; // Which server this device belongs to config: { path: string; description?: string; }; state: DeviceState; busId?: string; // Remote bus ID port?: string; // Local VHCI port after attachment retryCount?: number; // Number of retry attempts lastRetryTime?: number; // Timestamp of last retry } /** * Device manager for client */ export class DeviceManager { private devices: Map = new Map(); private healthCheckInterval: Timer | null = null; private sendMessage: (serverName: string, message: Message) => void; // Track last bind attempt per device for debouncing private lastBindAttempt: Map = new Map(); private readonly BIND_DEBOUNCE_MS = 500; // 500ms debounce window private readonly RETRY_DELAYS_MS = [1000, 2000, 5000, 10000, 30000]; // Exponential backoff private readonly MAX_RETRY_DELAY_MS = 30000; // Cap at 30 seconds for indefinite retries constructor( servers: ServerConnection[], sendMessage: (serverName: string, message: Message) => void ) { this.sendMessage = sendMessage; // Initialize device tracking for all servers for (const server of servers) { for (const deviceConfig of server.devices) { const key = this.getDeviceKey(server.name, deviceConfig.path); this.devices.set(key, { serverName: server.name, config: deviceConfig, state: DeviceState.DISCONNECTED, }); } } } /** * Start device management */ async start(): Promise { logger.info('Starting client device manager...'); // Load kernel modules await loadModules(); // Clean up any stale attached devices from previous sessions await this.cleanupStaleDevices(); // Start periodic health check this.startHealthCheck(); logger.info('Client device manager started'); } /** * Clean up stale attached devices that aren't managed by us */ private async cleanupStaleDevices(): Promise { const attachedDevices = await listAttachedDevices(); if (attachedDevices.length > 0) { logger.info( `Found ${attachedDevices.length} attached device(s) from previous session, detaching...` ); for (const device of attachedDevices) { await detachDevice(device.port); } } } /** * Stop device management */ async stop(): Promise { logger.info('Stopping client device manager...'); if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); this.healthCheckInterval = null; } // Detach all devices for (const device of this.devices.values()) { if (device.port) { await detachDevice(device.port); } } logger.info('Client device manager stopped'); } /** * Handle device bind message from server */ async handleDeviceBind(serverName: string, message: DeviceBindMessage): Promise { const { device, serverAddress } = message; const key = this.getDeviceKey(serverName, device.path); // First try exact match let managedDevice = this.devices.get(key); // If no exact match, check if any configured pattern matches this device if (!managedDevice) { for (const [configKey, dev] of this.devices.entries()) { if (dev.serverName === serverName && this.matchesPattern(device.path, dev.config.path)) { // Found a matching pattern! Use this managed device entry managedDevice = dev; logger.debug(`Device ${device.path} matches pattern ${dev.config.path}`); break; } } } if (!managedDevice) { logger.warn(`Received bind for unconfigured device: ${device.path} from ${serverName}`); return; } // Debounce: Prevent duplicate bind attempts within 500ms window const now = Date.now(); const lastAttempt = this.lastBindAttempt.get(key) || 0; if (now - lastAttempt < this.BIND_DEBOUNCE_MS) { logger.debug( `Ignoring duplicate bind for ${device.path} (debounced: ${now - lastAttempt}ms since last attempt)` ); return; } this.lastBindAttempt.set(key, now); // Skip if already attached if (managedDevice.state === DeviceState.ATTACHED) { logger.debug(`Device already attached: ${device.path}, skipping`); return; } logger.info(`Binding device: ${device.path} from ${serverName} (${serverAddress})`); await this.attemptAttachWithRetry(serverName, serverAddress, device, key, managedDevice); } /** * Attempt to attach device with retry logic */ private async attemptAttachWithRetry( serverName: string, serverAddress: string, device: DeviceInfo, key: string, managedDevice: ManagedDevice ): Promise { const retryCount = managedDevice.retryCount || 0; // First check if device is already attached const attachedDevices = await listAttachedDevices(); const alreadyAttached = attachedDevices.find((d) => d.busId === device.busId); if (alreadyAttached) { // Device is already attached, just update our state const deviceEntry: ManagedDevice = { serverName, config: { path: device.path, description: managedDevice.config.description, }, state: DeviceState.ATTACHED, busId: device.busId, port: alreadyAttached.port, retryCount: 0, }; this.devices.set(key, deviceEntry); logger.info( `✓ Device attached: ${device.path} on port ${alreadyAttached.port} (found in existing attachments)` ); return; } // Attach device const success = await attachDevice(serverAddress, device.busId); if (success) { // Wait for device to appear in usbip port await new Promise((resolve) => setTimeout(resolve, 1000)); // Increased to 1s // Query attached devices to get the port number const attachedDevicesAfter = await listAttachedDevices(); logger.debug(`Found ${attachedDevicesAfter.length} attached device(s)`); const attachedDevice = attachedDevicesAfter.find((d) => d.busId === device.busId); if (attachedDevice) { // Create or update device entry with actual path (not pattern) const deviceEntry: ManagedDevice = { serverName, config: { path: device.path, // Use actual device path, not pattern description: managedDevice.config.description, }, state: DeviceState.ATTACHED, busId: device.busId, port: attachedDevice.port, retryCount: 0, // Reset retry count on success }; this.devices.set(key, deviceEntry); logger.info(`✓ Device attached: ${device.path} on port ${deviceEntry.port}`); } else { logger.warn(`Device attached but not found in port list: ${device.path}`); // Schedule retry to verify attachment this.scheduleAttachRetry(serverName, serverAddress, device, key, managedDevice); } } else { logger.error(`Failed to attach device: ${device.path}`); // Schedule retry this.scheduleAttachRetry(serverName, serverAddress, device, key, managedDevice); } } /** * Schedule a retry attempt for device attachment (retries indefinitely) */ private scheduleAttachRetry( serverName: string, serverAddress: string, device: DeviceInfo, key: string, managedDevice: ManagedDevice ): void { const retryCount = (managedDevice.retryCount || 0) + 1; // Calculate delay with exponential backoff, capped at MAX_RETRY_DELAY_MS const delayIndex = Math.min(retryCount - 1, this.RETRY_DELAYS_MS.length - 1); const delay = Math.min( this.RETRY_DELAYS_MS[delayIndex] || this.MAX_RETRY_DELAY_MS, this.MAX_RETRY_DELAY_MS ); logger.info(`Scheduling retry #${retryCount} for ${device.path} in ${delay}ms`); setTimeout(async () => { logger.info(`Retrying attachment for ${device.path} (attempt #${retryCount})`); const updatedDevice = { ...managedDevice, retryCount, lastRetryTime: Date.now() }; this.devices.set(key, updatedDevice); await this.attemptAttachWithRetry(serverName, serverAddress, device, key, updatedDevice); }, delay); } /** * Handle server disconnect - detach all devices from that server */ async handleServerDisconnect(serverName: string): Promise { logger.info(`Detaching all devices from disconnected server: ${serverName}`); const devicesToDetach: Array<{ key: string; device: ManagedDevice; port: string }> = []; for (const [key, device] of this.devices.entries()) { if ( device.serverName === serverName && device.state === DeviceState.ATTACHED && device.port ) { devicesToDetach.push({ key, device, port: device.port }); } } for (const { key, device, port } of devicesToDetach) { logger.info(`Detaching device ${device.config.path} on port ${port}`); const success = await detachDevice(port); if (success) { device.state = DeviceState.DISCONNECTED; device.port = undefined; device.retryCount = 0; // Reset retry count this.devices.set(key, device); logger.info(`✓ Detached device: ${device.config.path}`); } } } /** * Handle device unbind message from server */ async handleDeviceUnbind(serverName: string, message: DeviceUnbindMessage): Promise { const { device } = message; const key = this.getDeviceKey(serverName, device.path); const managedDevice = this.devices.get(key); if (!managedDevice) { logger.warn(`Received unbind for unconfigured device: ${device.path} from ${serverName}`); return; } logger.info(`Unbinding device: ${device.path} from ${serverName}`); // Detach device if we have port info if (managedDevice.port) { const success = await detachDevice(managedDevice.port); if (success) { managedDevice.state = DeviceState.DISCONNECTED; managedDevice.port = undefined; this.devices.set(key, managedDevice); logger.info(`✓ Device detached: ${device.path}`); } } else { // Mark as disconnected anyway managedDevice.state = DeviceState.DISCONNECTED; this.devices.set(key, managedDevice); } } /** * Query server for device status */ queryDeviceStatus(serverName: string, path: string): void { this.sendMessage(serverName, { type: MessageType.DEVICE_STATUS_QUERY, timestamp: Date.now(), device: { path }, }); } /** * Start periodic health check */ private startHealthCheck(): void { this.healthCheckInterval = setInterval(async () => { logger.debug('Running device health check...'); await this.verifyDeviceStates(); }, 30000); // Every 30 seconds } /** * Verify device states and reconnect if needed */ private async verifyDeviceStates(): Promise { const attachedDevices = await listAttachedDevices(); for (const [key, device] of this.devices.entries()) { if (device.state === DeviceState.ATTACHED) { // Check if device is still attached const stillAttached = attachedDevices.some((d) => d.port === device.port); if (!stillAttached) { logger.warn(`Device lost: ${key}`); // Update state to disconnected device.state = DeviceState.DISCONNECTED; device.port = undefined; this.devices.set(key, device); // Query server to see if device is still available this.queryDeviceStatus(device.serverName, device.config.path); } } else if (device.state === DeviceState.DISCONNECTED) { // Query server to see if device is now available this.queryDeviceStatus(device.serverName, device.config.path); } } } /** * Get device map key */ private getDeviceKey(serverName: string, path: string): string { return `${serverName}:${path}`; } /** * Check if a device path matches a configured pattern (supports wildcards) */ private matchesPattern(devicePath: string, configPath: string): boolean { return matchesPathPattern(devicePath, configPath); } }