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:';