| 1 | # Meet slice 4 — the record |
| 2 | |
| 3 | Branch `slice/4-chat`, `3420396..HEAD` (this commit; started on main |
| 4 | `3420396`). §6 below is the Codex fix wave, which landed after the six |
| 5 | tasks and moved the head six times more — the range stated here is the |
| 6 | whole branch, record included, rather than the six tasks alone. |
| 7 | Spec: `docs/superpowers/specs/2026-09-07-meet-design.md`, revision 9 |
| 8 | (§15.15 and §15.16 carry this slice's two wire/behaviour rulings). |
| 9 | |
| 10 | Slice 4 built chat and screen share: `hub.Fanout`, `/signal`'s `chat` |
| 11 | and `share` dispatch, the chat panel as estilo's first adoptable |
| 12 | component, `room.js`'s broadcast lane and screen-share transceiver, and |
| 13 | the browser gate's `chat`, `share` and `mutualshare` scenarios — six |
| 14 | tasks, each with its own review and fix rounds, kept in |
| 15 | `.superpowers/sdd/2026-09-10-meet-slice-4-chat/progress.md` and the |
| 16 | `task-N-report.md` files. Like slice 2's and slice 3's, that ledger is |
| 17 | gitignored and does not survive the worktree; this file is its |
| 18 | substance. |
| 19 | |
| 20 | --- |
| 21 | |
| 22 | ## 1. Rulings and decisions |
| 23 | |
| 24 | From the ledger's pre-flight scan and the tasks that followed it: |
| 25 | |
| 26 | - **The fan-out excludes the sender; the panel echoes locally on |
| 27 | acceptance.** **Ruling.** Cost if wrong: a duplicate line in the |
| 28 | sender's own panel. Written into spec §15.15. |
| 29 | - **Chat above 2048 bytes is `413 too-large`; every other malformed |
| 30 | shape — an empty text, an extra key, a non-bool `on`, a `to` on |
| 31 | either type — is `400 bad-request`.** **Ruling.** Cost if wrong: |
| 32 | none observable beyond which sentence the client's status line |
| 33 | shows. Written into spec §15.15. |
| 34 | - **`share` carries only `{on}`; the tile's label is the sender's name |
| 35 | from the roster and the envelope, never a track label.** One sharer |
| 36 | at a time is not enforced — §5.4 already says so. Written into spec |
| 37 | §15.15. |
| 38 | - **The size and UTF-8 checks run on the raw wire bytes, capped at |
| 39 | 2048 + 32, not on the decoded field.** Found the hard way: Task 2's |
| 40 | first pass measured `len(*p.Text)` after decoding, which a |
| 41 | duplicated `"text"` key (JSON keeps only the last) or a run of |
| 42 | `\uXXXX` escapes (roughly a sixth of its raw size once decoded) both |
| 43 | defeat while the *raw* bytes — exactly what `Fanout` forwards |
| 44 | unread — stay large. Fixed in Task 2's fix round: `len(raw) > |
| 45 | maxChatBytes+32` before decoding, `utf8.Valid(raw)` on the same raw |
| 46 | bytes rather than `utf8.ValidString` on the decoded string (which a |
| 47 | scratch test proved is always valid, since `encoding/json` repairs |
| 48 | an invalid byte with U+FFFD on the way into a Go string — checking |
| 49 | the decoded value would let a genuinely invalid byte through while |
| 50 | reporting the payload clean). Written into spec §15.15. **Amended by |
| 51 | the fix wave (§6):** the raw cap and `utf8.Valid` still run first and |
| 52 | still judge the incoming bytes, but those bytes are no longer what |
| 53 | `Fanout` forwards — the payload is re-encoded from the value that was |
| 54 | validated, and §4.4's 2 KB is now counted on the escaped text, which |
| 55 | is what the wire actually carries and what the panel can count too. |
| 56 | - **The gate drives `getDisplayMedia` with |
| 57 | `--auto-accept-this-tab-capture` alone.** Settled by a throwaway |
| 58 | probe before a line of Task 5's scenario was written: the existing |
| 59 | `--use-fake-ui-for-media-stream` flags already accept display |
| 60 | capture in this Chromium over `http://127.0.0.1` (a `data:` URL is |
| 61 | not a secure context and fails for an unrelated reason), so the |
| 62 | brief's fallback flag, |
| 63 | `--auto-select-desktop-capture-source="Entire screen"`, was never |
| 64 | needed. Kept anyway, belt and braces, because it is the documented |
| 65 | flag for the job and costs nothing. |
| 66 | - **The polite side of a negotiation waits 300 ms before offering, |
| 67 | once per negotiation (`POLITE_YIELD`).** Found in Task 4's second fix |
| 68 | round, after the prescribed fix (releasing an unassociated share |
| 69 | transceiver before a rollback) was measured twice and shown not to |
| 70 | touch the actual cause. Chromium rebuilds every send and receive |
| 71 | stream on a connection — not only the m-line a rollback removes — |
| 72 | when it rolls back an offer that added an m-line, which is exactly |
| 73 | what two peers sharing a screen at the same instant does to the |
| 74 | polite side; the camera's sender comes back on the same SSRC having |
| 75 | encoded nothing, ever. An offer answered from `stable` needs no |
| 76 | rollback, which is what the yield buys. Measured at each step: yield |
| 77 | on the share path alone, 1 failure in 3; yield on every negotiation, |
| 78 | 1 failure in 4; bounded to once per negotiation, 0 in 6, then 0 in 8 |
| 79 | more, then 30/30 twice in the full gate. Written into spec §15.16. |
| 80 | **Amended by the fix wave (§6):** "once per negotiation" was really |
| 81 | once per connection until an offer of this side's own went out, which |
| 82 | an earlier glare could spend for good. It is now a generation the |
| 83 | yield checks on waking, one attempt in flight at a time, and an |
| 84 | allowance of two handed back by any exchange this side answers — and |
| 85 | a camera that was rebuilt anyway is now detected and repaired. |
| 86 | - **`regainShare` and `shareTxLost`, added in Task 4's first fix round |
| 87 | to put a screen back after a rollback, were both no-ops where they |
| 88 | stood — found and removed in the second round.** `regainShare` called |
| 89 | `addShareTo`, which the same round had just taught to refuse |
| 90 | anything but a stable connection, and `onOffer` at that point is |
| 91 | `have-remote-offer`; it could never add anything. `shareTxLost` asked |
| 92 | whether the transceiver was stopped or had lost the local track, |
| 93 | which a rollback does neither — it un-associates a transceiver |
| 94 | `addTransceiver` created, and the display track stays on its sender. |
| 95 | The test that "discriminated" for the first round only proved a |
| 96 | stub was *called* in the right order, never that calling it did |
| 97 | anything. Not written into the spec: an implementation dead end, not |
| 98 | a decision the spec has an opinion about — recorded here so the |
| 99 | false path is not walked twice. |
| 100 | - **`classifyTrack`'s four-signal order** — the receiving transceiver's |
| 101 | own direction, then the camera's mid, then the camera's stream id, |
| 102 | then the peer's other-media stream id, and only then a tiebreaker — |
| 103 | is an implementation decision the spec has no opinion about (§5.4 |
| 104 | says a peer's screen becomes a second tile; it does not say how a |
| 105 | mesh peer tells a screen from a camera on the wire). Found necessary |
| 106 | in Task 4's first fix round once the reviewer showed the blind spot |
| 107 | the first draft's report had wrongly called unreachable: a peer with |
| 108 | no camera (the audio-only rung of `startLocal`'s fallback ladder) |
| 109 | really can have its screen be the first video track its peers ever |
| 110 | see. |
| 111 | - **The one hole the classifier still has is documented, not closed.** |
| 112 | A peer with no camera *and* no microphone has sent nothing before |
| 113 | its screen — no camera mid, no camera stream, no peer stream — and |
| 114 | if the receiving tab also has no camera, direction says nothing |
| 115 | either. Classification then rests entirely on the `share` message, |
| 116 | which §4.3 delivers or reports as failed. Decided as acceptable |
| 117 | rather than closed: see §3 below. |
| 118 | - **`required` is dropped from the chat textarea rather than routing |
| 119 | Enter through `form.requestSubmit()`.** Task 3's fix round: this |
| 120 | form never reaches a server, so `required` buys exactly one thing — |
| 121 | the browser's own validation-bubble sentence on an empty click of |
| 122 | Send, worded and toned by the browser rather than this app's copy |
| 123 | list, and untranslatable by it. Both submit paths (Send, Enter) now |
| 124 | return silently on an empty or whitespace-only box, which agrees |
| 125 | with every other product's chat box and adds no string at all. |
| 126 | |
| 127 | --- |
| 128 | |
| 129 | ## 2. Per-task review dispositions |
| 130 | |
| 131 | | Task | Built | Review | Fix rounds | Final | |
| 132 | |---|---|---|---|---| |
| 133 | | 1 — `hub.Fanout` | `Fanout`, `ErrNotFanout`; three tests covering delivery-and-exclusion, sender authority, and a standing loss mid-fanout | Needs fixes, tests only: the type guard was proven against one targeted type rather than as an allowlist; the envelope-rebuild test forged fields that were already zero rather than a fully forged input; §6's "never replayed" was proven only for an attached recipient, never through a detach-and-resume | Round 1 (`fa8b40d`): all three fixed and mutation-verified — a denylist-shaped guard, a patch-in-place bug, and a queue-on-detach bug were each written, run against the new tests, and shown to fail exactly the test meant to catch it | Complete. | |
| 134 | | 2 — `/signal` dispatches `chat`/`share` | `validateFanout`, `fanoutError`, `maxChatBytes`, the dispatch arm; three new tests plus the captured-log test extended to an accepted and an oversize chat | Needs fixes: HIGH — the byte cap and UTF-8 check ran on decoded fields, not the raw bytes `Fanout` forwards, letting a duplicated key or an escape run through oversized; MEDIUM — the oversize refusal's log line was never proven text-free; LOW — the waiting-guest silence test had no positive control; LOW — refusal bodies (not just status codes) untested; LOW — a doc-comment gap on `signalJSON`'s normalisation | Round 1 (`012fb78`): all five fixed. The HIGH fix reverted and re-tested against the pre-fix code: four of five new subtests failed with 200 instead of 413/400, confirming the bug was real | Complete. | |
| 135 | | 3 — the chat panel | `templates/partials/chat.html`, `static/chat.js`/`chat.css`, `render.go`'s partial parsing, `mount(root, {name, send}) → {append, setEnabled, clear}` | Needs fixes, small: no test that an app partial cannot shadow a vendored `ui` partial name; `aria-describedby` unasserted; the echo fired on any truthy `send` result, not strictly `true`; an edited draft did not clear "Could not send"; the log did not compensate scroll position when trimming for a reader scrolled up; script load order (chat.js before room.js) unasserted; inconsistent empty-send behaviour between Enter and the button; no `title` attribute on the timestamp; `MAX_LINES`/`MAX_BYTES` retyped rather than pinned from source; no `mount()` test against a fake DOM | Round 1 (`43d381c`): all nine items fixed; `chat.test.mjs` grew from 11 to 28 tests, most driven by constants read out of the shipped source rather than retyped. `room.html` carried no diff this round, so `make browser` was not re-run, per the coordinator's own condition | Complete. | |
| 136 | | 4 — `room.js`: broadcast lane, screen share | The broadcast lane (`chat`), one `sendonly` transceiver per connection for `share`, `classifyTrack`, the share tile, the Share button | Needs fixes: MEDIUM — a second click while the `getDisplayMedia` picker was open took an un-stopped second capture; MEDIUM — a broadcast message queued during a 404 recovery that ended the session was never answered; MEDIUM — the classifier's blind spot was reachable through the audio-only fallback rung, not only theoretical; LOW-MEDIUM — mutual sharing could collide on m-line matching; LOW — a no-op mount (missing DOM hooks) still un-hid the panel; LOW — the `getStats` frame fallback could credit the wrong tile with two video streams; LOW — a 404 with the thirty-second budget already spent left no time to diagnose | Round 1 (`a7c91d3`): nine of ten items fixed outright; the mutual-share item shipped `regainShare`/`shareTxLost`, later shown to be no-ops (see §1). Re-review after round 1: nine of ten closed, item 4 (mutual sharing) not — the two functions could not do what the report claimed, the stable guard on `addShareTo` was untested, and the report overclaimed a discriminating test that only proved a stub was called. | Round 2 (`8797ea9`): root cause found (Chromium rebuilds every stream on a rollback of an offer that added an m-line); fix is `POLITE_YIELD`, 300 ms, once per negotiation; `regainShare`/`shareTxLost` removed; `negotiate()` extracted as a named, catchable function; 16 new node tests. Re-review after round 2: **Clean** — eight items carried to the fix wave rather than fixed on this branch; see §3. | |
| 137 | | 5 — the browser gate: `chat`, `share` | The two scenarios; `state().chat`/`sharing`/`shares`; `diagnose()`'s per-connection reading; the `--auto-accept-this-tab-capture` launch flag | Needs fixes, minor: the guest-silence checks needed a positive control (guarded on `counted`); no-echo and no-duplicate needed pinning with exact counts, not `>=`; both members named "paul" made a name assertion untestable; `shareCaption` untested outside the browser; `chatLog` read an id rather than the data-attribute contract; `diagnose()` said nothing about the media itself (inbound-rtp, outbound-rtp, `displaySurface`); not every run's log was kept; the reported check tallies were wrong | Round 1 (`a11ad9a`): all items fixed; member B renamed to `bea@rslw.com`; `shareCaption` lifted into `room-share.test.mjs`; `diagnose()` gained `inbound`/`outbound`/`displaySurface`, later also mids and `currentDirection` on transceiver strings. The round also added `mutualshare`, written to decide the mutual-sharing question rather than assume it — it found the real defect and was shipped **skipped**, with the exact failure and every check that still passed recorded | Un-skipped as part of Task 4's second fix round (`8797ea9`), once the client-side cause was fixed; no assertion in the scenario was touched. Complete; the gate is 30 scenarios. | |
| 138 | | 6 — this record and spec §15.15/§15.16 | Documentation only | — | — | This commit. | |
| 139 | |
| 140 | --- |
| 141 | |
| 142 | ## 3. Carried items, and where they go |
| 143 | |
| 144 | - **The no-media sharer hole in the classifier is documented, not |
| 145 | closed.** A peer that joined with no camera and no microphone has |
| 146 | sent nothing before its screen — no camera mid, no camera stream, no |
| 147 | peer stream to compare against — and if the receiving tab also has |
| 148 | no camera, the transceiver-direction signal says nothing either. |
| 149 | Classification then rests entirely on the `share` message. |
| 150 | **Destination: accepted as a known limitation**, backstopped by |
| 151 | §4.3's own delivery guarantee on `share` — not a bug this slice owes |
| 152 | a fix for, and named in `classifyTrack`'s own comment so the next |
| 153 | reader finds it there first. |
| 154 | - **`dropShare` and `shareCaption`, now pinned.** Task 5's first report |
| 155 | flagged both as untested by anything but the browser gate, since |
| 156 | that task cannot mutate `room.js` while a reviewer reads it |
| 157 | concurrently. `shareCaption` was lifted into `room-share.test.mjs` in |
| 158 | Task 5's fix round (the caption text, that it carries no ASCII |
| 159 | apostrophe, and that a name with a space goes in whole); `dropShare` |
| 160 | was pinned in Task 4's second fix round (clears the label, the media |
| 161 | and the mid; twice is the same as once; redraws the grid once, not |
| 162 | twice; a screen announced but never arrived is still dropped). |
| 163 | **Destination: done**, both closed on the branch. |
| 164 | - **`diagnose()` reading inbound-rtp.** Carried from Task 5's first |
| 165 | report (mutation testing against the client was impossible, and its |
| 166 | `diagnose()` said nothing about the media itself) into Task 5's fix |
| 167 | round: per connection, `inbound` (ssrc, `framesReceived`, |
| 168 | `framesDecoded`, `framesDropped`, `pliCount`, `freezeCount`, the |
| 169 | track's own `muted`) and `displaySurface`, then `outbound` and |
| 170 | mid-plus-direction transceiver strings added while diagnosing |
| 171 | `mutualshare` itself. **Destination: done** — this reading is exactly |
| 172 | what let Task 4's second fix round read the root cause off two |
| 173 | connections' own stats rather than guess at it. |
| 174 | - **The 300 ms polite-yield residual.** Task 4's second fix round |
| 175 | closed the mutual-share defect with a 300 ms wait, not a proof: a |
| 176 | pair whose signalling round trip exceeds 300 ms can still collide, |
| 177 | and on that pair the rollback still costs the polite side its |
| 178 | camera. Nothing detects or repairs a camera that has already been |
| 179 | rebuilt. **Destination: watched, not fixed further this slice** — |
| 180 | recorded in spec §15.16 as the first thing to check if a real |
| 181 | deployment ever reports a camera stopping after two people share at |
| 182 | once, and the second thing to check is whether Chromium has fixed |
| 183 | the rollback itself. |
| 184 | - **The `make browser` flake rate and its logs.** Across this slice's |
| 185 | runs: Task 4's first pass, 26/27 then 27/27; Task 4's first fix |
| 186 | round, 28/29 then 29/29; Task 5's first pass, three of four runs each |
| 187 | lost one *different* scenario (`senddeadline`, `share`, `icefail`) to |
| 188 | the stall before a clean 29/29; Task 5's fix round exposed the real |
| 189 | `mutualshare` defect (not a flake — 3 failures in 5, always the same |
| 190 | camera signature) and shipped it skipped, then a clean 29/1-skip; |
| 191 | Task 4's second fix round ran clean, 30/30 twice, 447 s each. Roughly |
| 192 | half of this slice's `make browser` runs lost exactly one scenario to |
| 193 | the environment's own media stall, and every stalled scenario passed |
| 194 | alone immediately afterward. **Destination: unchanged from slice 3** |
| 195 | — a red `make browser` run is worth a second run before it is worth |
| 196 | debugging. Every run's log this slice was kept under `$TMPDIR` |
| 197 | (`task5-browser-run3.log` … `task5-fix1-three-scenarios.log`, |
| 198 | `task5-mutualshare-try*.log`), not committed; §5 below gives the |
| 199 | stall's signature. |
| 200 | - **What the Task 4 re-review named, landing clean itself.** The |
| 201 | re-review of fix round 2 (`8797ea9`) returned **Clean** — nothing on |
| 202 | the branch itself needs another round — and carried eight items to |
| 203 | the fix wave rather than to this branch, each to be measured against |
| 204 | `mutualshare` when it is picked up: (1) `entry.yielded` is cleared |
| 205 | only by this side's own successful offer, so a lost yield whose |
| 206 | remote exchange happens to satisfy the same need leaves the polite |
| 207 | side unprotected next time — clear it on any stable return, not only |
| 208 | this side's own, bounded by a counter so it still cannot become a |
| 209 | habit; (2) a detect-and-repair path for a camera already rebuilt — |
| 210 | outbound-rtp `framesEncoded` static on the camera's mid while the |
| 211 | track is live and the m-line is sending → `replaceTrack(null)` then |
| 212 | `replaceTrack(track)`, followed by `requestReset`; (3) evaluate |
| 213 | reserving the screen's m-line at `createPC` itself (an inactive |
| 214 | transceiver from the start; starting a share becomes a |
| 215 | `replaceTrack` plus a direction change) so that starting a share |
| 216 | never renegotiates a new m-line into existence at all; (4) settle |
| 217 | whether the true trigger is "a rollback that added an m-line" |
| 218 | specifically or "any rollback once media is already flowing" — two |
| 219 | simultaneous ICE restarts with no share involved is the case that |
| 220 | would tell them apart; (5) file the defect against Chromium itself, |
| 221 | with the stats trace this slice captured; (6) widen the ordinary |
| 222 | negotiation scenarios to assert frames keep moving afterwards, not |
| 223 | only at connection; (7) two peers *stopping* a share at the same |
| 224 | instant is exactly as ungated as two starting one, and untested; |
| 225 | (8) pin `POLITE_YIELD` from the shipped source rather than retype |
| 226 | it, and correct one claim already written down: the report's "the |
| 227 | yield ends early" reads as a race that resolves on its own, when the |
| 228 | code is an unconditional sleep gated by a state check taken after it |
| 229 | — accurate for what ships, but the sentence describing it overstates |
| 230 | how the two interleave. **Destination: the fix wave**, alongside a |
| 231 | reviewer's own draft of a further spec note for §5.1 on the yield, |
| 232 | which this record does not reproduce — read it in the ledger |
| 233 | (`progress.md`) alongside the whole-branch review's own findings and |
| 234 | its handoffs for the Codex round and for slices 5 and 6. |
| 235 | - **Codex round 1, launched on this branch after this ledger entry.** |
| 236 | `codex exec` was launched against `8797ea9` as this record was being |
| 237 | written; nothing from it had landed by the time this commit was |
| 238 | made. **Destination:** its own entry in this file (or the next |
| 239 | slice's), the way slice 3's Codex rounds were appended to that |
| 240 | slice's own record after the fact — not fabricated here ahead of the |
| 241 | result. |
| 242 | |
| 243 | --- |
| 244 | |
| 245 | ## 4. Deploying this slice |
| 246 | |
| 247 | **No schema change.** Nothing in this slice touches `models.go`, |
| 248 | `migrations.go` or `migrations/` — chat and share are both delivered |
| 249 | messages, never rows (`git diff 3420396..8797ea9 -- internal/calls/migrations |
| 250 | internal/calls/models.go` is empty). Deploying this slice is an |
| 251 | ordinary rolling deploy with no migration step to sequence around it. |
| 252 | |
| 253 | **Every deploy still drops every live call**, exactly as slice 2's |
| 254 | record already states (spec §15.11 — the hub is per process and dies |
| 255 | with it) and slice 3's did not change: this slice adds nothing to the |
| 256 | hub's own durability. A chat message in flight at deploy time is not |
| 257 | queued or recovered; it is simply not delivered, the same as an offer |
| 258 | would be. Members rejoin automatically; guests go back to waiting. |
| 259 | |
| 260 | **Chat is not stored**, on this instance or any other: §6's "not |
| 261 | stored, not replayed" holds all the way down to `Fanout`, which |
| 262 | delivers to an attached subscriber or not at all. There is nothing in |
| 263 | a database backup, a log line, or a crash dump that a deploy could |
| 264 | lose that chat did not already lack the moment it was sent — the |
| 265 | property a deploy dropping a call would otherwise threaten does not |
| 266 | apply here, because there was never anything held to drop. |
| 267 | |
| 268 | No test on this branch touches a real deployment window; the gates |
| 269 | below prove the wire and the browser behaviour against fixtures and a |
| 270 | local pair of Chromium contexts, the same shape slice 2 and slice 3 |
| 271 | used. |
| 272 | |
| 273 | --- |
| 274 | |
| 275 | ## 5. The gates, and how to run them |
| 276 | |
| 277 | The tallies below are the six tasks'. The fix wave's own are in §6 and |
| 278 | in `codex-wave-report.md`: 194 node tests; 31 browser scenarios, 30 |
| 279 | passing and `laterolls` skipped, run twice clean on the final head at |
| 280 | 457 s and 455 s with no scenario lost to the environment stall on |
| 281 | either; and a `make browser` timeout raised to 20 minutes, which the |
| 282 | thirty-first scenario and four more media windows made necessary. |
| 283 | |
| 284 | | Gate | Command | Notes | |
| 285 | |---|---|---| |
| 286 | | The one gate | `CGO_ENABLED=0 make ci` | vet, gofmt, tests, migration check — unchanged from slice 3. | |
| 287 | | Race | `CGO_ENABLED=1 go test -race ./internal/...` | Run through this slice's fix rounds; green throughout. | |
| 288 | | Node | `node --test test/browser/{negotiation,chat,room-share,room-lane}.test.mjs` | 164 tests by the branch head, 0 failing; every assertion added this slice was watched failing against a deliberately broken source first, except Task 5's — a reviewer reads `room.js` concurrently through that task, so its evidence is the runs themselves: three name assertions failed against the wrong expected value before passing against the right one, and a media stall failed exactly the frame-counting checks and none of the message-driven ones. | |
| 289 | | Browser | `make browser` | 30 scenarios, three of them (`chat`, `share`, `mutualshare`) new this slice. **Needs the sandbox off** — inside the Claude Code bash sandbox Chromium gathers zero ICE candidates and `getDisplayMedia` produces a source with nothing crossing the connection, as slice 2's and slice 3's records already note. `getDisplayMedia` in headless Chromium additionally needs `--auto-accept-this-tab-capture`; `--use-fake-ui-for-media-stream` covers `getUserMedia` only. **The run takes about 7.5 minutes** — 447 s measured twice, clean, in Task 4's second fix round, against 423-424 s for the 29-30 scenarios before it; the two new message-driven scenarios (`chat`, `share`) together cost only about 5.4 s of that, since both are written entirely on `until`/`stayed` deadlines and neither sleeps. | |
| 290 | |
| 291 | **The one-scenario media stall, and its signature.** Roughly half of |
| 292 | this slice's `make browser` runs lost exactly one scenario — a |
| 293 | different one each time, never the same twice in a row — to the |
| 294 | environment's own known symptom, unrelated to anything this slice |
| 295 | built (the Makefile's own comment above `browser:` names it, and |
| 296 | slice 3's record already caught it against the ICE scenarios). The |
| 297 | signature, quoted from one losing run: `connection: "connected"`, |
| 298 | `ice: "connected"`, both tracks reporting `"live"`, `transceivers: |
| 299 | ["sendrecv:audio","sendrecv:video"]` — and `frames: {}`. The transport |
| 300 | is up, negotiation is done, and no pixels ever move; every stalled |
| 301 | scenario this slice hit passed alone, immediately, on a second try. |
| 302 | This is not the stale-roster defect slice 3 fixed (whose signature was |
| 303 | `peers: []`) and it is not the `mutualshare` defect this slice fixed |
| 304 | (whose signature was two live video streams with one encoding zero |
| 305 | frames) — it is the environment stall both of those records already |
| 306 | name, reached by a third scenario each time. |
| 307 | |
| 308 | `make browser` is still deliberately outside `make ci`, for the reason |
| 309 | slice 2's record already gives. As with slice 3's residual flake, a red |
| 310 | run here is worth a second run before it is worth debugging. |
| 311 | |
| 312 | --- |
| 313 | |
| 314 | ## 6. Codex round 1, and the fix wave |
| 315 | |
| 316 | `codex exec` was launched against `8797ea9` while §2's last task was |
| 317 | being written, and the whole-branch review of `26c022b` ran alongside |
| 318 | it. Between them they found eleven things; six were Codex's (S4-1 to |
| 319 | S4-6), five were the review's (W4-1, W4-3 to W4-7). All eleven are |
| 320 | closed on this branch, in six commits, each with its new tests seen |
| 321 | failing first. |
| 322 | |
| 323 | | Finding | Disposition | Commit | Test | |
| 324 | |---|---|---|---| |
| 325 | | **S4-1 (P1)** — `encoding/json` matches object keys to struct fields case-insensitively, and `DisallowUnknownFields` does not change that: `{"text":"<2049 a>","Text":"x"}` is 2071 raw bytes, decodes to the *last* match — a one-byte text — and was forwarded raw, so every recipient read the lowercase key and got 2049 bytes through a 2048-byte cap | Fixed. Keys are judged byte-exactly by `exactKey`, a token walk that insists on one member spelled exactly and appearing exactly once — deliberately not a `map[string]json.RawMessage`, which collapses a duplicate and cannot tell `{"text":"a","text":"b"}` from `{"text":"b"}` | `4bbadf3` | `TestChatKeysAreExactCaseSensitiveAndUnique`, ten cases; six of them returned 200 before the fix | |
| 326 | | **S4-2 (P1)** — `writeEvent`'s `json.Marshal` HTML-escapes `<`, `>` and `&` *inside* a `json.RawMessage`, so a validated 2059-byte payload of literal `<` left the process at 12,381 bytes — the invariant `validateFanout`'s whole doc comment rests on, broken on the way out | Fixed at both ends: `validateFanout` returns the payload re-encoded from the value it validated (`SetEscapeHTML(false)`), and `writeEvent` uses an encoder with the same setting. SSE data is not HTML, and the property the escaping never bought — that a payload cannot end its own frame — comes from JSON's own `\n` escape | `4bbadf3` | `TestChatDoesNotExpandOnTheWire`, `TestChatDeliversTheCanonicalValidatedPayload`, `TestChatCannotForgeASecondSSEFrame` | |
| 327 | | **S4-3 (P2)** — the textarea stays editable during a send, so `input.value = ''` on acceptance erased a draft typed while the earlier message was in flight | Fixed with a draft revision, bumped by every edit, captured by the submit, checked on acceptance. Clearing the box in code is not an edit, so an untouched box still empties | `a3a722f` | three tests in `chat.test.mjs`; the first failed `'' !== 'second draft'` | |
| 328 | | **S4-4 (P2)** — a pending polite yield survived the negotiation it belonged to (a state check cannot tell uninterrupted stability from an exchange that came and went), and `negotiate` did not stop a second invocation while the first slept | Fixed with three per-connection parts: a negotiation generation the yield captures and re-checks, one attempt in flight at a time, and `yields` — a counter in place of the boolean, handed back one at a time by `answeredExchange`. That last also closes the Task 4 re-review's item 1 and **W4-4**: the allowance is no longer a budget one earlier glare can spend for good | `41deb52` | five tests in `room-lane.test.mjs`, three of them mutation-verified against the shipped code | |
| 329 | | **S4-5 (P2)** — no recovery for a camera whose encoder was already rebuilt; the trigger survives POLITE_YIELD through a slow round trip, a 429 retry or a slow offer | Fixed. `pollFrames` runs in every browser now, not only where rVFC is missing, and reads outbound-rtp too: two polls with no new `framesEncoded` on a sending m-line with a live track is `replaceTrack(null)`/`replaceTrack(track)`; two more is `requestReset`; after that it stops asking. Closes the re-review's item 2 | `41deb52` | eight tests in `room-lane.test.mjs` over a fake stats sequence, mutation-verified | |
| 330 | | **S4-6 (P2)** — `mutualshare`'s later rounds read a counter `dropShare` deliberately preserves, so they could pass on round one's frames; every camera check compared against a pre-gesture snapshot, which frames delivered before a rollback stall satisfy | Fixed with `frameGrowth`: read the counter, *then* wait for it to rise. Every screen and camera assertion in `share` and `mutualshare` is rebuilt on it, after each transition settles and again through the observation window | `d8316e8` | the gate itself — and it earned its keep immediately (below) | |
| 331 | | **W4-1 (MEDIUM)** — the panel capped 2048 UTF-8 bytes of the text, the server 2080 raw JSON bytes of the payload; between them sat a draft the panel called legal and the server refused, needing only 23 quotes or newlines. **W4-2** falls with it: the "413: show beside the composer, never retry" line in `task-4-handoffs.md` was unimplementable through a boolean `send`, and is now unreachable | Fixed. One rule on both sides: the JSON-escaped length of the text, ≤ 2048 bytes — exactly what the canonical payload puts on the wire. The raw cap stays as the outer guard | `6f1be79` | `TestChatCapCountsTheEscapedText` (a quote, a newline and a backslash at and past the boundary) and `chat.test.mjs`'s counter test | |
| 332 | | **W4-3 (MEDIUM)** — `POLITE_YIELD` set to 0 left all 164 node tests green: the suite injected 250 of its own | Fixed. Every constant these tests assert against is read out of the source by `pin()`, and one test asserts the shipped values themselves — §15.16's 300 ms among them | `41deb52` | `the numbers these tests assert against are the ones room.js ships` | |
| 333 | | **W4-5 (LOW)** — the constant's comment claimed "the yield ends early because the remote offer has arrived"; the sleep is unconditional and the check is state-based | Fixed: the comment says what the code does | `41deb52` | — | |
| 334 | | **W4-6 (LOW)** — this record named `3420396..8797ea9`, one commit short of its own | Fixed at the top of this file | this commit | — | |
| 335 | | **W4-7 (LOW)** — §13 attached "no `chat` while waiting" to the `guest` scenario; it lives in `chat`, with a positive control | Fixed in the spec's own §13 sentence, which now also names the control | this commit | — | |
| 336 | |
| 337 | **The gate found a defect the node tests could not.** The first run of |
| 338 | S4-6's fresh-baseline checks failed `mutualshare` round 1: the polite |
| 339 | side's screen announced, captioned, and never arriving — an unassociated |
| 340 | `sendonly` transceiver three rounds later and one inbound stream where |
| 341 | there should have been two. S4-4's serializer had done it. The polite |
| 342 | side adds its screen, sleeps in its yield, and answers the other side's |
| 343 | offer while asleep; that exchange fires `negotiationneeded` for the |
| 344 | polite side's *own* screen, which the serializer dropped because an |
| 345 | attempt was in flight, and then the attempt woke, saw the generation |
| 346 | had moved, and abandoned its offer. Nobody was left to offer the |
| 347 | screen. Fixed in `f982bdf`: an ask that arrives mid-attempt is |
| 348 | remembered on the connection and an attempt that abandons its offer |
| 349 | goes round again for it, bounded by `NEGOTIATE_ROUNDS`. Two node tests, |
| 350 | the first mutation-verified. Worth recording as a shape, not just an |
| 351 | incident: **the counter-based checks the old gate could not make are |
| 352 | what made the client defect visible at all**, which is the same lesson |
| 353 | `mutualshare` itself taught in Task 5. |
| 354 | |
| 355 | **And then it found a second one, in the counter itself** (`3ecebb6`, |
| 356 | `2eadfd2`). `laterolls` reported a camera that had stopped arriving on |
| 357 | a tile that was playing perfectly — readyState 4, not paused, its clock |
| 358 | advancing, 453 frames decoded and rising. What had stopped was |
| 359 | `watchFrames`. Setting `srcObject` resets a media element and discards |
| 360 | its pending `requestVideoFrameCallback`, and the chain was keyed on a |
| 361 | `WeakSet` of elements, so a tile handed new media was refused a new |
| 362 | chain and its count stood still for the rest of the call — which every |
| 363 | frames assertion in the gate, and `state().frames` itself, reads as |
| 364 | dead media. Keying on the stream was not enough either, and the gate |
| 365 | said so on the next run: Chromium hands a rebuilt camera back inside |
| 366 | the *same* MediaStream on a new track. It is keyed on tile, stream and |
| 367 | track now, with a backstop for whatever else can drop a callback — a |
| 368 | chain that has counted nothing for two seconds while the element's own |
| 369 | clock moved on is re-armed, and `pollFrames` asks once a second, so a |
| 370 | lost callback costs a second rather than a call. Eight tests, two |
| 371 | mutation-verified. **The counter the whole browser gate stands on could |
| 372 | die quietly, and nothing but a real browser was ever going to say so.** |
| 373 | |
| 374 | **And then a third, which was not the counter at all** (`fee4eb4`). |
| 375 | With the counter repaired, `laterolls` still failed on the same tile — |
| 376 | and this time the count was telling the truth. The polite side's tile |
| 377 | for the other camera sat at `currentTime` 0.1 for the rest of the |
| 378 | scenario, `readyState` 4 and not paused, on a connection that was |
| 379 | stable, connected, and decoding four hundred inbound frames nobody |
| 380 | could see. The element was rendering a track that had ended. This is |
| 381 | the *receiving* half of the same rollback damage §15.16 describes: |
| 382 | Chromium rebuilds receive streams too, so the far side's camera arrives |
| 383 | again as a new track — sometimes with no stream of its own, sometimes |
| 384 | inside the very stream that still holds the old, ended one — and |
| 385 | `onRemoteTrack` added the new one and left the old one in place, where |
| 386 | a media element goes on rendering it. `adoptTrack` takes an ended track |
| 387 | of its kind out before putting the new one in, and `tile()` now follows |
| 388 | the stream's video track as well as the stream object. Four tests, |
| 389 | mutation-verified. |
| 390 | |
| 391 | Three defects, then, and the shape they share is worth naming: **every |
| 392 | one of them was invisible to the whole node suite and visible in the |
| 393 | first browser run that asked the right question.** The right question |
| 394 | in each case was "is this media moving *now*", asked from a baseline |
| 395 | taken now — which is exactly what S4-6 was about. |
| 396 | |
| 397 | **A thirty-first scenario, `laterolls`.** §15.16's residual said a pair |
| 398 | whose signalling round trip exceeds 300 ms still collides, and that |
| 399 | nothing detected or repaired the camera afterwards. Half of that is now |
| 400 | fixed (S4-5) and the other half is now *forced* rather than waited for: |
| 401 | `laterolls` holds one context's own `offer` POSTs for four times the |
| 402 | yield, so the collision is late by construction, and then asserts the |
| 403 | camera is encoding again inside the repair budget. It asserts the |
| 404 | recovery and never the failure — a Chromium that no longer rebuilds the |
| 405 | streams passes it exactly as a repaired one does — and records whether |
| 406 | the stall happened as a line rather than a check. |
| 407 | |
| 408 | **Said plainly: the delay is forced, the camera loss is not, and the |
| 409 | scenario ships skipped.** The delay is real — the scenario proves an |
| 410 | offer was held (`1 held`). The *encoder* loss §15.16 describes did not |
| 411 | reproduce: every run prints "the polite side's camera never stalled |
| 412 | through the late offer", so what the run exercises is the late |
| 413 | collision rather than the repair, and the repair itself is gated by the |
| 414 | node tests over a fake stats sequence. A scenario that could only pass |
| 415 | by breaking first would fail whenever the browser improves, which is |
| 416 | why it asserts recovery and never failure. |
| 417 | |
| 418 | What it does reproduce, deterministically, is one step further in than |
| 419 | anything this wave fixed: after the late collision the *receiving* |
| 420 | tile stops rendering. The connection is stable and connected, all four |
| 421 | transceivers are right, every track is live, both inbound video streams |
| 422 | decode (452 and 446 frames, unmuted, one PLI each), the far side's |
| 423 | outbound camera rises throughout, and the screen tile plays perfectly — |
| 424 | while the camera tile sits at `readyState` 4, not paused, |
| 425 | `currentTime` 1.5 s, for the thirty seconds the check waits. Media |
| 426 | arrives, the decoder decodes it, the element renders none of it. No |
| 427 | other scenario reproduces it, and `mutualshare` renegotiates the same |
| 428 | connection six times over with both cameras rendering throughout, which |
| 429 | puts it closer to the environment's own media stall (§5) than to |
| 430 | anything in `room.js`. **Destination: `laterolls` is in `skipped` with |
| 431 | the exact failure and the first two things to check** — whether the |
| 432 | receiving track for that m-line is a new track object that never |
| 433 | reached `onRemoteTrack`, and whether `startPlaying` needs to re-play an |
| 434 | element whose media was rebuilt under it — on the same terms |
| 435 | `mutualshare` was shipped skipped in Task 5. |
| 436 | |
| 437 | **What the fix wave did not take on.** The Task 4 re-review's items 3 |
| 438 | to 7 stand where §3 left them: reserving the screen m-line at |
| 439 | `createPC` (the fix that removes the trigger rather than racing it), |
| 440 | settling whether the trigger is "a rollback that added an m-line" or |
| 441 | "any rollback once media flows", filing the Chromium bug, widening the |
| 442 | ordinary negotiation scenarios to assert frames afterwards, and two |
| 443 | peers stopping at once. Item 3 is still the real fix and still wants |
| 444 | its own task; what changed is that the failure it prevents is now |
| 445 | detected and repaired when it happens, so it is an improvement rather |
| 446 | than a gap. |
| 447 | |
| 448 | **One ordering worth writing down**, from the whole-branch review's |
| 449 | §3: `leave()` and `terminal()` both set `state.stopped` *before* |
| 450 | calling `stopSharing()`, so the `share {on:false}` those paths trigger |
| 451 | short-circuits in `broadcast` and no POST goes out after the leave. |
| 452 | That is the right order, it is not obvious from reading `stopSharing` |
| 453 | alone, and no test would notice if a later edit inverted it. |
| 454 | |
| 455 | --- |
| 456 | |
| 457 | ## 7. Codex round 2 |
| 458 | |
| 459 | Judged at `55f5681`. S4-1, S4-2 and S4-3 closed; S4-4, S4-5 and S4-6 |
| 460 | half-closed, each with the half that was missing named; five new |
| 461 | findings. All of them are closed on this branch, and the receiving-side |
| 462 | failure §6 left standing is closed with them. |
| 463 | |
| 464 | | Finding | Disposition | Commit | Test | |
| 465 | |---|---|---|---| |
| 466 | | **S4-9 (P2)** — `adoptTrack` pruned only *ended* tracks, so a camera rebuilt on the known camera mid with its predecessor still `live` left the stream holding [old, new] and the tile showing the old one | Fixed. Each role — camera, screen — keeps a stream of this file's own; `adoptTrack` makes the arriving track *the* track of its kind in it, whatever the predecessor's readyState; `renderTiles` names the track each tile is meant to show and `tile()` binds to that, never to an array position | `560c9d2` | Four, all seen failing against the shipped source first; the same-mid replacement reproduced Codex's `cameraTracks: [old, new]`, `shownTrack: old` exactly | |
| 467 | | **S4-8 (P2)** — exhausting `NEGOTIATE_ROUNDS` cleared `renegotiate` in the `finally`, discarding an outstanding negotiation (waits 4, offers 0) | Fixed. The bound limits how long one attempt goes round, not whether outstanding work matters: the ask stays on the connection and `keepNegotiation` gives it the connection's next return to `stable`, or a timer where nothing else will announce one. One retry at a time; a connection that goes away keeps none | `5546a8d` | Three, two mutation-verified; the first reproduces Codex's trace and asserts the ask survives it | |
| 468 | | **S4-7 (P2)** — Go escapes U+2028/U+2029 unconditionally, `JSON.stringify` leaves them literal, so 2045 a's and one U+2028 counted 2048 in the composer and 2051 at the server | Fixed. `chatBytes` escapes them the way Go does. The pattern is written as escapes because both characters are line terminators in JavaScript source — which is most of why the gap existed | `bb97a15` | Client test seen failing first (6 !== 3); five matching server cases, sent as themselves the way a paste arrives | |
| 469 | | **S4-10 (P3)** — a decreasing `framesEncoded` counter read as "encoding recovered" and replenished the repair allowance | Fixed. The baseline moves either way, because the old number means nothing after a restart; the budget is forgiven only by frames that actually arrived | `716e0f6` | Two; the first seen failing first, the second holds the other side | |
| 470 | | **S4-11 (P3)** — every case in the canonicalisation regression sent an already-canonical payload | Fixed. Two cases now send real JSON escapes and require the decoded letters on the wire — verified by putting the raw bytes back, where those two fail and nothing else does — and `share` has output coverage at last | `716e0f6` | Seven cases, two of them discriminating | |
| 471 | | **The S4-6 gap** — `frameGrowth` returns on the first increase, so its timeout is a deadline and not an observation | Fixed. `sustained` requires growth in two successive intervals, and `checkMedia` asks it of everything a side should be receiving at once. Applied after every settled transition in `share` and `mutualshare`, including after the simultaneous stops and after the asymmetric stop's own five-second wait, which every media check used to precede | `00029e8` | The scenarios themselves; the round-two suppression mutation stays the gate on the baseline half | |
| 472 | |
| 473 | **§15.16 no longer runs three facts together.** It now records |
| 474 | separately: the historical encoder-loss observation from Task 4 (which |
| 475 | stands as evidence of what a rollback costs, and is not the diagnosis |
| 476 | of anything still failing), what the yield does about it (prevents the |
| 477 | rollback; a duration, not a guarantee), and what is repaired when it |
| 478 | happens anyway (two bounded watches, both ending in a reset). The |
| 479 | sentence saying there is no detection or repair, and the one promising |
| 480 | recovery three paragraphs later, are both gone. |
| 481 | |
| 482 | ### The tile that would not paint — what the identities showed |
| 483 | |
| 484 | S4-9 was a real defect. It was not this one. With the tile bound to the |
| 485 | role's own track, `laterolls` failed in the same place, and the |
| 486 | identity reading added for the purpose — `meet.media()`, which compares |
| 487 | the objects inside the page because identity does not survive an |
| 488 | `evaluate()` — settled what the failure is not: |
| 489 | |
| 490 | ``` |
| 491 | wanted ce46729b-…aef7 |
| 492 | bound ce46729b-…aef7 |
| 493 | element ["ce46729b-…aef7:live"] |
| 494 | receiver mid 1, sendrecv, live, muted:false, isCamera:true |
| 495 | agrees true currentTime 1.452 |
| 496 | ``` |
| 497 | |
| 498 | Three answers, one object, on both sides. The element held exactly the |
| 499 | track the connection was receiving the camera on, live and unmuted, and |
| 500 | the decoder went on delivering frames it never painted. Everything |
| 501 | upstream of the element was right and the picture was stopped |
| 502 | downstream of it — which is the branch Codex named for exactly this |
| 503 | result. |
| 504 | |
| 505 | So a second watch, on the same one-second `getStats` walk and in the |
| 506 | same shape as the encoder one: `framesDecoded` per tile, and three |
| 507 | polls of "the decoder is delivering, the track is playable, the clock |
| 508 | has not moved" is a rendering stall. The repair is the stream taken off |
| 509 | the element and given back, and `play()` asked for again; two of those, |
| 510 | then a reset, then it stops asking. A tile receiving nothing is idle |
| 511 | rather than stalled, which is what reading the decode count alongside |
| 512 | the clock is for. |
| 513 | |
| 514 | Measured either side of it, from the scenario's own 500 ms series of |
| 515 | rendered-frame totals: `[25,26,26,26,26,26]` — stopped, and stopped for |
| 516 | the twenty seconds the check waited — before, and `[25,25,25,25,25,26]` |
| 517 | after: frozen for the three polls it takes to notice, then moving |
| 518 | again. **`laterolls` is out of `skipped` and passing, and the |
| 519 | deterministic camera failure §6 recorded as a deploy blocker is |
| 520 | closed.** |
| 521 | |
| 522 | `laterolls` also proves its own premise now, which it did not: it |
| 523 | counts remote offers applied over this side's own outstanding offer — |
| 524 | the rollback, seen from outside — and fails if there was none to |
| 525 | recover from. One was seen on every run. |
| 526 | |
| 527 | ### Three red runs, and what each of them was right about |
| 528 | |
| 529 | The render watch did not land clean, and the gate was right every time. |
| 530 | |
| 531 | 1. **`playfallback`, twice.** §5 offers a Play control when the |
| 532 | browser's autoplay policy refuses a remote video, and it stopped |
| 533 | appearing. Guarding the watch on `paused` was not enough: the tile |
| 534 | binds more than once now — audio arrives, then the camera — and the |
| 535 | second bind called `play()` again behind the refusal and hid a |
| 536 | question nobody had answered. `shouldPlay` is the rule that came out |
| 537 | of it, and it is a product rule rather than a watch rule: while the |
| 538 | Play control is showing, the gesture is the person's to give. |
| 539 | 2. **`laterolls`, on a stutter.** The tile painted one frame every few |
| 540 | seconds — `[22,23,23,23,23,25]` across three seconds — and any |
| 541 | advance at all counted as health, so the watch's budget reset for |
| 542 | ever and the connection never earned the reset that would rebuild |
| 543 | it. A second of video is a second of clock; a frame is thirty |
| 544 | milliseconds. `RENDER_MOVED` is 0.25 s now, a clock that goes |
| 545 | backwards is the watch's own repair landing rather than a stall to |
| 546 | judge, and the replay hands the element a *new* `MediaStream` over |
| 547 | the same tracks rather than the object it was already holding. |
| 548 | |
| 549 | Measured on the run that had been failing: `[25,25,25,25,25,25]` — |
| 550 | stopped dead — and then 21 → 26 across two successive intervals once |
| 551 | the watch escalated. **The stall is the browser's; what is claimed here |
| 552 | is a bounded recovery from it, and nothing more.** |
| 553 | |
| 554 | **The gate on the final head:** `make browser` twice, **31 of 31 both |
| 555 | times**, 484 s and 485 s, with `laterolls` in it and `skipped` empty — |
| 556 | the first time this branch's gate has carried no skipped scenario since |
| 557 | Task 5 put `mutualshare` in it. 206 node tests, `make ci`, `-race` and |
| 558 | `go vet -tags browser` green. |
| 559 | |
| 560 | --- |
| 561 | |
| 562 | ## 8. Codex round 3 |
| 563 | |
| 564 | Judged at `a4d1ec1`. S4-7, S4-9, S4-10 and S4-11 closed; S4-8 and S4-6 |
| 565 | half-closed, each with the half that was missing named as a new |
| 566 | finding; three more beside them. All five are closed on this branch. |
| 567 | |
| 568 | | Finding | Disposition | Commit | Test | |
| 569 | |---|---|---|---| |
| 570 | | **S4-12 (P2)** — the retry timer spent the ask it was meant to protect: it fired mid-exchange, `negotiate` cleared the flag, slept, woke to a connection still not stable and walked away (pending false, timers none, offers none) | Fixed. An attempt that settles nothing owes the ask back: `politeYield` answers with the reason rather than a bare no, a `busy` wake and an offer that never went out both hand `renegotiate` back, and the timer defers instead of consuming, bounded by `NEGOTIATE_DEFERS` | `ad13608` | Five, both halves mutation-verified; the harness runs the timer callback now, which is what the round-2 tests never did | |
| 571 | | **S4-13 (P2)** — the render repair handed the element a fresh stream and left the role writing to the old one, so a microphone arriving or being replaced afterwards went where nothing was showing: authoritative [video, new mic], element [video, old mic] | Fixed. `freshStream` puts the new object back where the role keeps it, which restores the rule the file has about tiles: the element holds the role's stream, and every track adopted later reaches it | `5a0be1e` | Three replay tests, mutation-verified against the element-only version | |
| 572 | | **S4-15 (P2)** — a screen replaced on its own mid ends afterwards, and its `ended` listener checked only the connection, so the dead screen took the live replacement's tile with it | Fixed. The share is dropped only if the ending track is still the one being shown | `5a0be1e` | One test, mutation-verified; the screen that *is* showing still ends its tile | |
| 573 | | **S4-14 (P2)** — `sustained` was two early-return waits: 0, 1, 2, 3 then frozen for ever passed with a twenty-second argument, and streams were walked one after another | Fixed. `observe` requires growth in every one of three windows of 1.5 s, `watchMedia` reads every stream on both pages in one pass, and each caller names the keys it expects rather than asking the page what it is showing | `85e0a61` | Ten node tests in a new `media.test.mjs`, Codex's burst-then-freeze reproduction among them | |
| 574 | | **S4-16 (P3)** — §15.16 called the yield unconditional, listed slow stuttering as exempt when the watch repairs it, and claimed a recovery resting on those windows | Fixed in the spec: the yield is conditional on its allowance, stall criteria and exemptions are two separate lists, and the recovery claim is now "bounded and timed", with what is still not established said plainly | this commit | — | |
| 575 | |
| 576 | **What the new windows found on their first run**, which is the whole |
| 577 | argument for them: `laterolls` went red with `28->28->28->28` on one |
| 578 | camera. Two of that was mine — the scenario's own pre-check was "the |
| 579 | counter is above zero", which a counter left over from before the |
| 580 | collision answers by itself, and the settle check ran before the |
| 581 | recovery rather than after it, so a pair mid-rebuild read as a pair |
| 582 | that never settled. Fixed in the same commit: growth from a fresh |
| 583 | baseline for the *arrival* of recovery, then settling, then the |
| 584 | windows. It passes with the stall really happening first — rendered |
| 585 | frames `[25,25,25,25,25,25]`, recovery at 26 → 28, and |
| 586 | `273->296->320->351` through the windows. |
| 587 | |
| 588 | ### The reproduction, kept for an upstream report |
| 589 | |
| 590 | This is the material for a Chromium bug, and it is written down here |
| 591 | because it is the thing this slice learned that nobody else has. |
| 592 | |
| 593 | **Setup.** Two peers on one connection, both sharing a screen at the |
| 594 | same moment, with one side's own `offer` requests held back four times |
| 595 | `POLITE_YIELD` (1200 ms) so the remote offer lands after the yield and |
| 596 | the polite side rolls back. The gate's `laterolls` scenario does |
| 597 | exactly this and counts the rollback (`setRemoteDescription` of an |
| 598 | offer while in `have-local-offer`) rather than assuming it. |
| 599 | |
| 600 | **What is observed on the *receiving* side afterwards**, from |
| 601 | `meet.media()`, which compares the objects inside the page because |
| 602 | identity does not survive an `evaluate()`: |
| 603 | |
| 604 | ``` |
| 605 | cameraMid 1 |
| 606 | wanted ce46729b-…aef7 (the track onRemoteTrack recorded) |
| 607 | bound ce46729b-…aef7 (the track the tile is bound to) |
| 608 | element ["ce46729b-…aef7:live"] (the tracks in video.srcObject) |
| 609 | receivers mid 1, sendrecv, live, muted:false, isCamera:true |
| 610 | agrees true |
| 611 | currentTime 1.452 (and it does not move again) |
| 612 | ``` |
| 613 | |
| 614 | with `getStats` on the same connection reporting `framesDecoded` rising |
| 615 | throughout for that mid, and the far side's `framesEncoded` rising too. |
| 616 | So: the sender encodes, the receiver decodes, the element holds exactly |
| 617 | the receiver's own live, unmuted track — and paints nothing. Everything |
| 618 | upstream of the element is right and the picture is stopped downstream |
| 619 | of it. |
| 620 | |
| 621 | **The workaround that has an effect**, and it is a workaround: taking |
| 622 | the element's `srcObject` away and giving it a *new* `MediaStream` over |
| 623 | the same tracks. Re-assigning the same object does not do it — that was |
| 624 | measured, and it bought a frame or two. Rendered-frame totals every |
| 625 | 500 ms, before and after the watch that does this: `[25,26,26,26,26,26]` |
| 626 | and `[25,25,25,25,25,26]`; a later run `[25,25,25,25,25,25]` then |
| 627 | recovery. |
| 628 | |
| 629 | **What is not established.** Why a new stream object unblocks the |
| 630 | element; whether the decoder pipeline is rebuilt or merely re-attached; |
| 631 | and whether the rollback is the cause or only the trigger. Nothing here |
| 632 | identifies the Chromium defect — it locates it, downstream of decode |
| 633 | and upstream of paint, with a reproduction that runs in a headless |
| 634 | Chromium in under a minute. |
| 635 | |
| 636 | **Destination: file it upstream**, with this scenario and these |
| 637 | readings. Until it is understood, §15.16 claims a bounded, timed |
| 638 | recovery and nothing more. |
| 639 | |
| 640 | **The gate on the final head:** `make browser` twice, **31 of 31 both |
| 641 | times**, 523 s and 522 s, nothing skipped, with the timed windows in |
| 642 | every media check. 232 node tests — the five files plus the new |
| 643 | `media.test.mjs` — and `make ci`, `-race` and `go vet -tags browser` |
| 644 | green. |