fix(esp32): force clean reconnect in Esp32Bridge.connect() — the real run-after-agent fix

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 <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-07-21 07:01:05 +02:00
parent 9c26174c93
commit fce1cdafdc
2 changed files with 43 additions and 1 deletions

View File

@ -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([

View File

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