Merge pull request #244 from davidmonterocrespo24/fix/picow-gateway-browser-robustness

Fix/picow gateway browser robustness
This commit is contained in:
David Montero Crespo 2026-06-13 22:13:35 -03:00 committed by GitHub
commit 3b8266a75f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 89 additions and 53 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 ─────────────────────────────────────────────────

View File

@ -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)',
' n += 1',
' print("REQ", n)',
' cl.send(b"HTTP/1.1 200 OK\\r\\nContent-Type: text/html\\r\\n\\r\\n<html><body>VELXIO-PICO-OK</body></html>")',
' except Exception as e:',
' print("SRVERR", repr(e))',
' 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 reqDeadline = Date.now() + 30_000;
while (Date.now() < reqDeadline && result === null && err === null) {
const bigHeader = { 'X-Browser-Junk': 'a'.repeat(4096) };
const doFetch = async (path: string, headers?: Record<string, string>) => {
let r: { status: number; body: string } | null = null;
let e: unknown = null;
const p = fetch(gwUrl + path, headers ? { headers } : undefined)
.then(async (resp) => { r = { status: resp.status, body: await resp.text() }; })
.catch((x) => { e = x; });
const dl = Date.now() + 25_000;
while (Date.now() < dl && r === null && e === null) {
sim.runFrameForTime(10);
await new Promise((r) => setTimeout(r, 0));
await new Promise((res) => setTimeout(res, 0));
}
await fetchP;
await p;
return { r, e };
};
const first = await doFetch('', bigHeader); // page load (browser-sized)
const second = await doFetch('on'); // the toggle
try { bridge.disconnect(); } catch { /* noop */ }
try { sim.stop(); } catch { /* noop */ }
// eslint-disable-next-line no-console
console.log('\n===== GATEWAY E2E =====\nclientId=' + clientId +
'\nserial(PY)=' + serial.split('\n').filter((l) => l.startsWith('PY') || l.startsWith('LISTEN') || l.startsWith('SRV')).join(' | ') +
'\nfetch=' + JSON.stringify(result) + (err ? ' err=' + String(err) : '') +
'\npkts=\n' + pktlog.join('\n'));
'\nserial=' + serial.split('\n').filter((l) => /^(PYIP|LISTEN|REQ|SRV|Traceback|OSError)/.test(l)).join(' | ') +
'\nfirst=' + JSON.stringify(first.r) + '\nsecond=' + JSON.stringify(second.r));
expect(err).toBeNull();
expect(result).not.toBeNull();
expect(result!.status).toBe(200);
expect(result!.body).toContain('VELXIO-PICO-OK');
expect(first.e).toBeNull();
expect(first.r?.status).toBe(200);
expect(first.r?.body).toContain('VELXIO-PICO-OK');
// The blocking server survived the first request and served the second.
expect(second.r?.status).toBe(200);
expect(serial).toContain('REQ 2');
expect(serial).not.toMatch(/Traceback|ECONNRESET/);
}, 140_000);
});

View File

@ -72,8 +72,8 @@ async def wifi_connect():
HTML = """<!DOCTYPE html>
<html><body><h2>Pico W Async LED</h2>
<button onclick="fetch('/on')">ON</button>
<button onclick="fetch('/off')">OFF</button>
<button onclick="fetch('on')">ON</button>
<button onclick="fetch('off')">OFF</button>
</body></html>"""
async def handle_client(reader, writer):
@ -137,19 +137,32 @@ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((ip, 80))
s.listen(1)
print("Open browser: http://" + ip)
print("Open browser: http://%s/" % ip)
def page():
state = "ON" if relay_state == 0 else "OFF"
return ("<html><body><h2>Pico W Relay: %s</h2>"
"<button onclick=\\"fetch('on').then(()=>location.reload())\\">ON</button> "
"<button onclick=\\"fetch('off').then(()=>location.reload())\\">OFF</button>"
"</body></html>") % state
# Wrap each client in try/except: a browser that drops the connection
# mid-response would otherwise raise ECONNRESET and kill the server loop.
while True:
try:
conn, addr = s.accept()
request = str(conn.recv(1024))
if "/on" in request:
relay_state = 0; relay.value(relay_state)
elif "/off" in request:
relay_state = 1; relay.value(relay_state)
conn.send("HTTP/1.1 200 OK\\r\\nContent-Type: text/html\\r\\n\\r\\n")
conn.sendall("<html><body>Relay: %s</body></html>" %
("ON" if relay_state == 0 else "OFF"))
conn.send("HTTP/1.1 200 OK\\r\\nContent-Type: text/html\\r\\n\\r\\n" + page())
conn.close()
except OSError:
try:
conn.close()
except Exception:
pass
`);
const SERVO_WEB_PY = withVelxioGuest(`# Pico W Web Servo Controller — MicroPython
@ -183,7 +196,7 @@ def write_servo(angle):
def webpage(pos):
return ("<html><body><h1>Servo {p}&deg;</h1>"
"<input type=range min=0 max=180 value={p} "
"oninput=\\"fetch('/?value='+this.value)\\"></body></html>").format(p=pos)
"oninput=\\"fetch('?value='+this.value)\\"></body></html>").format(p=pos)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)