Files
rea/tests/fixtures/hopperBridgeFrameLimitProbe.py
T
Dery Ferdikaandmorluto 26573c3aa4 fix(hopper): bound bridge request frames like the Cutter bridge (#5262)
* fix(hopper): bound bridge request frames like the Cutter bridge

The Hopper bridge read each request with an unbounded readline, so a peer
that never wrote a newline kept Hopper allocating for as long as it kept
writing. The Cutter bridge already refuses a frame above MAX_FRAME_BYTES;
the Hopper bridge had no request ceiling at all.

Read the request line with a 16 MiB bound and refuse a line that fills the
limit without a terminator, using the existing invalid_request diagnostic
before closing the connection. Such a line cannot be answered or
resynchronized, because its remainder would be read as the next request.
The response direction is unchanged, so large decompilation results still
flow, and every dispatchable method carries small bounded parameters.

The stalled-line case keeps its connection open instead of half-closing
it, so the test pins the exact ceiling: a bound one byte larger waits for
a byte the peer never sends and fails instead of passing quietly.

Validation: npm run check:fast (typecheck, lint) passed uncached, and the
new boundary test passed against the real bridge over socket pairs at the
frame limit and above it. Removing only the read bound makes the same test
fail, and widening the bound to one byte past the limit fails it too. The
Hopper boundary and bridge-packaging suites report the same 17 failing
files and 126 failing tests before and after this change on this Windows
host; those failures are POSIX-only probes (Unix socket directories,
os.statvfs) unrelated to this diff. Native macOS Hopper and real-client
traffic were not run.

* fix(bridges): exclude line delimiter from payload limit

* test(hopper): cover partial request at EOF

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-23 02:25:24 +08:00

133 lines
4.4 KiB
Python

"""Exercise the shipped bridge's request-frame bound over real socket pairs.
Four cases share one loaded bridge module:
- ``legit``: an ordinary authenticated request is served.
- ``at_limit``: a request with an exactly-limit JSON payload plus LF is served,
so framing does not consume a payload byte.
- ``past_limit``: a peer writes one byte past the limit and never terminates the
line. An unbounded line read cannot answer this at all, which is what makes
Hopper allocate for as long as the peer keeps writing.
- ``stalled_line``: a peer exceeds the payload limit without a terminator,
stops writing, and keeps the connection open. The bridge must refuse it
without waiting for EOF, then close.
- ``eof_partial``: an authenticated, otherwise valid short JSON request has no
LF and the peer half-closes its write side. The bridge must reject the
incomplete frame without dispatching it, then close.
"""
import json
import socket
import sys
import threading
from hopperBridgeFacadeProbe import load_bridge
READ_TIMEOUT_SECONDS = 5
def _next_response(file):
while True:
line = file.readline()
if not line:
return {"closed": True}
value = json.loads(line.decode("utf-8"))
if "event" not in value:
return value
def serve(bridge, line, read_after, half_close_write=False):
"""Send one raw line and record the bridge's answer."""
server, client = socket.socketpair()
worker = threading.Thread(target=bridge["_serve_connection"], args=(server,))
worker.daemon = True
worker.start()
file = client.makefile("rwb")
client.settimeout(READ_TIMEOUT_SECONDS)
outcome = {}
dispatch_count = len(bridge["_probe_dispatches"])
try:
file.write(line)
file.flush()
if half_close_write:
client.shutdown(socket.SHUT_WR)
outcome["reply"] = _next_response(file)
if read_after:
outcome["next"] = _next_response(file)
except OSError as error:
outcome["reply"] = {"socket_error": type(error).__name__}
finally:
try:
file.close()
finally:
client.close()
worker.join(2)
outcome["thread_released"] = not worker.is_alive()
outcome["dispatch_calls"] = (
len(bridge["_probe_dispatches"]) - dispatch_count
)
return outcome
def request_line(bridge, params):
request = {
"id": 1,
"token": bridge["REA_TOKEN"],
"method": "health",
"params": params,
}
return json.dumps(request, separators=(",", ":")).encode("utf-8")
def main():
bridge = load_bridge(sys.argv[1])
bridge.update(REA_TOKEN="owned-token", REA_RUN_ID="owned-run")
bridge["_probe_dispatches"] = []
def dispatch(method, params):
bridge["_probe_dispatches"].append((method, params))
return {"run_id": bridge["REA_RUN_ID"], "ready": True}
bridge["_dispatch"] = dispatch
limit = bridge.get("MAX_REQUEST_BYTES")
if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
json.dump({"limit": limit}, sys.stdout)
return
legit = serve(bridge, request_line(bridge, {}) + b"\n", False)
# The payload may fill the limit; the framing LF is additional.
padding = limit - len(request_line(bridge, {"padding": ""}))
at_limit_line = request_line(bridge, {"padding": "p" * padding}) + b"\n"
assert len(at_limit_line) == limit + 1, len(at_limit_line)
at_limit = serve(bridge, at_limit_line, False)
# One byte past the limit, still unterminated and still open.
past_limit = serve(bridge, b"x" * (limit + 1), False)
# The payload limit plus one byte, without a terminator. The peer stays
# connected so the bounded read must reject it without waiting for EOF.
stalled_line = serve(bridge, b"x" * (limit + 1), True)
# This is a valid authenticated request, but omitting LF makes it an
# incomplete frame. EOF must terminate the read and produce a rejection;
# the application dispatch must never see it.
eof_partial = serve(
bridge, request_line(bridge, {}), True, half_close_write=True
)
json.dump(
{
"limit": limit,
"legit": legit,
"at_limit": at_limit,
"past_limit": past_limit,
"stalled_line": stalled_line,
"eof_partial": eof_partial,
},
sys.stdout,
)
if __name__ == "__main__":
main()