open-standards/joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch.js
Transaction Science 4e8e4fb098
All checks were successful
joule-moq-remote host agent / joule-moq-remote · does this change touch it (pull_request) Successful in 20s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (pull_request) Successful in 26s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 24s
WAI operation catalogue / does this change touch it (pull_request) Successful in 13s
wai cross-architecture byte-equality / does this change touch it (pull_request) Successful in 21s
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (pull_request) Successful in 46s
wai-app and Create / wai-app · does this change touch it (pull_request) Successful in 29s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 18s
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 34s
wai-audit collector and player integration / does this change touch it (pull_request) Successful in 29s
WAI conformance / does this change touch it (pull_request) Successful in 16s
WAI examples / WAI examples · does this change touch them (pull_request) Successful in 8s
WAI HTTP delivery / WAI HTTP · does this change touch it (pull_request) Successful in 12s
wai-key-proxy / does this change touch it (pull_request) Successful in 13s
WAI neural sink gates / does this change touch it (pull_request) Successful in 12s
wai-ledger service / does this change touch it (pull_request) Successful in 14s
WAI neural decoders end-to-end / does this change touch it (pull_request) Successful in 14s
wai-ops integer operators / does this change touch it (pull_request) Successful in 6s
WAI JPEG-AI whole decode (pinned inputs, required) / does this change touch it (pull_request) Successful in 17s
wai-pack / wai-pack · does this change touch it (pull_request) Successful in 6s
wai-quantum command line / does this change touch it (pull_request) Successful in 5s
wai-quantum contract / does this change touch it (pull_request) Successful in 5s
wai-fleet service / does this change touch it (pull_request) Successful in 21s
WAI energy receipts / does this change touch it (pull_request) Successful in 24s
wai-ops-gpu integer operators on GPUs / does this change touch it (pull_request) Successful in 20s
wai-quantum docs, course and locales / does this change touch it (pull_request) Successful in 5s
wai-quantum CUDA member / does this change touch it (pull_request) Successful in 7s
wai-quantum dataflow member / does this change touch it (pull_request) Successful in 8s
wai-quantum signal processor / does this change touch it (pull_request) Successful in 8s
wai-quantum editors / does this change touch it (pull_request) Successful in 6s
wai-quantum resource estimation / does this change touch it (pull_request) Successful in 7s
wai-quantum FPGA member / does this change touch it (pull_request) Successful in 6s
wai-quantum OpenQASM reader / does this change touch it (pull_request) Successful in 7s
wai-quantum Metal member / does this change touch it (pull_request) Successful in 7s
wai-quantum npu-cores member / does this change touch it (pull_request) Successful in 9s
wai-quantum OpenCL member / does this change touch it (pull_request) Successful in 7s
wai-quantum neural-processor member / does this change touch it (pull_request) Successful in 10s
wai cross-architecture byte-equality / macos-arm64 (reference) (pull_request) Successful in 36s
wai-quantum HIP member / does this change touch it (pull_request) Successful in 17s
wai-quantum for JavaScript / does this change touch it (pull_request) Successful in 20s
wai-quantum other formats / does this change touch it (pull_request) Successful in 22s
wai-quantum pulse and dynamics / does this change touch it (pull_request) Successful in 11s
wai-quantum for Python / does this change touch it (pull_request) Successful in 10s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (pull_request) Successful in 1m20s
wai-quantum error correction and mitigation / does this change touch it (pull_request) Successful in 10s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 2m14s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 21s
wai-quantum-qpu / does this change touch it (pull_request) Successful in 10s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (pull_request) Successful in 1m48s
wai-quantum extension points / does this change touch it (pull_request) Successful in 11s
wai-quantum service / does this change touch it (pull_request) Successful in 11s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (pull_request) Successful in 1m58s
wai cross-architecture byte-equality / linux-aarch64 (pull_request) Successful in 34s
wai-quantum matrix-extension member / does this change touch it (pull_request) Successful in 10s
wai-quantum studio and desktop / does this change touch it (pull_request) Successful in 10s
wai-quantum CUDA member / member · CUDA (jetson-hub) (pull_request) Successful in 1m58s
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Successful in 58s
wai-quantum tensor-engine member / does this change touch it (pull_request) Successful in 9s
wai-quantum portable tensor program / does this change touch it (pull_request) Successful in 9s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 34s
wai-quantum CUDA member / member · CUDA (thor) (pull_request) Successful in 2m20s
wai-quantum variational and algorithm library / does this change touch it (pull_request) Successful in 9s
wai-quantum on every processor / emulated · armv7-unknown-linux-gnueabihf (pull_request) Successful in 2m25s
WAI conformance / fast integer kernels, every x86_64 kernel set (AVX-512 silicon) (pull_request) Successful in 2m57s
joule-code conformance gate / joule-moq-relay, every target (pull_request) Successful in 3m49s
wai-quantum CPU vector units / does this change touch it (pull_request) Successful in 10s
wai-quantum OpenCL member / member · OpenCL GPU and CPU (jetson-hub) (pull_request) Successful in 38s
wai-quantum verdicts and repairs / does this change touch it (pull_request) Successful in 5s
wai-quantum WebGPU member / does this change touch it (pull_request) Successful in 5s
wai-quantum views in agent hosts / does this change touch it (pull_request) Successful in 10s
wai-sink-android native sink library / does this change touch it (pull_request) Successful in 5s
WAI mbt2018_mean integer decode (regenerated vectors) / does this change touch it (pull_request) Successful in 5s
wai-sink-swift native sink package / does this change touch it (pull_request) Successful in 10s
WAI Studio / does this change touch it (pull_request) Successful in 5s
wai-vector vector graphics / does this change touch it (pull_request) Successful in 9s
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 5s
wai-web wasm32 / does this change touch it (pull_request) Successful in 5s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 4m34s
wai-quantum on every processor / emulated · loongarch64-unknown-linux-gnu (pull_request) Successful in 3m12s
wai-quantum OpenCL member / member · OpenCL GPU and CPU (thor) (pull_request) Successful in 1m2s
wai-quantum npu-cores member / member · npu-cores, neural processor (framework-128gb silicon) (pull_request) Successful in 1m17s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 4m30s
wai-quantum service / service energy · linux packages (pull_request) Successful in 1m36s
wai-audit collector and player integration / collector · linux (pull_request) Successful in 1m1s
wai-quantum OpenCL member / member · OpenCL GPU (framework-128gb) (pull_request) Successful in 25s
wai cross-architecture byte-equality / linux-x86_64 (pull_request) Successful in 28s
wai-quantum portable tensor program / tensor program · GPUs (jetson-hub) (pull_request) Successful in 34s
wai-ledger service / service · macos (pull_request) Successful in 49s
wai-quantum on every processor / emulated · s390x-unknown-linux-gnu (pull_request) Successful in 5m3s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (pull_request) Successful in 2m2s
wai-quantum HIP member / member · HIP (framework-128gb) (pull_request) Successful in 1m7s
wai-quantum on every processor / emulated · riscv64gc-unknown-linux-gnu (pull_request) Successful in 5m24s
wai-quantum portable tensor program / tensor program · integrated GPU (framework-128gb) (pull_request) Successful in 13s
wai-ops integer operators / macos-arm64 (pull_request) Successful in 48s
joule-code conformance gate / joule-moq-ingest, every target (pull_request) Successful in 7m0s
wai-quantum CPU vector units / member · vector units, avx2 and avx512 (framework-128gb silicon) (pull_request) Successful in 19s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Successful in 20s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 36s
wai-quantum on every processor / emulated · powerpc64-unknown-linux-gnu (pull_request) Successful in 6m9s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (pull_request) Successful in 3m10s
WAI HTTP delivery / HTTP corpus · every patch and mutation in exactly one shard (pull_request) Successful in 5s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (pull_request) Successful in 32s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 47s
WAI conformance / faces from keypoints under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m50s
wai-key-proxy / proxy · linux (pull_request) Successful in 1m13s
wai-pack / wai-pack · macos (pull_request) Successful in 2m42s
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 3m35s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 2m11s
WAI neural sink gates / the exact JPEG-AI decoder reports its path (pinned inputs, required) (pull_request) Successful in 2m12s
wai-ledger service / service · linux (pull_request) Successful in 1m47s
wai-quantum on every processor / emulated · powerpc64le-unknown-linux-gnu (pull_request) Successful in 9m29s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 2m46s
wai-quantum command line / command line · macos (pull_request) Successful in 1m17s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (pull_request) Successful in 8m26s
wai-ops integer operators / wasm32 under Node (pull_request) Successful in 30s
wai-ops integer operators / wasm32-wasip2 (wasmtime) (pull_request) Successful in 36s
WAI neural decoders end-to-end / synthetic fixtures → Rust decode → reference (±4) (pull_request) Successful in 1m32s
wai-ops integer operators / linux-x86_64 (pull_request) Successful in 1m2s
wai-ops integer operators / independent checker (Python) (pull_request) Successful in 14s
wai-quantum on every processor / emulated · sparc64-unknown-linux-gnu (pull_request) Successful in 10m23s
wai-quantum contract / the policy is linked, and the deprecations page reads (pull_request) Successful in 7s
wai-fleet service / service · macos (pull_request) Successful in 54s
wai-quantum on every processor / emulated · aarch64-unknown-linux-gnu (pull_request) Successful in 12m16s
wai-pack / wai-pack · linux (pull_request) Successful in 2m6s
wai-quantum WebGPU member / member · DX12 and Vulkan (cbio-windows) (pull_request) Successful in 13m3s
WAI operation catalogue / the catalogue and every door · linux (pull_request) Successful in 10m18s
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (pull_request) Successful in 11m27s
wai-quantum command line / command line · linux (pull_request) Successful in 3m18s
wai-quantum dataflow member / member · dataflow device absent (macos) (pull_request) Successful in 21s
wai-quantum WebGPU member / member · integrated GPU, Vulkan (framework-128gb) (pull_request) Successful in 8m28s
wai-fleet service / service · linux (pull_request) Successful in 2m21s
WAI examples / WAI examples · portable · linux (pull_request) Successful in 9m18s
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Successful in 6m39s
wai-quantum docs, course and locales / the Studio's words in every locale · linux (pull_request) Successful in 8s
wai-quantum contract / API compatibility · linux (pull_request) Successful in 5m21s
wai-quantum editors / editors · macos (pull_request) Successful in 2m8s
wai-ops-gpu integer operators on GPUs / builds · no adapter is not a pass (linux) (pull_request) Successful in 3m53s
wai-quantum docs, course and locales / the course and the command line's languages · linux (pull_request) Successful in 2m18s
WAI conformance / each feature of the independent verifiers' steps compiles alone (pull_request) Successful in 14m36s
wai-quantum contract / schemas and the committed tool lists · linux (pull_request) Successful in 6m46s
wai-quantum FPGA member / member · FPGA datapath, simulated (macos) (pull_request) Successful in 1m25s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Successful in 18m0s
wai-quantum dataflow member / member · dataflow device, simulated (pci 1e52:401e) (pull_request) Successful in 1m45s
wai-quantum CUDA member / member · CUDA absent (linux) (pull_request) Successful in 4m6s
wai-quantum FPGA member / member · FPGA simulation absent (linux) (pull_request) Successful in 23s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 8m20s
wai-quantum Metal member / member · Metal absent (linux) (pull_request) Successful in 33s
wai-quantum OpenCL member / member · OpenCL GPU (macos) (pull_request) Successful in 50s
wai-quantum on every processor / emulated · i686-unknown-linux-gnu (pull_request) Successful in 18m7s
wai-quantum OpenCL member / member · OpenCL absent (linux) (pull_request) Successful in 26s
wai-quantum editors / editors · linux (pull_request) Successful in 2m28s
wai-quantum Metal member / member · Metal (macos) (pull_request) Successful in 1m19s
wai-quantum resource estimation / estimation and calibration · linux, release (pull_request) Successful in 1m54s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 18m56s
wai-quantum neural-processor member / member · neural processor absent (linux) (pull_request) Successful in 50s
wai-quantum OpenQASM reader / reader and corpus · linux (pull_request) Successful in 1m47s
wai-quantum pulse and dynamics / the same bits · macos (pull_request) Successful in 1m7s
wai-quantum OpenQASM reader / corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m52s
wai-quantum dataflow member / member · dataflow device, simulated (pci 1e52:b140) (pull_request) Successful in 4m13s
wai-quantum-qpu / credentials in the keychain (macos) (pull_request) Successful in 52s
wai-quantum pulse and dynamics / reader, simulator and pulse jobs under wasm32-wasip2 (wasmtime) (pull_request) Successful in 52s
wai-quantum other formats / the set under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m38s
wai-quantum matrix-extension member / member · matrix extension absent (macos) (pull_request) Successful in 34s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 10m58s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 16m0s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 8m6s
wai-quantum-qpu / clients against their contracts (linux) (pull_request) Successful in 46s
wai-quantum extension points / the registry under wasm32-wasip2 (wasmtime) (pull_request) Successful in 56s
wai-quantum for Python / python · macos (pull_request) Successful in 2m59s
wai-quantum matrix-extension member / member · matrix extension, emulated (aarch64 Linux) (pull_request) Successful in 20s
wai-quantum portable tensor program / tensor program · Metal and the arm64 CPU (macos) (pull_request) Successful in 26s
wai-quantum other formats / readers, writers and the set · linux (pull_request) Successful in 3m42s
wai-quantum for JavaScript / javascript · linux (pull_request) Successful in 3m28s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 21m22s
wai-quantum error correction and mitigation / codes, decoders, texts and mitigation · linux, release (pull_request) Successful in 2m41s
wai-quantum neural-processor member / member · neural processor (macos) (pull_request) Successful in 4m40s
wai-quantum CPU vector units / member · vector unit absent (macos) (pull_request) Successful in 35s
wai-quantum extension points / the registry and the example · linux (pull_request) Successful in 2m36s
wai-quantum studio and desktop / desktop app · windows (pull_request) Successful in 10m41s
wai-quantum portable tensor program / tensor program · CPU, mutants, GPU builds (pull_request) Successful in 40s
wai-ops-gpu integer operators on GPUs / Metal GPU and browser WebGPU (macos) (pull_request) Successful in 12m33s
wai-quantum service / service · macos (pull_request) Successful in 4m1s
wai-quantum CPU vector units / member · vector unit, rvv 1024-bit (riscv64, emulated) (pull_request) Successful in 23s
wai-quantum pulse and dynamics / reader, simulator and pulse jobs · linux, release (pull_request) Successful in 4m41s
wai-quantum CPU vector units / member · vector unit, sve 128-bit (aarch64, emulated) (pull_request) Successful in 37s
wai-quantum CPU vector units / member · vector unit, sve 2048-bit (aarch64, emulated) (pull_request) Successful in 11s
WAI examples / WAI examples · android-app · linux-arm64 (pull_request) Successful in 7m28s
wai-quantum CPU vector units / member · vector unit, avx2 (x86_64 silicon) (pull_request) Successful in 26s
wai-quantum WebGPU member / member · browser WebGPU (macos) (pull_request) Successful in 2m7s
wai-quantum variational and algorithm library / parameters, gradients, optimisers, VQE, QAOA and builders · linux, release (pull_request) Successful in 1m59s
wai-quantum CPU vector units / member · vector unit, sve 512-bit (aarch64, emulated) (pull_request) Successful in 10s
wai-quantum CPU vector units / member · vector unit, rvv 128-bit (riscv64, emulated) (pull_request) Successful in 1m18s
wai-quantum WebGPU member / member · Metal (macos) (pull_request) Successful in 2m30s
wai-quantum service / service · linux (pull_request) Successful in 4m43s
wai-quantum studio and desktop / desktop app · macos (pull_request) Successful in 5m17s
wai-quantum studio and desktop / studio · browser (pull_request) Successful in 3m51s
wai-quantum WebGPU member / member · GPU, Vulkan (jetson-hub) (pull_request) Successful in 21m17s
wai-quantum verdicts and repairs / the intent set and the verdict under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m51s
wai-vector vector graphics / macos-arm64 (pull_request) Successful in 2m7s
wai-quantum verdicts and repairs / verdicts, repairs and the intent set · linux (pull_request) Successful in 2m30s
wai-quantum WebGPU member / member · software Vulkan (linux) (pull_request) Successful in 1m48s
wai-quantum WebGPU member / member · GPU, Vulkan (thor) (pull_request) Successful in 24m55s
wai-quantum tensor-engine member / member · tensor engine, the kernel toolchain's simulator (linux) (pull_request) Successful in 5m59s
wai-quantum for Python / python · linux (pull_request) Successful in 9m4s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (pull_request) Successful in 1m0s
wai-quantum views in agent hosts / views · the page and its engine (pull_request) Successful in 3m13s
wai-vector vector graphics / wasm32-wasip2 (wasmtime) (pull_request) Successful in 50s
wai-vector vector graphics / wasm32 under Node (pull_request) Successful in 25s
wai-vector vector graphics / fuzz, peak allocation, comparison count (pull_request) Successful in 16s
wai-quantum studio and desktop / desktop app · linux (pull_request) Successful in 7m38s
WAI HTTP delivery / HTTP corpus · falsify patches 0/3 (pull_request) Successful in 5m54s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (pull_request) Successful in 7m4s
wai-quantum views in agent hosts / views · a scripted host (pull_request) Successful in 4m8s
wai-quantum signal processor / processor and member · signal processor, emulated (pull_request) Successful in 15m47s
wai-vector vector graphics / wasm32 in three browser engines (pull_request) Successful in 2m35s
wai-vector vector graphics / independent checker (Python) (pull_request) Successful in 3m0s
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Successful in 3m54s
wai-vector vector graphics / linux-x86_64 (pull_request) Successful in 5m37s
wai-quantum WebGPU member / shader checks · browser build · wasip2 inertness · MSRV (pull_request) Successful in 9m14s
wai-quantum docs, course and locales / every command and snippet in the docs · linux (pull_request) Successful in 21m22s
WAI mbt2018_mean integer decode (regenerated vectors) / regenerate mbt2018_mean integer vectors → Rust decode, required (pull_request) Successful in 7m27s
WAI Studio / Studio in Chromium and Firefox · macos (pull_request) Successful in 10m15s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (pull_request) Successful in 11m59s
WAI HTTP delivery / HTTP corpus · falsify patches 1/3 (pull_request) Successful in 6m48s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 6m44s
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Successful in 9m5s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 33m23s
WAI HTTP delivery / HTTP corpus · falsify patches 2/3 (pull_request) Successful in 7m11s
WAI Studio / Studio in WebKit · linux (pull_request) Successful in 18m7s
WAI HTTP delivery / HTTP corpus · verified both ways, and the differential (pull_request) Successful in 2m19s
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Successful in 1m20s
wai-ops integer operators / linux-aarch64 (pull_request) Successful in 1m2s
wai-pack / wai-pack · linux-arm64 (pull_request) Successful in 1m45s
WAI HTTP delivery / HTTP corpus · falsify mutations 2/3 (pull_request) Successful in 32m36s
wai-pack / wai-pack · the floor through a media prober (pull_request) Successful in 32s
wai-pack / wai-pack · dependency licences (pull_request) Successful in 16s
wai-pack / wai-pack · independent verifier (Python) (pull_request) Successful in 7m5s
wai-quantum npu-cores member / member · npu-cores absent (linux-arm64) (pull_request) Successful in 27s
wai-quantum HIP member / member · HIP absent (linux-arm64) (pull_request) Successful in 58s
WAI HTTP delivery / HTTP corpus · falsify mutations 1/3 (pull_request) Successful in 39m55s
wai-quantum pulse and dynamics / the same bits · linux-arm64 (pull_request) Successful in 1m24s
WAI HTTP delivery / HTTP corpus · falsify mutations 0/3 (pull_request) Successful in 40m34s
wai-quantum tensor-engine member / member · tensor engine absent (linux-arm64) (pull_request) Successful in 20s
wai-quantum variational and algorithm library / parameters, gradients, optimisers, VQE, QAOA and builders · linux-arm64, release (pull_request) Successful in 2m40s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (pull_request) Successful in 2m39s
wai-quantum extension points / the registry and the example · linux-arm64 (pull_request) Successful in 4m1s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 11m3s
wai-vector vector graphics / linux-aarch64 (pull_request) Successful in 4m17s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 1h10m6s
watch-together: the page makes its audio context before its join; a slot is set apart only when the machine ran nothing of the page
The watch-together example failed twice on the macOS runner for #616, on a loaded host (load
average about 50, then about 30). Each time the first page in the browser, Ada's, signed its
first presentation statement after its join, at 3.83 s and then 3.44 s on the room clock, and
then none in the slots from 4 s and 5 s (and 6 s the second time), while it was still entering
(begin()): it followed the log from 7.49 s, then 8.49 s, 3.7 s and 5.0 s after its first
statement, where Ben's page took 1.2 s and 1.0 s. Ada's page was silent at least 2.17 s, then
at least 3.56 s, with nothing refused, and was never taken for gone. What held it on the runner
was not measured.

