From fce1cdafdcc0c63003c91d0161a47f327dcdc18d Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Tue, 21 Jul 2026 07:01:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(esp32):=20force=20clean=20reconnect=20in=20?= =?UTF-8?q?Esp32Bridge.connect()=20=E2=80=94=20the=20real=20run-after-agen?= =?UTF-8?q?t=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stop-first guard in the Run button only fires when board.running is true, but the Run button is DISABLED while a board runs — so by the time the user can actually click Run, the board has already disconnected (running=false) and the guard is a no-op. The failure lives one level down: Esp32Bridge.connect() early-returned whenever a socket lingered in ANY non-CLOSED state (CONNECTING/OPEN/CLOSING). The agent's run_simulation leaves such a socket; when its backend QEMU session ends but the frontend socket is still zombie, the user's Run → startBoard → connect() did nothing. A page reload "fixed" it only by constructing a fresh bridge. connect() now tears down any lingering socket (detaching handlers + close) and opens a new one to the same session key — exactly what the reload does, which is why the reload always worked. The backend already handles a new WS replacing an existing session, so no reload is needed. Test: connect() on an OPEN socket closes the old one and boots a fresh start_esp32 (esp32-dht22-flow). Co-Authored-By: Claude Fable 5 --- .../src/__tests__/esp32-dht22-flow.test.ts | 18 +++++++++++++ frontend/src/simulation/Esp32Bridge.ts | 26 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/esp32-dht22-flow.test.ts b/frontend/src/__tests__/esp32-dht22-flow.test.ts index c0e760ca..cf0abf77 100644 --- a/frontend/src/__tests__/esp32-dht22-flow.test.ts +++ b/frontend/src/__tests__/esp32-dht22-flow.test.ts @@ -337,6 +337,24 @@ describe('Esp32Bridge — sensor WebSocket protocol', () => { ]); }); + it('connect() on a lingering (non-CLOSED) socket force-reconnects instead of no-op', () => { + // Regression: the agent leaves the board running (socket OPEN); the user's + // Run → startBoard → connect() must boot a FRESH session, not silently + // return because a socket already exists. Reproduces "run after the agent + // did nothing until I reloaded". + const firstWs = (bridge as any).socket as MockWebSocket; + expect(firstWs.readyState).toBe(MockWebSocket.OPEN); + + bridge.connect(); // second connect while the first socket is still OPEN + const secondWs = (bridge as any).socket as MockWebSocket; + + expect(firstWs.readyState).toBe(MockWebSocket.CLOSED); // old zombie torn down + expect(secondWs).not.toBe(firstWs); // a brand-new socket + secondWs.open(); + const startMsg = secondWs.messages.find((m) => m.type === 'start_esp32'); + expect(startMsg).toBeDefined(); // fresh boot actually happened + }); + it('sendSensorUpdate sends esp32_sensor_update message', () => { bridge.sendSensorUpdate(4, { temperature: 35, humidity: 70 }); expect(ws.messages).toEqual([ diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index d9dcea87..b396e97d 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -292,7 +292,31 @@ export class Esp32Bridge { } connect(): void { - if (this.socket && this.socket.readyState !== WebSocket.CLOSED) return; + // Force a clean reconnect. The old guard here was + // if (this.socket && readyState !== CLOSED) return; + // which made connect() a SILENT NO-OP whenever a socket lingered in any + // non-CLOSED state (CONNECTING / OPEN / CLOSING). That's exactly the + // "el agente terminó, di Run y no funcionó; recargué y sí" bug: the + // agent's run_simulation left a live/half-dead socket, the backend QEMU + // session had ended, and the user's Run → startBoard → connect() returned + // without doing anything. A page reload worked only because it built a + // fresh bridge. Tearing the zombie socket down and opening a new one to + // the same session key is exactly what that reload does — the backend + // already handles a new WS replacing an existing session (that's why + // reload works), so it's safe to do it without the reload. + if (this.socket) { + try { + this.socket.onopen = null; + this.socket.onmessage = null; + this.socket.onclose = null; + this.socket.onerror = null; + this.socket.close(); + } catch { + /* already closing/closed */ + } + this.socket = null; + this._connected = false; + } const base = API_BASE(); const wsProtocol = base.startsWith('https') ? 'wss:' : 'ws:';