From 2d7614e66efdd53f31d32b020d889a901982f308 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 19 Feb 2026 12:01:33 -0500 Subject: [PATCH 1/2] fix: resolve serial debugger spurious values from concurrent polling bugs Three interrelated concurrency bugs caused phantom TRUE values on BOOL outputs in the serial (Modbus RTU) debugger. Serial is most affected because its small batch size (20) and slow transport make poll cycles frequently exceed the 200ms interval. 1. Replace setInterval with recursive setTimeout so the next poll only schedules after the current one completes, making concurrent polls structurally impossible. 2. Send a single batch per poll cycle instead of all batches back-to-back. A batchOffsetRef tracks position across cycles so all variables still get polled. Values from previous batches persist because newValues starts as a copy of the current store. 3. Add promise-chain mutex to both RTU and TCP Modbus clients so that a setVariable (force/release) IPC call arriving during a poll's getVariablesList cannot cause concurrent serial/socket access. Co-Authored-By: Claude Opus 4.6 --- src/main/modules/modbus/modbus-client.ts | 367 ++++++++----------- src/main/modules/modbus/modbus-rtu-client.ts | 16 +- src/renderer/screens/workspace-screen.tsx | 100 ++--- 3 files changed, 213 insertions(+), 270 deletions(-) diff --git a/src/main/modules/modbus/modbus-client.ts b/src/main/modules/modbus/modbus-client.ts index 27f68b43c..a742d411b 100644 --- a/src/main/modules/modbus/modbus-client.ts +++ b/src/main/modules/modbus/modbus-client.ts @@ -26,6 +26,7 @@ export class ModbusTcpClient { private timeout: number private socket: Socket | null = null private transactionId: number = 0 + private sendRequestMutex: Promise = Promise.resolve() constructor(options: ModbusTcpClientOptions) { this.host = options.host @@ -66,6 +67,48 @@ export class ModbusTcpClient { } } + private sendTcpRequestImpl(request: Buffer): Promise { + return new Promise((resolve, reject) => { + if (!this.socket) { + reject(new Error('Not connected to target')) + return + } + + const timeoutHandle = setTimeout(() => { + this.socket?.removeListener('data', onData) + this.socket?.removeListener('error', onError) + reject(new Error('Request timeout')) + }, this.timeout) + + const onData = (data: Buffer) => { + clearTimeout(timeoutHandle) + this.socket?.removeListener('data', onData) + this.socket?.removeListener('error', onError) + resolve(data) + } + + const onError = (error: Error) => { + clearTimeout(timeoutHandle) + this.socket?.removeListener('data', onData) + this.socket?.removeListener('error', onError) + reject(error) + } + + this.socket.once('data', onData) + this.socket.once('error', onError) + this.socket.write(request as unknown as Uint8Array) + }) + } + + private sendTcpRequest(request: Buffer): Promise { + return new Promise((resolve, reject) => { + this.sendRequestMutex = this.sendRequestMutex.then( + () => this.sendTcpRequestImpl(request).then(resolve, reject), + () => this.sendTcpRequestImpl(request).then(resolve, reject), + ) + }) + } + async getMd5Hash(): Promise { if (!this.socket) { throw new Error('Not connected to target') @@ -87,59 +130,30 @@ export class ModbusTcpClient { request.writeUInt8(0, 10) request.writeUInt8(0, 11) - return new Promise((resolve, reject) => { - const timeoutHandle = setTimeout(() => { - reject(new Error('Request timeout')) - }, this.timeout) + const data = await this.sendTcpRequest(request) - const onData = (data: Buffer) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) + if (data.length < 9) { + throw new Error('Invalid response: too short') + } - try { - if (data.length < 9) { - reject(new Error('Invalid response: too short')) - return - } - - const responseTransactionId = data.readUInt16BE(0) - const responseFunctionCode = data.readUInt8(7) - const statusCode = data.readUInt8(8) - - if (responseTransactionId !== transactionId) { - reject(new Error('Transaction ID mismatch')) - return - } - - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { - reject(new Error('Function code mismatch')) - return - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - reject(new Error(`Target returned error code: 0x${statusCode.toString(16)}`)) - return - } - - const md5String = data.slice(9).toString('utf-8').trim() - resolve(md5String) - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))) - } - } + const responseTransactionId = data.readUInt16BE(0) + const responseFunctionCode = data.readUInt8(7) + const statusCode = data.readUInt8(8) - const onError = (error: Error) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) - reject(error) - } + if (responseTransactionId !== transactionId) { + throw new Error('Transaction ID mismatch') + } - this.socket!.once('data', onData) - this.socket!.once('error', onError) - this.socket!.write(request as unknown as Uint8Array) - }) + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { + throw new Error('Function code mismatch') + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + throw new Error(`Target returned error code: 0x${statusCode.toString(16)}`) + } + + const md5String = data.slice(9).toString('utf-8').trim() + return md5String } async getVariablesList(variableIndexes: number[]): Promise<{ @@ -173,95 +187,66 @@ export class ModbusTcpClient { request.writeUInt16BE(variableIndexes[i], 10 + i * 2) } - return new Promise((resolve) => { - const timeoutHandle = setTimeout(() => { - resolve({ success: false, error: 'Request timeout' }) - }, this.timeout) + try { + const data = await this.sendTcpRequest(request) - const onData = (data: Buffer) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } - try { - if (data.length < 9) { - resolve({ success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` }) - return - } - - const responseTransactionId = data.readUInt16BE(0) - const responseFunctionCode = data.readUInt8(7) - const statusCode = data.readUInt8(8) - - if (responseTransactionId !== transactionId) { - resolve({ success: false, error: 'Transaction ID mismatch' }) - return - } - - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { - resolve({ success: false, error: 'Function code mismatch' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { - resolve({ success: false, error: 'ERROR_OUT_OF_BOUNDS' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { - resolve({ success: false, error: 'ERROR_OUT_OF_MEMORY' }) - return - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - resolve({ success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` }) - return - } - - if (data.length < 17) { - resolve({ - success: false, - error: `Incomplete success response (${data.length} bytes, expected at least 17)`, - }) - return - } - - const lastIndex = data.readUInt16BE(9) - const tick = data.readUInt32BE(11) - const responseSize = data.readUInt16BE(15) - - if (data.length < 17 + responseSize) { - resolve({ - success: false, - error: `Incomplete variable data (expected ${responseSize} bytes, got ${data.length - 17})`, - }) - return - } - - const variableData = data.slice(17, 17 + responseSize) - - resolve({ - success: true, - tick, - lastIndex, - data: variableData, - }) - } catch (error) { - resolve({ success: false, error: String(error) }) + const responseTransactionId = data.readUInt16BE(0) + const responseFunctionCode = data.readUInt8(7) + const statusCode = data.readUInt8(8) + + if (responseTransactionId !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { + return { success: false, error: 'Function code mismatch' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { success: false, error: 'ERROR_OUT_OF_MEMORY' } + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + if (data.length < 17) { + return { + success: false, + error: `Incomplete success response (${data.length} bytes, expected at least 17)`, } } - const onError = (error: Error) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) - resolve({ success: false, error: error.message }) + const lastIndex = data.readUInt16BE(9) + const tick = data.readUInt32BE(11) + const responseSize = data.readUInt16BE(15) + + if (data.length < 17 + responseSize) { + return { + success: false, + error: `Incomplete variable data (expected ${responseSize} bytes, got ${data.length - 17})`, + } } - this.socket!.once('data', onData) - this.socket!.once('error', onError) - this.socket!.write(request as unknown as Uint8Array) - }) + const variableData = data.slice(17, 17 + responseSize) + + return { + success: true, + tick, + lastIndex, + data: variableData, + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } } async setVariable( @@ -272,10 +257,7 @@ export class ModbusTcpClient { success: boolean error?: string }> { - console.log('[ModbusTcpClient] setVariable called with:', { variableIndex, force, valueBuffer }) - if (!this.socket) { - console.log('[ModbusTcpClient] Socket not connected') return { success: false, error: 'Not connected to target' } } @@ -305,101 +287,40 @@ export class ModbusTcpClient { request.writeUInt8(0, 13) } - console.log('[ModbusTcpClient] Sending request:', { - transactionId, - protocolId, - pduLength, - unitId, - functionCode: `0x${functionCode.toString(16)}`, - variableIndex, - forceFlag: force ? 1 : 0, - dataLength, - valueBuffer: valueBuffer?.toString('hex'), - requestHex: request.toString('hex'), - }) + try { + const data = await this.sendTcpRequest(request) - return new Promise((resolve) => { - const timeoutHandle = setTimeout(() => { - resolve({ success: false, error: 'Request timeout' }) - }, this.timeout) + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } - const onData = (data: Buffer) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) + const responseTransactionId = data.readUInt16BE(0) + const responseFunctionCode = data.readUInt8(7) + const statusCode = data.readUInt8(8) - console.log('[ModbusTcpClient] Received response:', { - length: data.length, - hex: data.toString('hex'), - }) - - try { - if (data.length < 9) { - console.log('[ModbusTcpClient] Response too short') - resolve({ success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` }) - return - } - - const responseTransactionId = data.readUInt16BE(0) - const responseFunctionCode = data.readUInt8(7) - const statusCode = data.readUInt8(8) - - console.log('[ModbusTcpClient] Response parsed:', { - responseTransactionId, - expectedTransactionId: transactionId, - responseFunctionCode: `0x${responseFunctionCode.toString(16)}`, - expectedFunctionCode: `0x${ModbusFunctionCode.DEBUG_SET.toString(16)}`, - statusCode: `0x${statusCode.toString(16)}`, - }) - - if (responseTransactionId !== transactionId) { - console.log('[ModbusTcpClient] Transaction ID mismatch') - resolve({ success: false, error: 'Transaction ID mismatch' }) - return - } - - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_SET as number)) { - console.log('[ModbusTcpClient] Function code mismatch') - resolve({ success: false, error: 'Function code mismatch' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { - console.log('[ModbusTcpClient] ERROR_OUT_OF_BOUNDS') - resolve({ success: false, error: 'ERROR_OUT_OF_BOUNDS' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { - console.log('[ModbusTcpClient] ERROR_OUT_OF_MEMORY') - resolve({ success: false, error: 'ERROR_OUT_OF_MEMORY' }) - return - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - console.log('[ModbusTcpClient] Unknown error code') - resolve({ success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` }) - return - } - - console.log('[ModbusTcpClient] Success!') - resolve({ success: true }) - } catch (error) { - console.error('[ModbusTcpClient] Error parsing response:', error) - resolve({ success: false, error: String(error) }) - } + if (responseTransactionId !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } } - const onError = (error: Error) => { - clearTimeout(timeoutHandle) - this.socket?.removeListener('data', onData) - this.socket?.removeListener('error', onError) - resolve({ success: false, error: error.message }) + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_SET as number)) { + return { success: false, error: 'Function code mismatch' } } - this.socket!.once('data', onData) - this.socket!.once('error', onError) - this.socket!.write(request as unknown as Uint8Array) - }) + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { success: false, error: 'ERROR_OUT_OF_MEMORY' } + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } } } diff --git a/src/main/modules/modbus/modbus-rtu-client.ts b/src/main/modules/modbus/modbus-rtu-client.ts index b7770b34e..060737852 100644 --- a/src/main/modules/modbus/modbus-rtu-client.ts +++ b/src/main/modules/modbus/modbus-rtu-client.ts @@ -141,7 +141,18 @@ export class ModbusRtuClient { }) } + private sendRequestMutex: Promise = Promise.resolve() + private async sendRequest(request: Buffer): Promise { + return new Promise((resolve, reject) => { + this.sendRequestMutex = this.sendRequestMutex.then( + () => this.sendRequestImpl(request).then(resolve, reject), + () => this.sendRequestImpl(request).then(resolve, reject), + ) + }) + } + + private async sendRequestImpl(request: Buffer): Promise { if (!this.serialPort || !this.serialPort.isOpen) { throw new Error('Serial port is not open') } @@ -181,10 +192,7 @@ export class ModbusRtuClient { const calculatedCrc = this.calculateCrc(responseBuffer.slice(0, responseBuffer.length - 2)) if (receivedCrc !== calculatedCrc) { - // OpenPLC debugger ignores CRC errors - //reject(new Error('CRC validation failed')) - //return - console.warn('Warning: CRC validation failed, but continuing anyway') + // OpenPLC debugger ignores CRC errors — mismatch is non-fatal } const responseWithoutCrc = responseBuffer.slice(0, responseBuffer.length - 2) diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx index b3b7295b3..4ff87a5e4 100644 --- a/src/renderer/screens/workspace-screen.tsx +++ b/src/renderer/screens/workspace-screen.tsx @@ -171,6 +171,7 @@ const WorkspaceScreen = () => { const pollingIntervalRef = useRef(null) const isMountedRef = useRef(true) const graphListRef = useRef([]) + const batchOffsetRef = useRef(0) useEffect(() => { isMountedRef.current = true @@ -202,7 +203,7 @@ const WorkspaceScreen = () => { if (!isDebuggerVisible) { if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current) + clearTimeout(pollingIntervalRef.current) pollingIntervalRef.current = null } variableInfoMapRef.current = null @@ -218,7 +219,7 @@ const WorkspaceScreen = () => { if (isRuntimeTarget) { if (connectionStatus !== 'connected') { if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current) + clearTimeout(pollingIntervalRef.current) pollingIntervalRef.current = null } variableInfoMapRef.current = null @@ -1377,63 +1378,69 @@ const WorkspaceScreen = () => { return } + // Single-batch-per-cycle: use batchOffsetRef to track position across poll cycles + // Values from previous batches persist because newValues starts as a copy of the current store const { workspace: currentWorkspace } = useOpenPLCStore.getState() const newValues = new Map() currentWorkspace.debugVariableValues.forEach((value: string, key: string) => { newValues.set(key, value) }) + let currentBatchSize = batchSize - let processedCount = 0 - while (processedCount < allIndexes.length) { - const batch = allIndexes.slice(processedCount, processedCount + currentBatchSize) + // Clamp batchOffset to valid range (handles list size changes between cycles) + let batchOffset = batchOffsetRef.current + if (batchOffset >= allIndexes.length) { + batchOffset = 0 + } + + // Slice one batch from the current offset + let batch = allIndexes.slice(batchOffset, batchOffset + currentBatchSize) + + // First request + let result = await window.bridge.debuggerGetVariablesList(batch) - const result = await window.bridge.debuggerGetVariablesList(batch) + // Handle ERROR_OUT_OF_MEMORY with retry (halve batch size, retry same offset) + while (!result.success && result.error === 'ERROR_OUT_OF_MEMORY' && currentBatchSize > 2) { + currentBatchSize = Math.max(2, Math.floor(currentBatchSize / 2)) + batch = allIndexes.slice(batchOffset, batchOffset + currentBatchSize) + result = await window.bridge.debuggerGetVariablesList(batch) + } + + if (!result.success) { + if (result.needsReconnect) { + const { consoleActions, workspaceActions: wsReconnect } = useOpenPLCStore.getState() + consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `Debugger connection lost: ${result.error || 'Unknown error'}. Attempting to reconnect...`, + }) - if (!result.success) { - if (result.needsReconnect) { - const { consoleActions, workspaceActions } = useOpenPLCStore.getState() + if (result.error?.includes('Failed to reconnect')) { + wsReconnect.setDebuggerVisible(false) + wsReconnect.setDebugForcedVariables(new Map()) consoleActions.addLog({ id: crypto.randomUUID(), level: 'error', - message: `Debugger connection lost: ${result.error || 'Unknown error'}. Attempting to reconnect...`, + message: 'Debugger session closed due to connection failure.', }) - - if (result.error?.includes('Failed to reconnect')) { - workspaceActions.setDebuggerVisible(false) - workspaceActions.setDebugForcedVariables(new Map()) - consoleActions.addLog({ - id: crypto.randomUUID(), - level: 'error', - message: 'Debugger session closed due to connection failure.', - }) - return - } + return } - - if (result.error === 'ERROR_OUT_OF_MEMORY' && currentBatchSize > 2) { - currentBatchSize = Math.max(2, Math.floor(currentBatchSize / 2)) - continue - } else { - break - } - } - - if (!result.data || result.lastIndex === undefined) { - break } + return + } - if (!Array.isArray(result.data)) { - break - } + let itemsProcessed = 0 + if (result.data && result.lastIndex !== undefined && Array.isArray(result.data)) { const responseBuffer = new Uint8Array(result.data) let bufferOffset = 0 - let itemsProcessed = 0 for (const index of batch) { const varInfos = variableInfoMapRef.current?.get(index) - if (!varInfos || varInfos.length === 0) continue + if (!varInfos || varInfos.length === 0) { + continue + } // Use the first entry for parsing (all entries share the same debug index and type) const { variable } = varInfos[0] @@ -1465,8 +1472,11 @@ const WorkspaceScreen = () => { break } } + } - processedCount += itemsProcessed + // Advance offset for next poll cycle (wraps around) + if (itemsProcessed > 0) { + batchOffsetRef.current = (batchOffset + itemsProcessed) % allIndexes.length } if (isMountedRef.current) { @@ -1482,14 +1492,18 @@ const WorkspaceScreen = () => { } } - void pollVariables() - pollingIntervalRef.current = setInterval(() => { - void pollVariables() - }, DEBUGGER_POLL_INTERVAL_MS) + const schedulePoll = () => { + if (!isMountedRef.current) return + pollingIntervalRef.current = setTimeout(() => { + void pollVariables().finally(() => schedulePoll()) + }, DEBUGGER_POLL_INTERVAL_MS) + } + // Fire first poll immediately, then chain + void pollVariables().finally(() => schedulePoll()) return () => { if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current) + clearTimeout(pollingIntervalRef.current) pollingIntervalRef.current = null } void window.bridge.debuggerDisconnect().catch((error: unknown) => { From 71ccc1eaa473243cc47c158122e65bac07e912a4 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 19 Feb 2026 14:56:48 -0500 Subject: [PATCH 2/2] fix: increase debugger polling rate and reduce RTU frame timeout Reduce poll interval from 200ms to 50ms with skip-if-busy guard so polling adapts to target speed. Lower RTU frame-complete timeout from 50ms to 10ms to avoid unnecessary waiting after each serial response. Co-Authored-By: Claude Opus 4.6 --- src/main/modules/modbus/modbus-rtu-client.ts | 3 +- src/renderer/screens/workspace-screen.tsx | 30 ++++++++++++-------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/main/modules/modbus/modbus-rtu-client.ts b/src/main/modules/modbus/modbus-rtu-client.ts index 060737852..21f25c5b9 100644 --- a/src/main/modules/modbus/modbus-rtu-client.ts +++ b/src/main/modules/modbus/modbus-rtu-client.ts @@ -14,7 +14,8 @@ interface ModbusRtuClientOptions { const ARDUINO_BOOTLOADER_DELAY_MS = 2500 const MD5_REQUEST_MAX_RETRIES = 3 const MD5_REQUEST_RETRY_DELAY_MS = 500 -const FRAME_COMPLETE_TIMEOUT_MS = 50 + +const FRAME_COMPLETE_TIMEOUT_MS = 10 export class ModbusRtuClient { private port: string diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx index 4ff87a5e4..d603cc694 100644 --- a/src/renderer/screens/workspace-screen.tsx +++ b/src/renderer/screens/workspace-screen.tsx @@ -50,7 +50,7 @@ import { StandardFunctionBlocks } from '../data/library/standard-function-blocks import { useOpenPLCStore } from '../store' import { getVariableSize, parseVariableValue } from '../utils/variable-sizes' -const DEBUGGER_POLL_INTERVAL_MS = 200 +const DEBUGGER_POLL_INTERVAL_MS = 50 const WorkspaceScreen = () => { const { @@ -203,7 +203,7 @@ const WorkspaceScreen = () => { if (!isDebuggerVisible) { if (pollingIntervalRef.current) { - clearTimeout(pollingIntervalRef.current) + clearInterval(pollingIntervalRef.current) pollingIntervalRef.current = null } variableInfoMapRef.current = null @@ -219,7 +219,7 @@ const WorkspaceScreen = () => { if (isRuntimeTarget) { if (connectionStatus !== 'connected') { if (pollingIntervalRef.current) { - clearTimeout(pollingIntervalRef.current) + clearInterval(pollingIntervalRef.current) pollingIntervalRef.current = null } variableInfoMapRef.current = null @@ -1492,18 +1492,24 @@ const WorkspaceScreen = () => { } } - const schedulePoll = () => { - if (!isMountedRef.current) return - pollingIntervalRef.current = setTimeout(() => { - void pollVariables().finally(() => schedulePoll()) - }, DEBUGGER_POLL_INTERVAL_MS) - } - // Fire first poll immediately, then chain - void pollVariables().finally(() => schedulePoll()) + let isPolling = false + // Fire first poll immediately + isPolling = true + void pollVariables().finally(() => { + isPolling = false + }) + // Schedule fixed-rate polling; skip tick if previous poll is still in progress + pollingIntervalRef.current = setInterval(() => { + if (!isMountedRef.current || isPolling) return + isPolling = true + void pollVariables().finally(() => { + isPolling = false + }) + }, DEBUGGER_POLL_INTERVAL_MS) return () => { if (pollingIntervalRef.current) { - clearTimeout(pollingIntervalRef.current) + clearInterval(pollingIntervalRef.current) pollingIntervalRef.current = null } void window.bridge.debuggerDisconnect().catch((error: unknown) => {