What was measured:
- On the CI host, with CI jobs running (load average 30 to 39), in a browser launched as the
  example's test launches it (headless, the same flags, at nice 10), on a blank page with
  nothing else in it: the first audio context in a fresh browser held its page's thread 172 to
  192 ms (3 runs), for the constructor call alone; a second page's took 3.8 to 4.0 ms; neither
  page's thread had a gap over 60 ms in the 1.5 s after. That is a tenth or less of Ada's
  silences. Moving the context before the join takes about 0.2 s, as measured there, out of the
  stretch in which the page must state; what held Ada's page for the rest is unexplained. The
  rest of begin() (its audio worklet, its relay session, its WebAssembly, its first draws, the
  camera and microphone) was not measured there, nor an audio context made beside that work.
- On a laptop, in 25 runs of the example: the first page's audio context 94 to 128 ms, the
  second's 0.8 to 1.4 ms.
- On a laptop, on main, two injected faults each leave empty the slots just after the first
  page's first statement, as in CI: the audio-context call held 2.5 s (a busy wait in it; the
  slots from 1 s and 2 s, 1 run), and the first page's renderer process stopped 2.5 s from its
  join (SIGSTOP; the slot from 2 s, 2 of 2 runs). Neither was measured on the CI host.

This change is not shown to make the example pass on the CI host. If what held Ada's page there
was the page's own work on its thread, or the host running the page's other threads and not
that one, the check below still fails (its heartbeat ticking), and what it prints does not tell
those two apart.

