Serve frames from the pump instead of reading QMP per request

/stream and /frame.png now read the pump's shared buffer. A slow client falls
behind in frames rather than in QMP reads, and reconnects no longer multiply the
load on the emulator.

/frame.png returns 503 when there is no frame rather than raising, because that is
a real state: the emulator can be powered off and the page still has to load. The
same reason create_app now tolerates client=None.

Measured on the live server with 4 concurrent streams, which is what a reconnecting
browser produces: keypress latency went from 6 ms avg to 5 ms, so -1 ms, i.e. noise.
All four clients received the same ~27 frames. /stream first byte in 4 ms.

Note this rules out my earlier guess: I had assumed concurrent streams were
starving keypresses, and the numbers said otherwise both before and after. The
pump is worth having for constant QMP load, not because contention was the
slowness.
This commit is contained in:
mckero committed 2026-08-28 05:55:03 +01:00
1 parent b75ef5fdcd
commit b500697224
2 files changed
+77 -15

No files matched your search

+48
View File
@@ -220,5 +220,53 @@ class TestFrontEndHoldMs(unittest.TestCase):
self.assertIn("MIN_HOLD_MS", self.body)
class TestStreamUsesPump(unittest.TestCase):
"""Serving frames must not cost QMP reads: the pump owns the grabbing."""
def _app(self):
client = StubClient()
app = webui.create_app(client, frame_addr=0x1000, status_addr=0x2000)
app.config.update(TESTING=True)
return client, app, app.test_client()
def _wait(self, app, timeout=3.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if app.config["PUMP"].latest() is not None:
return True
time.sleep(0.02)
return False
def test_exposes_the_pump(self):
_, app, _ = self._app()
self.assertIn("PUMP", app.config)
def test_serving_frames_costs_no_qmp_reads(self):
client, app, http = self._app()
self.assertTrue(self._wait(app), "pump produced no frame")
before = len([n for n, _ in client.sent if n == "memsave"])
http.get("/stream?limit=1")
http.get("/frame.png")
after = len([n for n, _ in client.sent if n == "memsave"])
# The pump keeps grabbing in the background, so allow a little drift;
# what must not happen is a read per request.
self.assertLess(after - before, 8,
"serving a frame triggered fresh QMP reads")
def test_frame_png_still_returns_a_png(self):
_, app, http = self._app()
self.assertTrue(self._wait(app))
resp = http.get("/frame.png")
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.get_data().startswith(b"\x89PNG\r\n\x1a\n"))
def test_frame_png_returns_503_when_there_is_no_frame(self):
"""No frame is a state, not a crash: the emulator may be powered off."""
app = webui.create_app(None, frame_addr=0x1000, status_addr=0x2000)
app.config.update(TESTING=True)
self.assertEqual(app.test_client().get("/frame.png").status_code, 503)
if __name__ == "__main__":
unittest.main()
+29 -15
View File
@@ -24,7 +24,7 @@ import time
from flask import Flask, Response, jsonify, request
from uvk5_keys import KEYS, is_valid, normalise
from uvk5_lcd import FrameGrabber, encode_png, unpack
from uvk5_stream import FramePump
KEYPAD_PATH = "/machine/keypad"
@@ -73,7 +73,12 @@ KEY_BINDINGS = {
def create_app(client, frame_addr: int, status_addr: int, scale: int = 4):
app = Flask(__name__)
grabber = FrameGrabber(client, frame_addr, status_addr)
# One background grabber for every client. client may be None: the emulator
# can be powered off, and the page still has to load.
pump = FramePump(client, frame_addr, status_addr, fps=TARGET_FPS, scale=scale)
pump.start()
app.config["PUMP"] = pump
def set_press(value: str):
client.command("qom-set", path=KEYPAD_PATH, property="press", value=value)
@@ -130,9 +135,21 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4):
set_press("")
return jsonify(ok=True)
def wait_for_frame(timeout: float = 2.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
png = pump.latest()
if png is not None:
return png
time.sleep(0.02)
return None
@app.get("/frame.png")
def frame_png():
return Response(grabber.png(scale), mimetype="image/png",
png = wait_for_frame()
if png is None:
return jsonify(error="no frame available; is the emulator on?"), 503
return Response(png, mimetype="image/png",
headers={"Cache-Control": "no-store"})
@app.get("/stream")
@@ -142,24 +159,21 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4):
interval = 1.0 / TARGET_FPS
def frames():
sent, last = 0, None
sent, seen = 0, -1
while limit is None or sent < limit:
started = time.monotonic()
status, frame = grabber.raw()
current = (status, frame)
# Only re-encode when the screen actually changed: the LCD is
# static most of the time, so idle CPU stays near zero.
if current != last or limit is not None:
last = current
png = encode_png(unpack(status, frame), scale)
png = pump.latest()
generation = pump.generation()
# Send only when the pump reports a new frame. A slow client
# therefore falls behind in frames, never in QMP reads.
if png is not None and (generation != seen or limit is not None):
seen = generation
yield (b"--" + BOUNDARY.encode() + b"\r\n"
b"Content-Type: image/png\r\n"
b"Content-Length: " + str(len(png)).encode()
+ b"\r\n\r\n" + png + b"\r\n")
sent += 1
slack = interval - (time.monotonic() - started)
if slack > 0:
time.sleep(slack)
else:
time.sleep(interval)
return Response(frames(),
mimetype=f"multipart/x-mixed-replace; boundary={BOUNDARY}",