diff options
| -rw-r--r-- | CLAUDE.md | 44 | ||||
| -rw-r--r-- | docs/USERGUIDE.md | 58 |
2 files changed, 90 insertions, 12 deletions
@@ -292,6 +292,44 @@ keypair bundle, or anything that looks like a user's public key. SPA; prefer ones that re-derive a value from the source over ones that restate it +- **A test that models a fix agrees with it by construction.** The first + buffer-ceiling test transcribed the player's credit loop into a small model and + passed, while the player it was written for still hung on a phone. The model and + the fix had the same author and the same misunderstanding. `tests/harness/ + mse_harness.mjs` lifts `bufferedAhead`, `evictBehind`, `flushQueue` and `pump` + out of `app.js` *as text* and executes them; what it models is the browser. When + even that was not enough, a headless Chrome driven against real fragmented MP4 + reproduced the defect in one run. Model the environment, never the code under + test + +- **`Cache-Control: no-cache` only binds a browser that asks.** One that cached + the SPA before that header existed applies heuristic freshness — a fraction of + the file's age, days for a file dated weeks ago — and does not ask at all. A + fix can be written, tested, deployed, served and still not be what runs, which + is indistinguishable from a fix that does not work. The whole module graph is + now served under `/a/<content-hash>/` so relative imports inherit the prefix and + no cache can serve yesterday's build or half of each. `test_asset_versioning.py` + +- **Redeploying during someone else's test destroys the evidence.** A node deploy + restarts the daemon, which kills every live WebRTC session — the tester sees + "transport not connected" caused by nothing they did — and `deploy-node.sh` + truncates `/tmp/meshbay-node.log`, taking the reproduction with it. Ask before + deploying while a reproduction is in flight + +- **`updateend` fires for `remove()` as well as `appendBuffer()`.** Crediting the + node from that event paid it for the player's own evictions: every time room was + made, more was asked for to fill it. Credit now follows the buffer, decided in + one place, and the append path grants nothing + +- **Flow control on a media stream is a window, not a debt.** Granting a credit + per append means pulling at network speed, which for a film is far faster than + watching it and fills the browser's SourceBuffer ceiling; accumulating those + credits and releasing the balance when the buffer finally drains sends the lot + in one burst and then says nothing for forty-six seconds. Bound the read-ahead + by *time past the playhead* and top a small window up as segments land. A viewer + deliberately holding credit must still say so, or the node's stall timeout ends + a film that is merely paused + - **`create_all()` is not a migration.** It creates missing *tables* and never a missing *column*, so a new column reaches the tests (fresh DB every run) and never reaches the deployed hub. Symptom: one endpoint answering 500 with an HTML body @@ -419,6 +457,12 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | JWT scope enforcement | `meshbay_hub.api.deps` | `require_user_scope` — blocks node-scoped tokens from mutations | | Node local admin UI | `meshbay_node.ui.app` | Dashboard, peers, groups, audit log (localhost:18000) | | Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py`, `QE/demo-v3/*.py` (not versioned) | +| Video flow control (browser) | `static/app.js` | `pump()` — the only place credit is granted. Read-ahead bounded by `BUFFER_AHEAD_S` of film, `STREAM_WINDOW` segments in flight, driven by a clock and by playback, never by arriving data | +| Player under test | `tests/harness/mse_harness.mjs` | Runs the real `pump`/`flushQueue`/`evictBehind` against a fake SourceBuffer with a ceiling. Do not write a second model of them | +| Asset versioning (hub) | `meshbay_hub.api.webapp` | `_asset_version()` — content hash; whole module graph served under `/a/<hash>/` so a cache cannot mix two builds | +| Stream capacity (node) | `meshbay_node.config` | `[node] max_concurrent_streams` (default 8) — a slot is held for the length of a film, so it counts simultaneous viewers | +| Stream diagnosis (node) | `webrtc_server.py` | `client_diag` at DEBUG — the player's own view (`ready`, `quota`, `ranges`, `err`) in the node's log. The only window into a phone | +| Stream probe (no browser) | — | `QE/deploy/stream_probe.py` — pulls a real film over real MNP. Answers "is it the node or the browser" in one run (not versioned) | ## meshbay.org server (état cible) diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index d6ce327..abfc404 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -732,9 +732,10 @@ node but cannot substitute one. ## 7. Video Streaming Video is streamed over the same MNP channel and played through Media Source Extensions. -The node transcodes to fragmented MP4 on the fly and encrypts each segment exactly like a -file chunk, so a standard `<video src=...>` cannot play it — the segments are ciphertext -until the client decrypts them. +The node remuxes to fragmented MP4 on the fly — the container changes, the video and audio +streams are copied untouched — and encrypts each segment exactly like a file chunk, so a +standard `<video src=...>` cannot play it: the segments are ciphertext until the client +decrypts them. ``` → {"type": "stream_req", "v": "0.1", "file_id": "<blake3 hex>"} @@ -751,22 +752,55 @@ ffmpeg must be installed on the node for transcoding. **Flow control.** The client says how many segments it can take — `stream_req` carries a credit count — and the node sends no more than that until `stream_more` -grants more. Without it the node hands ffmpeg's entire output to the channel as -fast as it is produced, and the browser holds a whole film in memory while the -player consumes it a segment at a time. Segments are 256 KB and the web client -keeps 24 outstanding, so roughly 6 MB is in flight whatever the film's length. A -client that sends no credit count gets the old unpaced behaviour. +grants more. Segments are 256 KB. + +What governs the credit is the playhead, not the append: the client grants more +only while it holds less than **90 seconds of film ahead of where you are +watching**. That bound is the whole point. ffmpeg runs with `-c copy` — a remux, +not a re-encode — so the bytes on the wire are the file's own, and a 500 MB film +really does try to put 500 MB somewhere. Granting credit per append instead meant +taking it as fast as the network could deliver, which filled the browser's +SourceBuffer ceiling (a few hundred megabytes) in the first minute and wedged the +player at "buffering" for good. Buffering by time costs the same for a two-hour +film as for a two-minute clip — around 20 MB at a typical bitrate. + +A client that sends no credit count gets the old unpaced behaviour. + +**A viewer that is well ahead still says so.** Holding credit back means granting +nothing for minutes at a time, which the node would otherwise read as a closed +tab. The client sends `stream_more` with `n = 0` every 20 seconds: it grants no +room but proves someone is there. The node ends a stream on silence, not on +stinginess. **Closing the viewer stops the stream.** `stream_stop` tells the node nobody is -watching, so ffmpeg is killed and its transcode slot released at once. There are -two slots; before this, leaving a video held one for the two-minute credit -timeout, which is what made the next video answer "server busy". +watching, so ffmpeg is killed and its slot released at once. Before this, leaving +a video held one for the two-minute credit timeout, which is what made the next +video answer "server busy". + +**How many people may watch at once.** A slot is now held for as long as someone +is watching, so it is a limit on simultaneous viewers rather than on bursts. The +default is 8. The operator sets it in `node.toml`: + +```toml +[node] +max_concurrent_streams = 8 +``` + +or with `MESHBAY_MAX_CONCURRENT_STREAMS` in the environment. One ffmpeg runs per +viewer, remuxing rather than encoding — little CPU, roughly 50 MB of memory, idle +most of the film — so raise it on a machine with memory to spare and lower it on +a Pi. Past the limit a viewer is told the server is busy. Zero, a negative number +or a non-number is refused with a warning naming the setting, because a limit of +zero is a node where no video ever plays and nothing says why. **The player drops what has been watched.** A SourceBuffer is not a file: browsers cap it and refuse the append that goes past, so anything more than a minute behind the playhead is evicted. A segment refused for want of room is retried rather than dropped — dropping it leaves a hole in the middle of the film and no error -anywhere. +anywhere. The retry is driven by a timer and by playback progress, never by the +arrival of the next segment: an append refused for want of room produces no +`updateend` and so grants no credit, and a pipeline whose only wakeup is the +segment it is waiting for cannot restart itself. --- |