1. The page makes the composition's audio context before it enters the co-watch log (app.js
   enter(), the new Compositor.openAudio()), since from that join it states in every slot
   (co-watch §7.3), so that hold is not in the stretch in which it must state. The rest of the
   entry (begin()) still runs after the join. begin() takes the compositor the entry made; in
   any other room it makes one, as before. The snapshot carries when the context was made, on
   the room clock, and how long making it held the thread (cowatch.audio_open).

2. A compositor made for an entry that failed is closed when the next entry makes its own
   (Compositor.close(): its mix timer, its worklet node, its drawing, its frames and its audio
   context). Before, it stayed, its audio context open and its mix timer running.

3. The statement loop records its stalls (cowatch.js run(), snapshot().stalls): each gap over
   half a slot between two of its passes, `{ from, to }` on the page's estimate of the room
   clock, with how long its wait and its signing took in it, on that clock (`timer_us`,
   `signing_us`); the rest of a stall is the loop's own work. It states every half slot while
   it runs, so it leaves a slot empty only inside such a stall. Its wait is a method (`wait`),
   and roomTime(ms) gives the room clock at an instant of the page's clock. The snapshot
   carries the loop's wait (`pass_ms`) and the page's clock beside the room clock (`wall_ms`).
   The co-watch rule and §7.3 are unchanged.

