fix(picow): make the IoT gateway robust to real browsers

Two real-world failures hit the Pico W gateway that the headless e2e
(node fetch, tiny request, fast timing) didn't surface:

- A browser sends KILOBYTES of headers (cookies, User-Agent, sec-*).
  Forwarded verbatim, the request overran the chip's small recv()
  (e.g. recv(1024)); lwIP then RST the connection on close-with-unread-
  data, crashing blocking-socket sketches with ECONNRESET. Now we forward
  a MINIMAL request (method, path, Host, Connection: close, and
  Content-Type/Length for bodies) — nothing a tiny server can choke on.

- After a gateway request completed we dropped the connection immediately,
  so a late chip segment (retransmitted FIN / trailing ACK) no longer
  matched and fell through to the chip-initiated NAT, which RST it. Add a
  short TIME_WAIT: keep the connection briefly and re-ACK late segments so
  the chip closes cleanly, never RSTing it.
This commit is contained in:
David Montero 2026-06-14 03:11:54 +02:00
parent 319decf1bd
commit bb4d06cc7a
2 changed files with 23 additions and 13 deletions

View File

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

View File

@ -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 ─────────────────────────────────────────────────