diff --git a/backend/app/api/routes/iot_gateway.py b/backend/app/api/routes/iot_gateway.py index ca5cc9fc..76259338 100644 --- a/backend/app/api/routes/iot_gateway.py +++ b/backend/app/api/routes/iot_gateway.py @@ -133,11 +133,6 @@ async def _proxy_esp32(inst, path: str, request: Request) -> Response: ) -# Hop-by-hop / per-connection headers we never forward verbatim. -_HOP_BY_HOP = {'host', 'transfer-encoding', 'connection', 'content-encoding', - 'keep-alive', 'proxy-connection', 'upgrade'} - - async def _proxy_picow(bridge, path: str, request: Request) -> Response: """Reverse-proxy to a Pico W web server living in the browser-side lwIP. @@ -149,13 +144,17 @@ async def _proxy_picow(bridge, path: str, request: Request) -> Response: query = request.url.query target = '/' + path + (('?' + query) if query else '') - headers = { - k: v for k, v in request.headers.items() - if k.lower() not in _HOP_BY_HOP - } - headers['Host'] = STA_IP - headers['Connection'] = 'close' # make the chip's server FIN when done - if body and 'content-length' not in {k.lower() for k in headers}: + # Forward a MINIMAL request. The chip's servers are tiny — they typically + # read one small recv() (e.g. recv(1024)) and don't parse beyond the + # request line + Host. A browser sends kilobytes of headers (cookies, + # User-Agent, sec-*, ...); forwarding them verbatim overruns that recv, + # and the chip's lwIP then RSTs the connection when it closes with unread + # data — crashing blocking-socket sketches with ECONNRESET. Keep it lean. + headers = {'Host': STA_IP, 'Connection': 'close'} + ctype = request.headers.get('content-type') + if ctype: + headers['Content-Type'] = ctype + if body: headers['Content-Length'] = str(len(body)) req_line = f'{request.method} {target} HTTP/1.1\r\n' diff --git a/backend/app/services/picow_net/tcp_inbound.py b/backend/app/services/picow_net/tcp_inbound.py index a1e0b26d..6f42cc46 100644 --- a/backend/app/services/picow_net/tcp_inbound.py +++ b/backend/app/services/picow_net/tcp_inbound.py @@ -128,6 +128,10 @@ class TcpInbound: return if conn.state != _State.ESTABLISHED: + # TIME_WAIT (or pre-handshake): re-ACK late retransmits so the chip + # closes cleanly. Never let it reach the outbound NAT (RST). + if tcp.payload or (tcp.flags & (TCP_FIN | TCP_SYN)): + await self._send(conn, TCP_ACK) return # In-order data only; re-ACK and drop anything out of order so the @@ -199,7 +203,14 @@ class TcpInbound: break # got a full header block and went idle — good enough return bytes(conn.rx) if conn.rx else None finally: - self._conns.pop(our_port, None) + # TIME_WAIT: keep the connection around briefly so late chip + # segments (a retransmitted FIN, a trailing ACK) still match this + # connection and get absorbed/re-ACKed here, instead of falling + # through to the chip-initiated NAT which would RST them — a RST + # crashes blocking-socket sketches with ECONNRESET. + conn.state = _State.CLOSED + loop = asyncio.get_event_loop() + loop.call_later(5.0, self._conns.pop, our_port, None) # ── frame emission ───────────────────────────────────────────────── diff --git a/frontend/src/__tests__/picow-gateway-e2e.investigate.test.ts b/frontend/src/__tests__/picow-gateway-e2e.investigate.test.ts index 54fdd982..1c4606f5 100644 --- a/frontend/src/__tests__/picow-gateway-e2e.investigate.test.ts +++ b/frontend/src/__tests__/picow-gateway-e2e.investigate.test.ts @@ -43,13 +43,17 @@ const SERVER_PY = [ 's.bind(("0.0.0.0", 80))', 's.listen(1)', 'print("LISTEN", ip)', + 'n = 0', + // Blocking server with NO per-client try/except (like the relay example): + // if the gateway ever forwarded an oversized request or RST a stale + // connection, recv()/the next accept() would raise ECONNRESET and kill + // this loop, so a second request would never be served. 'while True:', ' cl, addr = s.accept()', - ' try:', - ' cl.recv(512)', - ' cl.send(b"HTTP/1.1 200 OK\\r\\nContent-Type: text/html\\r\\n\\r\\n
VELXIO-PICO-OK")', - ' except Exception as e:', - ' print("SRVERR", repr(e))', + ' cl.recv(512)', + ' n += 1', + ' print("REQ", n)', + ' cl.send(b"HTTP/1.1 200 OK\\r\\nContent-Type: text/html\\r\\n\\r\\nVELXIO-PICO-OK")', ' cl.close()', ].join('\n'); @@ -113,34 +117,43 @@ describe.skipIf(!process.env.CYW43_GATEWAY_E2E)('Pico W IoT gateway inbound', () } expect(serial).toMatch(/LISTEN 10\.13\.37\.42/); - // 2. Fire the gateway request and keep stepping the chip so it can - // accept the inbound connection and serve the page. + // 2. Fire TWO sequential gateway requests, the first carrying a large + // header (simulating a real browser's cookies/User-Agent). The chip's + // non-resilient blocking server must serve BOTH (proving the gateway + // forwards a lean request and never RSTs the chip on cleanup). const gwUrl = `${API_BASE}/gateway/${encodeURIComponent(clientId)}/`; - let result: { status: number; body: string } | null = null; - let err: unknown = null; - const fetchP = fetch(gwUrl) - .then(async (r) => { result = { status: r.status, body: await r.text() }; }) - .catch((e) => { err = e; }); + const bigHeader = { 'X-Browser-Junk': 'a'.repeat(4096) }; + const doFetch = async (path: string, headers?: Record