4. The example's slot check (co-watch §7.3: a statement in every slot while present) sets an
   empty slot apart only if the machine ran nothing of the page in the slot's second half: a
   stall of the loop spans that half; a heartbeat worker that the test starts in each page,
   ticking every 20 ms, did not tick in it either (to within a pass at each end); and the stall
   is the loop's wait and its signing but for at most one pass of its own work. Otherwise the
   check fails and says why: no stall of the loop (a statement lost); the heartbeat ticking (the
   page's thread did not run its loop while the worker ran: the page's own work holding the
   thread, or the host running the worker and not the page's thread, which the check does not
   tell apart); or more than a pass of the loop's own work (its own code, an await outside its
   measures, or the thread taken off the processor inside it). On a host that runs the page's
   threads unevenly, the check can fail in these ways with no defect in the page. Pass or fail,
   it prints each page's audio-context time, its loop's stalls and its heartbeat's silences over
   100 ms. They show whether the page's thread or the whole page was held, not what held it.

Unit tests (cowatch.test.mjs) run the loop on a room clock the test moves: no stall and every
slot held while it runs; a thread held while the loop waited, recorded once with all of the
stall its wait, and the only empty slots those it spans; a gap just over half a slot recorded
and one of half a slot not; a stall in a pass's own work recorded with a wait of one pass, the
rest neither wait nor signing.

Verified on a laptop with this change on main (152aab5c7), in a browser launched as the
example's test launches it and niced:
- From an archive build.sh made, with the WebAssembly build-web.sh made from crates this change
  does not touch, run-examples.sh browser passes video-room, watch-together, messaging,
  remote-desktop and package-and-deliver in each of 2 runs, and watch-together alone 2 more.
- With this room client served beside an earlier archive's binaries (the same Rust but for the
  relay's DVR change in the commit before), watch-together passes 3 of 3, and the faults below
  ran on that set. In each passing run the first page's audio context was made before its join
  and Ada's loop had no stall. Ben's deliberate hold, a synchronous request, shows as a stall of
  3.8 to 4.2 s with his heartbeat never silent.
- The room client's unit tests pass under node --test (77).
- Falsified, each fault put into a scratch copy of the room client, 2 runs each unless said:
  the audio context made in begin() again and that call held 2.5 s (a busy wait), the check
  fails: no statement in the slots from 1 s and 2 s (Ada) and 4 s and 5 s (Ben), each in a
  stall of the loop with its heartbeat ticking, the context printed as made after the join in
  2.5 to 2.6 s. A 2.5 s busy wait just after the join, or 4 s after it, fails it the same way:
  the slots it spans, with the heartbeat ticking. Every statement of the third whole slot after
  each join dropped fails it (no stall of its loop). One 1.5 s await in the loop's own work
  fails it (heartbeat ticking, 1.5 s neither its wait nor its signing); the same await inside
  the loop's wait fails it on the heartbeat alone. The page's renderer process stopped 2.5 s
  (SIGSTOP) at Ada's join, or 2.5 s after it, or Ben's 1 s after his (1 run): it passes, the
  slots inside the stop set apart; the heartbeat was silent and the loop stalled over the stop,
  to within 25 ms. The stop at Ada's join and a 2.5 s busy wait 4 s after it (1 run): the
  stop's slot is set apart and the busy wait's fail.
- In the unit tests, the wait measured on another clock than the stall, no stall recorded, a
  stall recorded only past a whole slot, and the wait left out of the record each fail one or
  two of the stall tests.
- A video room: one page publishes; the other presses Watch, its first relay connection is
  refused (a fault the probe injects), and it presses Watch again. Then that page holds one
  audio context open (the first closed) and one mix timer, and mixes 50 blocks of 20 ms a
  second. On main it holds two open and two timers; with close() doing nothing, two open. The
  same in a watch room: one open with this change, two on main.
- Not shown: that the example passes on the CI host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:56:36 -05:00

647 lines
30 KiB
JavaScript

// Watching together in a room (WAI extensions/co-watch.md), in a page.
//
// A watch room's service serves the source and carries its viewers' events in a log
// (co-watch §10). This module is the page's part, in two halves:
//
// - the rules, with nothing of the page: the policy read (§3.1), controls and presentation
// statements written byte for byte (§5.1, §7.1), statements read (§4.2), the controlled
// position (§5.4, §6), the checks a control meets before a player applies it (§5.2, §5.3),
// the catch-up rule (§9), and the clock estimate (§7.3). Its tests (cowatch.test.mjs) run
// them against the corpus wai/live-conformance/cowatch/;
// - CoWatch, the player: it enters the room by proving its grant's key over the service's
// challenge (live-rooms §10.5.3), follows the log, applies each control at its instant on
// its estimate of the room clock, keeps a media element at the controlled position by the
// catch-up rule, signs a presentation statement twice a slot under the page's key, and shows
// the room's sync: its own error, and each viewer's verdict from the clock's last skew
// statement. Taken for gone after a silence, it enters again and signs again, after its new
// `join`, the controls the room had not carried.
//
// Everything here is the platform's: WebCrypto's SHA-256 and Ed25519, an HTMLMediaElement.
const subtle = globalThis.crypto.subtle;
const enc = new TextEncoder();
export const NORMAL = 1000000;
export const ACTION = { play: 1, pause: 2, seek: 3, rate: 4 };
export const ACTION_NAME = { 1: "play", 2: "pause", 3: "seek", 4: "rate" };
export const STATE = { playing: 1, paused: 2, stalled: 3, ended: 4 };
export const VERDICT = { 1: "in_sync", 2: "out_of_sync", 3: "unknown" };
const MAGIC = { policy: "WXP1", control: "WXC1", presentation: "WXS1", skew: "WXK1" };
const PRESENTATION_DOMAIN = enc.encode("wai:co-watch-presentation\x01");
const PROOF_DOMAIN = enc.encode("wai:live-room-proof\x01");
const META = 0x01;
export const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
export const unhex = (h) => new Uint8Array((h.match(/../g) || []).map((x) => parseInt(x, 16)));
export function concat(...parts) {
const n = parts.reduce((a, p) => a + p.length, 0);
const out = new Uint8Array(n);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
export async function sha256(...parts) {
return new Uint8Array(await subtle.digest("SHA-256", concat(...parts)));
}
const le = (n, bytes) => {
const out = new Uint8Array(bytes);
let v = BigInt(n);
for (let i = 0; i < bytes; i++) { out[i] = Number(v & 0xffn); v >>= 8n; }
return out;
};
class Rd {
constructor(b, p = 0) { this.b = b; this.p = p; }
take(n) { if (this.p + n > this.b.length) throw new Error("short"); const s = this.b.subarray(this.p, this.p + n); this.p += n; return s; }
u8() { return this.take(1)[0]; }
uint(n) { const s = this.take(n); let v = 0n; for (let i = n - 1; i >= 0; i--) v = (v << 8n) | BigInt(s[i]); return v; }
u16() { return Number(this.uint(2)); }
u32() { return Number(this.uint(4)); }
u64() { return Number(this.uint(8)); } // every instant, position and rate here is below 2^53
done() { return this.p === this.b.length; }
}
// ---------------------------------------------------------------- §3 the policy
/** A co-watch policy's fields (§3.1); its signature is the room's to check. */
export function readPolicy(b) {
if (new TextDecoder().decode(b.subarray(0, 4)) !== MAGIC.policy || b[4] !== 1) throw new Error("not a WXP1 policy");
const r = new Rd(b, 5);
const p = { room: hex(r.take(32)), prev: hex(r.take(32)), effective: r.u64(), source_type: r.u8(), source: hex(r.take(32)), duration_us: r.u64() };
for (const k of ["lead_ms", "horizon_ms", "min_rate", "max_rate", "period_ms", "window_ms", "settle_ms", "bound_us"]) p[k] = r.u32();
p.quantile = r.u8();
for (const k of ["tolerance_us", "nudge", "seek_us"]) p[k] = r.u32();
r.take(64);
if (!r.done()) throw new Error("bytes after the policy");
return p;
}
/** The policy in effect at `t` (§3.4): the last whose `effective` is at or below it. */
export function policyAt(policies, t) {
let k = null;
policies.forEach((p, i) => { if (p.effective <= t) k = i; });
return k;
}
/** An object sequence's digest (§3.5). */
export async function sourceDigest(objects) {
return sha256(enc.encode("wai:co-watch-source\x01"), le(objects.length, 4), ...objects.flatMap((o) => [unhex(o.content_hash), le(o.duration_ms, 4)]));
}
// ---------------------------------------------------------------- §5, §7 statements
/** A control (§5.1), 22 bytes. */
export function controlPayload(c) {
return concat(enc.encode(MAGIC.control), Uint8Array.of(1, c.action), le(c.at, 8), le(c.arg, 8));
}
/** A presentation statement's record from `magic` through `rate` (§7.1). */
export function presentationBody(p) {
return concat(enc.encode(MAGIC.presentation), Uint8Array.of(1), unhex(p.room), unhex(p.player), unhex(p.source), le(p.at, 8), le(p.position, 8), Uint8Array.of(p.state), le(p.rate, 4));
}
/** The statement, signed with Ed25519 under the player's key (§7.1): 186 bytes. */
export async function signPresentation(p, privateKey) {
const body = presentationBody(p);
const sig = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, concat(PRESENTATION_DOMAIN, body)));
return concat(body, sig);
}
/** WebCrypto's key for a 32-byte Ed25519 seed (RFC 8410's PKCS #8 form), and its public key. */
export async function keyFromSeed(seed) {
const pkcs8 = concat(Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20), seed);
const privateKey = await subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
const jwk = await subtle.exportKey("jwk", privateKey);
const publicKey = Uint8Array.from(atob(jwk.x.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
return { privateKey, publicKey };
}
/** A statement of this extension (§4.1, §4.2) read, or null for another `meta` payload; a
* statement that does not read is `{ kind: "malformed" }`. */
export function readStatement(payload) {
const magic = new TextDecoder().decode(payload.subarray(0, 4));
if (![MAGIC.control, MAGIC.presentation, MAGIC.skew].includes(magic)) return null;
try {
if (payload[4] !== 1) throw new Error("version");
const r = new Rd(payload, 5);
let s;
if (magic === MAGIC.control) {
s = { kind: "control", action: r.u8(), at: r.u64(), arg: r.u64() };
const ok = s.action === 1 || s.action === 2 ? s.arg === 0 : s.action === 3 ? true : s.action === 4 ? s.arg >= 1 && s.arg <= 0xffffffff : false;
if (!ok) throw new Error("arg");
} else if (magic === MAGIC.presentation) {
s = { kind: "presentation", room: hex(r.take(32)), player: hex(r.take(32)), source: hex(r.take(32)), at: r.u64(), position: r.u64(), state: r.u8(), rate: r.u32() };
s.sig = r.take(64);
if (!(s.state in { 1: 1, 2: 1, 3: 1, 4: 1 }) || s.rate === 0) throw new Error("state");
} else {
s = { kind: "skew", from: r.u64(), to: r.u64(), bound_us: r.u32(), quantile: r.u8(), viewers: [], pairs: [] };
const nv = r.u8();
for (let i = 0; i < nv; i++) {
s.viewers.push({ principal: hex(r.take(32)), held: r.u64(), due: r.u16(), samples: r.u16(), counted: r.u16(), p50: r.u32(), pq: r.u32(), max: r.u32(), verdict: VERDICT[r.u8()] });
}
const np = r.u8();
for (let i = 0; i < np; i++) s.pairs.push({ a: r.u8(), b: r.u8(), n: r.u16(), p50: r.u32(), pq: r.u32(), max: r.u32(), verdict: VERDICT[r.u8()] });
}
if (!r.done()) throw new Error("bytes after");
return s;
} catch {
return { kind: "malformed" };
}
}
/** A WEV1 record's fields (live-events §3.1), read as far as this module needs them. */
export function readEvent(b) {
const r = new Rd(b, 5);
const ev = { room: hex(r.take(32)), principal: hex(r.take(32)), seq: r.u64(), prev: hex(r.take(32)), t: r.u64() };
if (r.u8() === 1) r.take(48);
ev.kind = r.u8();
ev.payload = r.take(r.u16());
ev.body = b.subarray(0, r.p);
ev.sig = r.take(64);
return ev;
}
// ---------------------------------------------------------------- §5.4, §6 the controlled position
function positionOf(st, x, duration) {
if (!st.playing) return st.pos;
const p = st.pos + Number((BigInt(Math.max(0, x - st.anchor)) * BigInt(st.rate)) / 1000000n);
return Math.min(duration, p);
}
/** The controlled position over the room clock (§6), from the policies and the accepted
* controls, each `{ at, t, digest (hex), action, arg }`. */
export class Timeline {
constructor(policies, controls) {
this.policies = policies;
this.starts = [];
policies.forEach((p, i) => {
if (i === 0 || p.source_type !== policies[i - 1].source_type || p.source !== policies[i - 1].source) this.starts.push(p.effective);
});
const key = (c) => [c.at, c.t, c.digest];
this.controls = [...controls].sort((a, b) => {
const [x, y] = [key(a), key(b)];
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
return 0;
});
const aspect = { 1: 0, 2: 0, 3: 1, 4: 2 };
this.overridden = new Set();
this.controls.forEach((c, i) => {
for (const d of this.controls.slice(i + 1)) {
if (d.at !== c.at) break;
if (aspect[d.action] === aspect[c.action]) { this.overridden.add(c.digest); break; }
}
});
const events = [...this.starts.map((s) => ({ x: s, start: true })), ...this.controls.map((c) => ({ x: c.at, c }))];
events.sort((a, b) => a.x - b.x || (a.start ? 0 : 1) - (b.start ? 0 : 1));
this.points = [];
let st = null;
for (const e of events) {
if (e.start) st = { playing: false, rate: NORMAL, anchor: e.x, pos: 0 };
else if (st) {
st = { ...st, pos: positionOf(st, e.x, this.durationAt(e.x)), anchor: e.x };
if (e.c.action === 1) st.playing = true;
else if (e.c.action === 2) st.playing = false;
else if (e.c.action === 3) st.pos = e.c.arg;
else st.rate = e.c.arg;
}
if (st) this.points.push([e.x, st]);
}
}
durationAt(x) {
const k = policyAt(this.policies, x);
return k == null ? 0 : this.policies[k].duration_us;
}
stateAt(x) {
let st = null;
for (const [at, s] of this.points) if (at <= x) st = s;
return st;
}
positionAt(x) {
const st = this.stateAt(x);
return st ? positionOf(st, x, this.durationAt(x)) : 0;
}
}
/** Why a control is not applied (§5.2, §5.3), or null: `mayControl(action)` says whether its
* author may send it, under the policy in effect at its `t`. */
export function controlRefusal(policies, t, c, mayControl) {
const k = policyAt(policies, t);
if (k == null) return "cw_no_policy";
const p = policies[k];
if (!mayControl(c.action)) return "control_not_permitted";
if (c.at < t + p.lead_ms * 1000) return "control_not_ahead";
if (c.at > t + p.horizon_ms * 1000) return "control_beyond_horizon";
const q = policies[policyAt(policies, c.at)];
if ((c.action === 3 && c.arg > q.duration_us) || (c.action === 4 && (c.arg < q.min_rate || c.arg > q.max_rate))) return "control_out_of_range";
return null;
}
// ---------------------------------------------------------------- §9 catch-up
/** The correction the catch-up rule gives (§9). */
export function catchUp(p, playing, rate, controlled, duration, presented) {
const e = presented - controlled;
const mag = Math.abs(e);
if (!playing || controlled >= duration) return mag > p.tolerance_us ? { action: "seek", value: controlled } : { action: "hold" };
if (mag <= p.tolerance_us) return { action: "rate", value: rate };
if (mag > p.seek_us) return { action: "seek", value: controlled };
const r = e > 0 ? rate - p.nudge : rate + p.nudge;
return { action: "rate", value: Math.max(p.min_rate, Math.min(p.max_rate, r)) };
}
// ---------------------------------------------------------------- §7.3 the clock
/** The offset of the service's clock from this page's, in µs, from round trips each
* `{ sent, got, server }` (this page's ms at sending and receiving, the server's µs): the
* answer of least round trip, at its midpoint (§7.3). */
export function clockOffset(samples) {
const best = samples.reduce((a, s) => (!a || s.got - s.sent < a.got - a.sent ? s : a), null);
return Math.round(best.server - ((best.sent + best.got) / 2) * 1000);
}
// ---------------------------------------------------------------- the player
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// A slot is stated twice (co-watch §7.3), so timer jitter leaves none empty.
const STATES_PER_SLOT = 2;
// Statements one post carries at most: a front may hold a request's body to a few KiB.
const POST_EVENTS = 4;
// Times a page taken for gone enters again with nothing of it carried since its last entry
// (taken for gone again at once, as by a second stall) before it stops.
const REENTRIES = 3;
// The player's wait between two passes of its loop, in ms, and the stalls of the loop a page
// keeps (`stalls`).
const PASS_MS = 50;
const STALLS_KEPT = 256;
export class CoWatch {
// o: { api (path, body) -> {status, body}, wai, room, t0, seed (Uint8Array), principal (hex),
// grant (hex), grantDigest (hex), media (HTMLMediaElement), policies (hex), objects,
// sourceUrl, mayControl (principal, action) -> bool, target (the join's proof target,
// "//authority/path"), log, onChange }
constructor(o) {
Object.assign(this, o);
this.policyList = o.policies.map((h) => readPolicy(unhex(h)));
this.policy = this.policyList[0];
this.offset = 0;
this.at = 0; // the next entry of the log
this.controls = [];
this.timeline = new Timeline(this.policyList, []);
this.seq = 0;
this.prev = "00".repeat(32);
// Where the room's copy of this page's chain ends: the next seq, and the last digest it carried.
this.carried = { seq: 0, prev: "00".repeat(32) };
this.lastT = 0;
this.joinT = null;
this.outbox = []; // each { rec, seq, digest, control }: signed, not yet carried
this.reentered = 0; // entries again since the room last carried an event of this page
this.stopped = null;
this.skews = [];
this.others = {}; // principal -> its last statement: at, position, error
// `gone`: posts refused `not_present`, the room having taken the page for gone; `rejoins`:
// its entries again; `resigned`: controls signed again after one; `dropped`: statements
// and controls signed before one and not signed again. `stalls`: the stretches in which
// the player's loop did not run (`run`), the last STALLS_KEPT of them.
this.stats = { log: 0, controls: 0, applied: 0, refused: {}, statements: 0, posted: 0, post_refused: {}, seeks: 0, nudges: 0, verified_source: false, gone: 0, rejoins: 0, resigned: 0, dropped: 0, followed_at: null, stalls: [], errors: [] };
this.onChange = o.onChange || (() => {});
}
/** The room clock now, on this page's estimate (§7.3). */
now() {
return this.roomTime(Date.now());
}
/** The room clock at `ms` on this page's clock (`Date.now()`), on its estimate. */
roomTime(ms) {
return Math.max(0, Math.round(ms * 1000 + this.offset - this.t0));
}
async estimateClock() {
const samples = [];
for (let i = 0; i < 5; i++) {
const sent = Date.now();
const r = await this.api("/watch/time");
const got = Date.now();
if (r.status === 200) samples.push({ sent, got, server: r.body.now_us });
}
if (samples.length) this.offset = clockOffset(samples);
this.stats.offset_us = this.offset;
}
/** The source, fetched whole and checked by its content hash (§3.5), as the media's own. */
async loadSource() {
const r = await fetch(this.sourceUrl, { cache: "no-store" });
const bytes = new Uint8Array(await r.arrayBuffer());
const h = hex(await sha256(bytes));
const obj = this.objects[0];
if (h !== obj.content_hash) throw new Error(`the source's bytes are not the object the policy names: ${h.slice(0, 16)}…`);
if (hex(await sourceDigest(this.objects)) !== this.policy.source) throw new Error("the source's objects are not the policy's source");
this.stats.verified_source = true;
this.media.src = URL.createObjectURL(new Blob([bytes], { type: r.headers.get("content-type") || "application/octet-stream" }));
this.media.preload = "auto";
await new Promise((ok, bad) => {
if (this.media.readyState >= 1) return ok();
this.media.addEventListener("loadedmetadata", ok, { once: true });
this.media.addEventListener("error", () => bad(new Error("the source does not play here")), { once: true });
});
}
/** Enter: a proof of the grant's key over the service's challenge, for the request's own
* method and target (live-rooms §10.5.1, §10.5.3); the clock then signs this page's `join`. */
async join() {
const c = await this.api("/watch/challenge");
if (c.status !== 200) throw new Error(`no challenge: ${c.status}`);
const grant = unhex(this.grant);
const digest = await sha256(grant.subarray(0, grant.length - 64));
const context = enc.encode(`POST ${this.target}`);
const key = await keyFromSeed(this.seed);
const body = concat(enc.encode("WRP1"), Uint8Array.of(1), digest, unhex(this.principal), unhex(c.body.challenge), le(context.length, 2), context);
const sig = new Uint8Array(await subtle.sign({ name: "Ed25519" }, key.privateKey, concat(PROOF_DOMAIN, body)));
const r = await this.api("/watch/join", { room: this.room, grant: this.grant, proof: hex(concat(body, sig)) });
if (r.status !== 200) throw new Error(`the room refused the join: ${r.status} ${r.body && r.body.error}`);
this.joinT = r.body.t;
this.key = key;
this.log(`co-watch: joined at room clock ${r.body.t}`);
}
/** A `meta` event of this page's chain, signed (live-events §3), at `t`: the outbox's entry
* for it, with the control it carries, if it is one. */
event(t, payload, control = null) {
t = Math.max(t, this.lastT, this.joinT || 0);
const seq = this.seq;
const rec = this.wai.eventSign(this.seed, unhex(this.room), BigInt(seq), unhex(this.prev), BigInt(t), META, payload);
const v = JSON.parse(this.wai.eventRead(rec));
this.seq++;
this.prev = v.digest;
this.lastT = t;
return { rec, seq, digest: v.digest, control };
}
/** When a control asked for `at` is signed now: its `t`, and its instant, `at` or, if the
* policy's lead (§5.3) no longer allows that, the earliest instant it does. */
controlTime(at) {
const t = Math.max(this.now(), this.lastT, this.joinT || 0);
return { t, at: Math.max(at, t + this.policy.lead_ms * 1000 + 50000) };
}
/** A control signed now, at the instant `when` (`controlTime`) gives. */
signControl(c, when = this.controlTime(c.at)) {
const { t, at } = when;
return this.event(t, controlPayload({ ...c, at }), { ...c, at });
}
/** Ask the room to `action` (ACTION) with `arg`, at the policy's lead from now (§5.3). */
control(action, arg = 0) {
const e = this.signControl({ action, at: 0, arg });
this.outbox.push(e);
this.flush();
return e.control.at;
}
/** The outbox's first `n` events carried: the room's copy of the chain ends with them. */
carry(n) {
if (!n) return;
const last = this.outbox[n - 1];
this.carried = { seq: last.seq + 1, prev: last.digest };
this.outbox.splice(0, n);
this.stats.posted += n;
}
/** After entering again. Every event the room has not carried was signed before its new
* `join`, so the room would refuse it as reversed in time: the chain goes on from where the
* room's copy ends, at or after the join. The room holds none of those events (it carries
* no event it refuses), so signing their positions again leaves it one event at each
* (co-watch §10). A control is signed again, at its instant if the policy's lead still
* allows it, else at the earliest it does, unless that moves it to or past the instant of
* another viewer's control the page holds at or after its own: controls apply in the order
* of their instants (§5.4), and the earlier press would then override the later one, so it
* is dropped, as a press the room never had. A presentation statement of an instant before
* the page was present again is dropped, and the page states anew within half a slot. */
resign() {
const pending = this.outbox;
this.outbox = [];
this.seq = this.carried.seq;
this.prev = this.carried.prev;
let resigned = 0;
for (const e of pending) {
if (!e.control) continue;
const c = e.control;
const when = this.controlTime(c.at);
const passed = this.controls.find((x) => x.author !== this.principal && x.at >= c.at && x.at <= when.at);
if (passed) {
this.log(`co-watch: a ${ACTION_NAME[c.action]} at ${c.at}, signed while the room had taken this page for gone, is dropped: moved to ${when.at}, it would apply after another viewer's ${ACTION_NAME[passed.action]} at ${passed.at}`);
continue;
}
const again = this.signControl(c, when);
this.outbox.push(again);
if (again.control.at !== c.at) this.log(`co-watch: a ${ACTION_NAME[c.action]} moved from ${c.at} to ${again.control.at}, the earliest the lead allows`);
resigned++;
}
this.stats.rejoins++;
this.stats.resigned += resigned;
this.stats.dropped += pending.length - resigned;
this.log(`co-watch: entered again; ${resigned} control(s) signed again, ${pending.length - resigned} earlier event(s) dropped`);
}
/** Enter again, the room having taken this page for gone, and sign again what it had not
* carried (`resign`). Whether it entered: a join the service could not make now (the clock
* could not sign it) leaves the outbox as it is, for the next post to try again. */
async enterAgain() {
try {
await this.join();
} catch (e) {
this.log(`co-watch: entering again failed (${e.message}); the next post tries again`);
return false;
}
this.resign();
return true;
}
async flush() {
if (this.posting || !this.outbox.length) return;
this.posting = true;
try {
while (this.outbox.length) {
const batch = this.outbox.slice(0, POST_EVENTS);
const r = await this.api("/watch/post", { room: this.room, events: batch.map((e) => hex(e.rec)) });
if (r.status === 200) {
this.carry(batch.length);
this.reentered = 0;
} else if (r.status === 409 && r.body && r.body.error === "not_present" && this.reentered < REENTRIES) {
// The room took this page for gone (it was silent too long, as a page on a loaded
// machine can be): it enters again, and its chain goes on from what the room carried.
// Taken for gone again before anything of it was carried (a second stall, right
// after its join), it enters again too, a few times at most.
this.carry(r.body.carried || 0);
this.stats.gone++;
if (await this.enterAgain()) this.reentered++;
else await sleep(500);
} else if (r.status === 409) {
// Those before it were carried; this one never will be, and the chain cannot go
// on past it: the page stops stating rather than leave a gap it cannot fill.
const why = r.body.error;
this.stats.post_refused[why] = (this.stats.post_refused[why] || 0) + 1;
this.carry(r.body.carried || 0);
this.outbox.shift();
this.stop(`the room refused an event: ${why}`);
break;
} else {
await sleep(500);
}
}
} finally {
this.posting = false;
}
}
stop(why) {
this.stopped = why;
this.stats.errors.push(why);
this.log(`co-watch: ${why}`);
}
/** Follow the room's log, each record checked before anything of it is applied. */
async follow(alive) {
this.stats.followed_at = this.now();
while (alive() && !this.stopped) {
let r;
try { r = await this.api(`/watch?room=${this.room}&from=${this.at}`); } catch { await sleep(1000); continue; }
if (r.status === 410) { this.stop("the room was replaced"); break; }
if (r.status !== 200 || !r.body) { await sleep(1000); continue; }
let changed = false;
for (const e of r.body.entries) {
if (e.seq !== this.at) { this.stop(`the log skipped from ${this.at} to ${e.seq}`); return; }
changed = (await this.onRecord(unhex(e.event))) || changed;
this.at++;
this.stats.log++;
}
if (changed) this.timeline = new Timeline(this.policyList, this.controls);
this.onChange();
}
}
refuse(why) {
this.stats.refused[why] = (this.stats.refused[why] || 0) + 1;
}
async onRecord(bytes) {
const v = JSON.parse(this.wai.eventRead(bytes));
if (!v.ok) { this.refuse(v.reason); return false; }
if (v.room !== this.room) { this.refuse("wrong_room"); return false; }
if (v.kind !== META) return false;
const s = readStatement(readEvent(bytes).payload);
if (!s) return false;
if (s.kind === "malformed") { this.refuse("cw_statement_malformed"); return false; }
if (s.kind === "control") {
this.stats.controls++;
const why = controlRefusal(this.policyList, v.t, s, (a) => this.mayControl(v.principal, a));
if (why) { this.refuse(why); return false; }
this.controls.push({ at: s.at, t: v.t, digest: v.digest, action: s.action, arg: s.arg, author: v.principal });
return true;
}
if (s.kind === "skew") {
this.skews.push(s);
if (this.skews.length > 64) this.skews.shift();
return false;
}
if (s.player !== v.principal) { this.refuse("presentation_wrong_key"); return false; }
this.others[v.principal] = { at: s.at, position: s.position, error: s.position - this.timeline.positionAt(s.at), state: s.state };
return false;
}
/** Keep the media at the controlled position (§9) and state what it presents (§7.3).
*
* Each pass of the loop states, if half a slot has passed since its last statement, then
* waits PASS_MS. Two statements are so at most half a slot and the gap between two passes
* apart, and while no such gap is over half a slot every slot holds one: a slot is left empty
* only if no pass began in its second half. The page records each gap between two passes
* over half a slot (`stalls`): `{ from, to }`, and how long the wait and the signing in it
* took (`timer_us`, `signing_us`), all on its estimate of the room clock. The rest of the gap
* is the loop's own work. A wait or a signing that took long is the page's thread held, by
* the page's own work or by its machine not running it; the record does not tell which. */
async run(alive) {
const per = (this.policy.period_ms * 1000) / STATES_PER_SLOT;
let next = 0;
let last = null; // the room clock at the previous pass
let timer = 0; // how long its wait and its signing took, on the room clock (µs)
let signing = 0;
while (alive() && !this.stopped) {
const x = this.now();
if (last != null && x - last > per) {
this.stats.stalls.push({ from: last, to: x, timer_us: timer, signing_us: signing });
if (this.stats.stalls.length > STALLS_KEPT) this.stats.stalls.shift();
}
last = x;
signing = 0;
const st = this.timeline.stateAt(x);
const duration = this.timeline.durationAt(x);
const controlled = this.timeline.positionAt(x);
const presented = Math.round(this.media.currentTime * 1e6);
if (st) {
if (st.playing && controlled < duration && this.media.paused) this.media.play().catch(() => {});
if ((!st.playing || controlled >= duration) && !this.media.paused) this.media.pause();
const c = catchUp(this.policy, st.playing, st.rate, controlled, duration, presented);
if (c.action === "seek") { this.media.currentTime = c.value / 1e6; this.stats.seeks++; }
else if (c.action === "rate") {
if (c.value !== st.rate) this.stats.nudges++;
if (Math.abs(this.media.playbackRate - c.value / 1e6) > 1e-6) this.media.playbackRate = c.value / 1e6;
}
this.error = presented - controlled;
this.controlled = controlled;
}
if (x >= next && this.key) {
next = x + per;
const ended = this.media.ended || (st && controlled >= duration);
const state = this.media.readyState < 3 && !this.media.paused ? STATE.stalled : ended ? STATE.ended : this.media.paused ? STATE.paused : STATE.playing;
const pos = Math.round(this.media.currentTime * 1e6);
const body = { room: this.room, player: this.principal, source: this.policy.source, at: x, position: pos, state, rate: Math.max(1, Math.round(this.media.playbackRate * 1e6)) };
const entries = this.stats.rejoins;
const s0 = this.now();
const signed = await signPresentation(body, this.key.privateKey);
signing = this.now() - s0;
if (this.stats.rejoins !== entries) {
// The page entered again while it signed (`flush`): `x` is an instant from before its
// present join, which it states nothing of. It states anew at once.
this.stats.dropped++;
next = 0;
} else {
this.outbox.push(this.event(x, signed));
this.stats.statements++;
this.flush();
}
}
this.onChange();
const w = this.now();
await this.wait(PASS_MS);
timer = this.now() - w;
}
}
/** The loop's wait between two passes. */
wait(ms) {
return sleep(ms);
}
/** What a test reads, and what the page shows. */
snapshot() {
const last = this.skews.length ? this.skews[this.skews.length - 1] : null;
const lastWindow = last ? this.skews.filter((s) => s.from === last.from) : [];
const wall = Date.now();
return {
principal: this.principal, joined: this.joinT, at: this.at, offset_us: this.offset, room_now: this.roomTime(wall), wall_ms: wall, pass_ms: PASS_MS, controls: this.controls.length, outbox: this.outbox.length,
overridden: this.controls.filter((c) => this.timeline.overridden.has(c.digest)).length,
controlled: this.controlled ?? null, error_us: this.error ?? null,
playing: !this.media.paused, rate: this.media.playbackRate, current_time: this.media.currentTime,
skews: this.skews.length, last_window: lastWindow.length ? { from: lastWindow[0].from, to: lastWindow[0].to, bound_us: lastWindow[0].bound_us, viewers: lastWindow.flatMap((s) => s.viewers), pairs: lastWindow.flatMap((s) => s.pairs) } : null,
others: this.others, stopped: this.stopped, ...this.stats,
};
}
}