test(native): gate one-viewer delivery evidence (#67)
This commit is contained in:
1 parent
1e11e09261
commit
671ad6f22e
12 files changed
+1822
-23
No files matched your search
@@ -52,14 +52,67 @@ authentication failure gets one retry with the same room/token/client generation
|
||||
and never another room POST. Server restart drops the room; SQLite stays at v2
|
||||
and ordinary Web rooms are unchanged. No claim-confirm message, durable native token, or recovery
|
||||
subsystem is added. Focused Go/TypeScript tests and static checks pass. The unused
|
||||
generic probe framework is deliberately not retained.
|
||||
No runtime/browser revalidation has run, so the product gate remains no-go and
|
||||
this local branch is not a product availability claim.
|
||||
generic probe framework is deliberately not retained. A current command consumer
|
||||
may keep only the minimum stage ledger needed by the next authorized run.
|
||||
|
||||
The first authorized current-wire run on 2026-08-20 remains
|
||||
`no-go-unclassified`. It used Windows amd64, Chrome 151, one real animated Chrome
|
||||
tab, and an isolated local Node server with an in-memory RoomStore and peer
|
||||
assistance disabled. Safe Chrome history booleans prove that the source and
|
||||
Sender pages loaded and that no Viewer page was created. The runner stopped
|
||||
after a bounded 20-second Sender-start interval without observing the combined
|
||||
Sender-ready and Go source-RTP condition.
|
||||
|
||||
The timeout path failed to preserve its final Sender DOM/counter sample. It
|
||||
therefore cannot distinguish capture selection, Host admission/create, WSS
|
||||
authentication, local bridge, first encoded output, or Go ingest as the first
|
||||
runtime break. None is a proven product failure. No retry, timeout adjustment,
|
||||
Viewer, second Viewer, or FIFO run followed. A separately authorized rerun must
|
||||
first make the stage-1 ledger unconditional and prove that timeout retains the
|
||||
last sanitized checkpoint; only a passed Sender/source-RTP stage may create the
|
||||
single Viewer. The product gate remains no-go and this local branch is not a
|
||||
product availability claim.
|
||||
|
||||
The retained current command consumer appends and flushes only monotonic,
|
||||
sanitized Sender-start fields. Its static one-Viewer gate now freezes one
|
||||
socket/auth/connection/PeerConnection/video-track generation and the matching
|
||||
Pion slot/edge generation, then revalidates that identity during media progress;
|
||||
raw connection IDs never leave the page probe. Every CDP RPC and sample shares
|
||||
its enclosing absolute deadline. `passed` is impossible until bounded Chrome
|
||||
and native process-tree exit, listener closure, and audited deletion of the
|
||||
exact task profile under system Temp all succeed; a reparse point or deletion
|
||||
failure is a fixed cleanup failure. Pure checks cover retained timeout evidence,
|
||||
hung samples, cross-generation rejection, cleanup-before-pass, and profile
|
||||
cleanup failure.
|
||||
|
||||
One separately authorized second run then used that frozen gate with the same
|
||||
bounded local topology. Its retained ledger proves `getDisplayMedia` request and
|
||||
resolution, Host admission and room creation, Host WSS authentication, local
|
||||
bridge readiness and fixed-`HIGH` config acceptance, exactly one encoder object,
|
||||
and entry into the first WebCodecs output callback. It retained zero Go
|
||||
`framesWritten`, source-RTP packets, and source-RTP bytes, so it stopped at
|
||||
`sender-start` and never created a Viewer. No retry or threshold adjustment
|
||||
followed. Cleanup completed before final failure: Chrome and Native exited, the
|
||||
Node server and all three loopback ports closed, and the exact task Chrome
|
||||
profile passed the non-reparse audit and was removed.
|
||||
|
||||
This remains `no-go-unclassified`. The frozen probe counted the first encoded
|
||||
chunk before invoking the product output callback and did not record whether the
|
||||
generation-bound local bridge attempted, returned from, or threw during its
|
||||
binary send. Static inspection finds matching 17-byte big-endian frame contracts
|
||||
on the Web and Go sides, but cannot convert that into runtime evidence. The proven
|
||||
break interval is therefore after WebCodecs output callback entry and before Go
|
||||
frame/source-RTP accounting, not a proven product bridge defect. The current
|
||||
unrun gate now records only bounded attempt/synchronous-return/synchronous-throw
|
||||
counters for the first local bridge generation and rechecks that identity at
|
||||
Viewer signal, Viewer media, and final success. Pure tests reject late
|
||||
replacement. A further run requires separate authorization. There is still no
|
||||
Viewer, two-viewer, FIFO, or physical/hardware-encoder proof.
|
||||
|
||||
## Staged Revalidation
|
||||
|
||||
A new run requires separate authorization and must stop at the first failed
|
||||
stage while retaining a bounded final-negative snapshot:
|
||||
stage while synchronously retaining a bounded final-negative snapshot:
|
||||
|
||||
1. Prove host room/auth, local config acknowledgement, one encoder object,
|
||||
bridge ingress, frame decode, source RTP, and zero critical errors.
|
||||
|
||||
@@ -23,7 +23,7 @@ Last updated: 2026-08-20
|
||||
- Use direct host P2P for one or two viewers. Keep ADR-0004 off broadly; use an isolated exact-room candidate until resource, quality, recovery, SFU/UDP, bounded-failure, and browser/mobile gates pass.
|
||||
- Browser relays resend remote `MediaStreamTrack` values and re-encode at each hop; WebRTC does not guarantee a shared encoder across peer connections, so measure the cost.
|
||||
- Keep experiments bounded: the standard representation sequence is below; native RTP relay, encoded-object striping, and FEC remain separate.
|
||||
- ADR-0006 stays no-go. Its v2 room is memory-only, capped by `ROOM_TTL` and rearmed 300s after Host disconnect; SQLite/Web unchanged. Pre-auth retries once in-room without another POST.
|
||||
- ADR-0006 stays no-go: the second run reached first WebCodecs output, then zero Go frame/RTP and no Viewer; cleanup passed 5/5. Its unrun gate rechecks one Viewer/Pion/bridge generation and cleanup-before-pass. No product bug or further run is authorized; Native v2 stays memory-only and leaves SQLite/Web unchanged.
|
||||
- Mobile Web Host is unsupported; feature-detect and fail clearly. Mobile Viewer stays leaf-only. After Windows native, gate Android 14+; iOS waits for stable iOS 27 ScreenCaptureKit.
|
||||
- ADR-0005 accepts direct-first Peer ICE and bounded SFU roots. Its default-off exact-room slice is undeployed; production/coturn stay STUN-only. PR #12/selected-edge superseded.
|
||||
- Keep the controller exact-room only: room `1` is the STUN/SFU smoke. Preserve sticky progressing P2P, mobile leaves and break-before-make. Recovery spends one attempt per layer (ICE restart, same-parent rebuild, alternate peer, then SFU), never three identical retries; active SFU gets one fresh grant before Peer failback.
|
||||
@@ -48,7 +48,6 @@ Last updated: 2026-08-20
|
||||
- Production logs show two root participants and two short Host participants with no service restart or retained track. Their timing is consistent with, but does not directly prove, one fresh-grant retry and Peer failback. The Host exposes only the local current-revision failure stage (`connect|source|video-publish|sender-config|audio-publish|transport`) and uploads no raw error or endpoint data. Browser relays still re-encode; native capture/hardware remains outside validated production behavior.
|
||||
- The retained v1 backup is the only schema rollback path. A failed artifact attempt exposed unsafe hard-linked dependency reuse; the successful immutable release has zero shared regular-file inodes with rollback, and future releases preserve that boundary.
|
||||
- Chrome 151/LiveKit localhost A/B cut failure-to-active/render from 1.481/2.257 seconds to 0.200/0.320; 31 new frames and 25 ms sampling kept host edges at two. It is headless synthetic 720p30 and includes SDK/network prewarm, not public-network evidence.
|
||||
- Source `main` at `f5d48ed` includes a memory-only Native v2 candidate, but production predates it and has no Native runtime gate. Render, hardware, two-edge/FIFO and TURN evidence remain absent.
|
||||
- Chrome 151 synthetic topology/quality-control runs kept fanout 2/1 and all viewers decoding; one relay close recovered in 5.32 seconds. This is control evidence only.
|
||||
- Room `1` publishes `HIGH+LOW` with Dynacast off and subscriber `HIGH` ceilings, and now has bounded local quality reparenting. Both remain unverified on real media; test zero-child leaves and calibrated C+B loss before broad rollout.
|
||||
- Generation-bound C+B needs three hard-bad windows; parent samples are one-use per connection. Pending SFU binds intent/guard. Stale/successful peer work releases quality exclusion; real failure may take it over. It uploads no raw endpoint metadata and computes no score.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- Scope: one Windows game-capture sender, one encoded video stream, and at most
|
||||
two independent standard WebRTC media edges
|
||||
- Status: stacked Draft PRs #16/#18/#22/#23/#25/#28 pass one bounded live
|
||||
two-leg WebCodecs/Pion candidate. The first product-wiring gate is
|
||||
two-leg WebCodecs/Pion candidate. Both product-wiring attempts remain
|
||||
no-go-unclassified; product and physical shared encoding remain unproven.
|
||||
|
||||
## Decision Input
|
||||
@@ -178,12 +178,93 @@ retry without a second room POST. SQLite remains v2 and ordinary Web creation is
|
||||
unchanged.
|
||||
HTTP statuses, Cookie attributes, create-room responses, Host authentication,
|
||||
and ignored Viewer evidence are strict and bounded; errors retain only fixed categories.
|
||||
Focused Go and TypeScript checks pass, but no Node/Chrome runtime gate has run.
|
||||
The overbroad unused gate/probe framework was deleted; a future authorized run
|
||||
must add only its executable minimum. This static checkpoint does not change the
|
||||
no-go result below.
|
||||
Focused Go and TypeScript checks pass. The overbroad unused gate/probe framework
|
||||
stays deleted; the current command consumer is limited to the executable runner
|
||||
and sanitized stage ledger required by the next authorized run.
|
||||
|
||||
The sole authorized 2026-08-19 local product gate is
|
||||
The one authorized 2026-08-20 current-wire attempt used Chrome 151 on Windows
|
||||
amd64, a temporary non-default Chrome profile, a real animated Chrome tab, and
|
||||
an isolated local Node server with an in-memory RoomStore and peer assistance
|
||||
disabled. It did not contact production or SQLite. Retained history booleans
|
||||
show that the source and Sender pages loaded and no Viewer page was created. The
|
||||
runner reported `failedStage=node-host` after its bounded 20-second Sender-start
|
||||
interval because it did not observe the combined Sender-ready and Go source-RTP
|
||||
condition.
|
||||
|
||||
This is `no-go-unclassified`, not evidence that the Node Host path itself
|
||||
failed. The timeout branch did not retain a final Sender DOM/counter sample, so
|
||||
it cannot locate the break among `getDisplayMedia` request/resolve, Host
|
||||
admission/create, WSS authentication, local bridge connection, first encoded
|
||||
chunk, or Go source RTP. There was no retry or timeout adjustment. Viewer auth,
|
||||
offer/answer/ICE, Pion bound-edge output, Viewer inbound/decode/render, two-edge,
|
||||
and FIFO evidence were never attempted. A later run needs separate authorization
|
||||
and an unconditional stage-1 final-negative ledger before it may create one
|
||||
Viewer.
|
||||
|
||||
That local-only runner is now the current `npm run gate:native-one-viewer`
|
||||
consumer. It requires an explicit Chrome executable environment path and uses
|
||||
the task-local Go executable or task-specific `PATH`; neither path is committed.
|
||||
Each monotonic Sender-start transition appends and flushes one bounded JSON
|
||||
record containing only booleans and capped counters. A pure timeout test proves
|
||||
that capture, admission/create, Host WSS, bridge, fixed-`HIGH`, first encoded
|
||||
chunk, Go-ingest, and source-RTP state cannot be erased by a later failed sample.
|
||||
The one-Viewer signaling/media waits likewise preserve their latest sanitized
|
||||
sample in the final report. They now accept evidence only from one frozen
|
||||
signaling socket/auth/opaque-connection ordinal, one PeerConnection/video-track
|
||||
ordinal, and the same Pion slot/edge generation across signal and media stages.
|
||||
The raw connection ID remains page-local. Every CDP RPC/sample is capped by the
|
||||
remaining stage deadline. Final status is written only after bounded process-tree
|
||||
exit and closed-port polling, followed by an exact system-Temp path audit,
|
||||
recursive reparse-point rejection, profile deletion, and absence verification;
|
||||
any cleanup failure is fail-closed. Microsoft documents `/T` as terminating a
|
||||
task's child processes and `ReparsePoint` as the filesystem attribute used for
|
||||
these special entries. Pure tests cover hung sampling, cross-PC evidence,
|
||||
cleanup-before-pass, profile path bounds, and deletion failure. Sources were
|
||||
rechecked 2026-08-20.
|
||||
|
||||
One separately authorized second current-wire run used the frozen gate once and
|
||||
did not retry or adjust a threshold. Its append-and-flush ledger reached sequence
|
||||
7 and retained positive booleans for capture request/resolution, Host admission,
|
||||
private room creation, Host WSS authentication, bridge readiness, fixed VP8
|
||||
1280x720@30/3 Mbps config acceptance, one encoder object, and entry into the
|
||||
first WebCodecs output callback. Go diagnostics remained at zero
|
||||
`framesWritten`, source-RTP packets, and source-RTP bytes. The runner therefore
|
||||
failed closed at `sender-start`; it never created the Viewer page and did not
|
||||
exercise Viewer auth, SDP/ICE, Pion bound-edge output, decode/render, a second
|
||||
Viewer, or FIFO. Cleanup was complete before the report was finalized: Chrome
|
||||
and Native exited, the isolated Node listener and all three random loopback
|
||||
ports closed, and the exact task profile passed a non-reparse audit and was
|
||||
removed.
|
||||
|
||||
The result narrows but does not classify the break. The gate increments its
|
||||
encoded-output observation before it calls the product output callback, and that
|
||||
run did not retain whether the current local media WebSocket attempted, returned
|
||||
from, or threw during the binary frame send. Static source comparison shows a
|
||||
matching binary message type, 17-byte big-endian header, one-MiB payload limit,
|
||||
and VP8 frame decode on both sides. It also confirms that Pion's unbound static RTP track
|
||||
returns no error. Those facts do not prove which runtime operation followed the
|
||||
callback. The retained first break interval is after WebCodecs output callback
|
||||
entry and before Go frame/source-RTP accounting. There is no uniquely proven
|
||||
product bug. The current unrun probe now binds the first actual local media
|
||||
WebSocket as gate-local generation 1 and records only bounded binary-send
|
||||
attempt, synchronous-return, and synchronous-throw counters. A second bridge
|
||||
generation saturates at 2, stops accumulating the first generation's counters,
|
||||
and fails the Viewer signal, Viewer media, and final-success checks even after
|
||||
Sender-start passed. A pure in-memory test covers success, throw, and late
|
||||
replacement without retaining a URL, payload, token, SDP, IP address, or raw
|
||||
error. No subsequent browser/media run is authorized or has occurred.
|
||||
|
||||
Chromium documents the tab-capture auto-selection switch as a test-only aid,
|
||||
and Chrome requires a non-default user-data directory for remote debugging from
|
||||
Chrome 136. Those constraints explain the isolated harness setup; they do not
|
||||
prove that `getDisplayMedia` resolved in the first failed current-wire run.
|
||||
Static inspection of the pinned Pion v4.2.18 `TrackLocalStaticRTP.WriteRTP` shows
|
||||
that it iterates the
|
||||
current bindings and returns no error for an empty binding set. This excludes a
|
||||
pre-Viewer no-binding write as the likely fatal break; it is not runtime proof
|
||||
of Go ingest or source RTP. Sources were rechecked 2026-08-20.
|
||||
|
||||
The earlier authorized 2026-08-19 local product gate is
|
||||
`no-go-unclassified`:
|
||||
|
||||
| Checkpoint | Retained evidence |
|
||||
@@ -309,8 +390,9 @@ diversity, lifecycle, and sustained CPU/GPU,
|
||||
memory, latency, quality, and upload measurements. A third host edge, custom
|
||||
RTP/SRTP, or a custom congestion-control framework remains out of scope. The
|
||||
#28 minimum-of-two policy is also out of scope for product code; retaining its
|
||||
evidence does not retain its policy. Selected-edge TURN is a separate future
|
||||
contract and gate, not a prerequisite silently added to this STUN-only canary.
|
||||
evidence does not retain its policy. Built-in exact-room Peer ICE TURN
|
||||
supersedes selected-edge as a separate Web source canary; Native stays STUN-only
|
||||
unless its own bounded capability and transport gate is accepted.
|
||||
|
||||
## Primary Sources And License Boundary
|
||||
|
||||
@@ -328,6 +410,7 @@ contract and gate, not a prerequisite silently added to this STUN-only canary.
|
||||
- [WebRTC video codec requirements, RFC 7742](https://www.rfc-editor.org/rfc/rfc7742.html)
|
||||
- [WebRTC congestion control requirements, RFC 8836](https://www.rfc-editor.org/rfc/rfc8836.html)
|
||||
- [Pion track fanout implementation](https://github.com/pion/webrtc/blob/main/track_local_static.go)
|
||||
- [Pion v4.2.18 pinned track implementation](https://github.com/pion/webrtc/blob/v4.2.18/track_local_static.go)
|
||||
- [Pion send-side bandwidth estimator](https://github.com/pion/interceptor/blob/main/pkg/gcc/send_side_bwe.go)
|
||||
- [Pion WebRTC license](https://github.com/pion/webrtc/blob/main/LICENSE)
|
||||
- [WebCodecs](https://www.w3.org/TR/webcodecs/)
|
||||
@@ -337,6 +420,10 @@ contract and gate, not a prerequisite silently added to this STUN-only canary.
|
||||
- [W3C WebRTC simulcast](https://www.w3.org/TR/webrtc/#simulcast-functionality)
|
||||
- [Pion WebRTC v4.2.18](https://github.com/pion/webrtc/tree/v4.2.18)
|
||||
- [Pion Interceptor v0.1.47](https://github.com/pion/interceptor/tree/v0.1.47)
|
||||
- [Chromium Chrome test switches](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/chrome/common/chrome_switches.cc)
|
||||
- [Chrome remote-debugging profile requirement](https://developer.chrome.com/blog/remote-debugging-port)
|
||||
- [Microsoft `taskkill`](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill)
|
||||
- [Microsoft `FileAttributes.ReparsePoint`](https://learn.microsoft.com/en-us/dotnet/api/system.io.fileattributes?view=net-10.0)
|
||||
- [RFC 4585 RTP/AVPF feedback](https://www.rfc-editor.org/rfc/rfc4585.html)
|
||||
- [RFC 4588 RTP retransmission](https://www.rfc-editor.org/rfc/rfc4588.html)
|
||||
- [RFC 8888 congestion-control feedback](https://www.rfc-editor.org/rfc/rfc8888.html)
|
||||
|
||||
+8
-8
@@ -11,7 +11,7 @@ Ordinary ICE is STUN-only process-wide; persistent room `1` alone enables the
|
||||
automatic peer/SFU-UDP controller with at most two SFU roots. The old
|
||||
`05f98d10ecd1` is the immediate rollback with the current v2 DB/env. Crossing
|
||||
the admission cutover requires `89e6d7649169` plus its recorded old environment;
|
||||
only deeper pre-access `9610032` may restore v1 state. Source `main` at `f5d48ed`
|
||||
only deeper pre-access `9610032` may restore v1 state. Source `main` at `a11a73d`
|
||||
also contains a no-go Native v2 candidate, but production does not run it.
|
||||
|
||||
## Current Snapshot
|
||||
@@ -19,7 +19,7 @@ also contains a no-go Native v2 candidate, but production does not run it.
|
||||
- Capture precedes room creation; source/quality changes preserve peers and pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options.
|
||||
- Production requires `HOST_ADMISSION_PASSWORD` only for creation/Host role and uses `screener-v2`, default private fragment grants, explicit public-watch, and generation-bound rotate/revoke. Viewers never need the Host secret; there is no account/session table or old parser.
|
||||
- SQLite v2 keeps Host and nullable Viewer-grant digests in one checked `STRICT` row. The stopped migration retained four rooms and locked each old room private without minting a raw grant.
|
||||
- Source-only Native v2 uses memory-only rooms, 300s post-disconnect reclaim and `ROOM_TTL`; it has no production gate.
|
||||
- Source-only Native v2 uses memory-only rooms, 300s reclaim and `ROOM_TTL`; production does not run it.
|
||||
- PR #44's exact-room STUN-only/SFU-UDP router is enabled only for production room `1`: host/SFU roots <=2, browser relay <=1, bounded failure. HTTPS/WSS stays TLS/TCP.
|
||||
- Fallback prewarm is token-free and limited to room `1`; mobile/iPad viewers are leaves and healthy edges stay sticky.
|
||||
- After an answer, a generation-bound 15s initial-connect deadline enters ICE restart; success/replacement/disposal cancels it. It is deployed but not mobile-verified.
|
||||
@@ -37,7 +37,7 @@ also contains a no-go Native v2 candidate, but production does not run it.
|
||||
- Host A+B and authenticated P2P Viewer C are sanitized, generation-bound, read-only, and fail closed for stale, ambiguous, or SFU-fed evidence; no raw media metadata is retained.
|
||||
- SVC is `no-go-web-svc-cross-path-hardware-contract`: no direct/peer selection, cross-PC shared encode, or portable hardware proof; pinned screen share is `L1T3`. No browser run was warranted.
|
||||
- Source TURN tests pass; production/coturn stay STUN-only and untested.
|
||||
- Draft #16/#18/#22/#23/#25/#28 passed isolated experiments only. ADR-0006 reached host setup/one encoder output, then timed out before viewer 1 decoded/rendered.
|
||||
- Native remains experimental. The second 2026-08-20 run reached first WebCodecs output, then zero Go frame/RTP and no Viewer; cleanup passed 5/5. No product bug or retry followed.
|
||||
- Access focused tests pass protocol/config/HTTP/storage/RoomStore/SQLite/signaling, including commit-first teardown, persistence-failure continuity, relay/SFU retirement, grant bounds, and v1 migration rollback.
|
||||
|
||||
## Unverified Boundaries
|
||||
@@ -47,7 +47,7 @@ also contains a no-go Native v2 candidate, but production does not run it.
|
||||
- Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this remains unverified.
|
||||
- Real audio and heterogeneous clients remain unverified. Production reports poor film audio and voice-call self-echo under system capture; there is no app audio ceiling or Web process isolation. Diagnose A/B/C and sync, then gate Windows 11 game-process audio; Windows 10 stays unresolved without system fallback.
|
||||
- The former release reportedly sustained bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not. The new deployment has not yet reproduced or cleared it.
|
||||
- ADR-0006 stays no-go: its v2 memory-only candidate passed static checks only; render, two-edge/FIFO and later TURN remain unverified.
|
||||
- ADR-0006 stays no-go: its runtime gap is WebCodecs output -> Go frame/RTP; the unrun bridge-generation probe rechecks signal/media/final. Viewer, two-edge/FIFO, and Native TURN remain unverified.
|
||||
- Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges.
|
||||
- PR #49/#50 BWE is unverified/default-off: test zero-child leaves, then root impact. No local per-leaf UDP shaper; resume with Linux `tc` or public canary, never CDP. Web P2P/SVC shortcuts remain no-go.
|
||||
- The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open.
|
||||
@@ -62,16 +62,16 @@ The recovery target uses one ICE restart, one same-parent rebuild, one alternate
|
||||
SFU; it never runs three identical retries or abandons progressing P2P early.
|
||||
|
||||
Next gate exactly-two/Dynacast-off BWE on a zero-child SFU leaf, then root
|
||||
evacuation. Native v2 remains no-go pending an authorized runtime gate; Audio
|
||||
A/B/C may proceed without displacing P0.
|
||||
evacuation. Native needs separate authorization; its next run gates Go RTP on
|
||||
the first bridge send.
|
||||
Audio A/B/C may proceed without displacing P0.
|
||||
Peer ICE TURN supersedes selected-edge but remains source-only pending an isolated coturn canary.
|
||||
|
||||
ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network,
|
||||
resource, quality, latency, recovery, and browser/mobile-leaf matrix. A separate
|
||||
20-viewer gate must pass before the current default eight changes to target 20.
|
||||
|
||||
ADR-0004 fails closed. Native shared encode and packet/layer striping remain
|
||||
separate experiments, not ways to relabel a failed browser route.
|
||||
ADR-0004 remains fail-closed; Native/striping experiments cannot relabel it.
|
||||
|
||||
## Blockers And Decisions
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/native/sender/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
sender, err := app.New()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "sender initialization failed")
|
||||
os.Exit(1)
|
||||
}
|
||||
defer sender.Close()
|
||||
|
||||
launchURL, err := sender.Start()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "sender startup failed")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err = json.NewEncoder(os.Stdout).Encode(map[string]string{"launchUrl": launchURL}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "sender handoff failed")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
_, _ = bufio.NewReader(os.Stdin).ReadByte()
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"build:server": "tsc -p tsconfig.server.json",
|
||||
"start": "node dist/server/server/index.js",
|
||||
"benchmark:peer-assisted": "tsx scripts/peer-assisted-benchmark.ts",
|
||||
"gate:native-one-viewer": "tsx scripts/native-one-viewer-gate.ts",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
|
||||
export interface ViewerIdentityEvidence {
|
||||
socketCount: number;
|
||||
socketOrdinal: number;
|
||||
authGeneration: number;
|
||||
connectionCount: number;
|
||||
connectionOrdinal: number;
|
||||
pcCount: number;
|
||||
pcOrdinal: number;
|
||||
trackCount: number;
|
||||
trackOrdinal: number;
|
||||
consistent: boolean;
|
||||
}
|
||||
|
||||
export interface SenderIdentityEvidence {
|
||||
peerCount: number;
|
||||
maxPeers: number;
|
||||
slot: number;
|
||||
edgeGeneration: number;
|
||||
}
|
||||
|
||||
export interface BridgeSendEvidence {
|
||||
bridgeGeneration: number;
|
||||
binarySendAttempts: number;
|
||||
binarySendSucceeded: number;
|
||||
binarySendFailed: number;
|
||||
}
|
||||
|
||||
export interface OneViewerIdentity {
|
||||
socketOrdinal: number;
|
||||
authGeneration: number;
|
||||
connectionOrdinal: number;
|
||||
pcOrdinal: number;
|
||||
trackOrdinal: number;
|
||||
pionSlot: number;
|
||||
pionEdgeGeneration: number;
|
||||
}
|
||||
|
||||
export interface CleanupResult {
|
||||
browserExited: boolean;
|
||||
nativeExited: boolean;
|
||||
serverClosed: boolean;
|
||||
portsClosed: boolean;
|
||||
profileRemoved: boolean;
|
||||
}
|
||||
|
||||
interface FinalizableReport {
|
||||
status: "passed" | "failed";
|
||||
failedStage: string | null;
|
||||
stages: Record<string, Record<string, boolean | number | string | null> | undefined>;
|
||||
}
|
||||
|
||||
export function captureOneViewerIdentity(
|
||||
viewer: ViewerIdentityEvidence,
|
||||
sender: SenderIdentityEvidence,
|
||||
): OneViewerIdentity | null {
|
||||
if (!viewer.consistent || viewer.socketCount !== 1 || viewer.authGeneration !== 1 ||
|
||||
viewer.connectionCount !== 1 || viewer.pcCount !== 1 || viewer.trackCount !== 1 ||
|
||||
sender.peerCount !== 1 || sender.maxPeers > 1 || sender.edgeGeneration !== 1 ||
|
||||
viewer.socketOrdinal !== 1 || viewer.connectionOrdinal !== 1 || viewer.pcOrdinal !== 1 ||
|
||||
viewer.trackOrdinal !== 1 || sender.slot <= 0) return null;
|
||||
return {
|
||||
socketOrdinal: viewer.socketOrdinal,
|
||||
authGeneration: viewer.authGeneration,
|
||||
connectionOrdinal: viewer.connectionOrdinal,
|
||||
pcOrdinal: viewer.pcOrdinal,
|
||||
trackOrdinal: viewer.trackOrdinal,
|
||||
pionSlot: sender.slot,
|
||||
pionEdgeGeneration: sender.edgeGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
export function retainsOneViewerIdentity(
|
||||
baseline: OneViewerIdentity,
|
||||
viewer: ViewerIdentityEvidence,
|
||||
sender: SenderIdentityEvidence,
|
||||
): boolean {
|
||||
const current = captureOneViewerIdentity(viewer, sender);
|
||||
return current !== null && Object.keys(baseline).every(
|
||||
(key) => current[key as keyof OneViewerIdentity] === baseline[key as keyof OneViewerIdentity],
|
||||
);
|
||||
}
|
||||
|
||||
export function retainsFirstBridgeSend(evidence: BridgeSendEvidence): boolean {
|
||||
return evidence.bridgeGeneration === 1 && evidence.binarySendAttempts > 0 &&
|
||||
evidence.binarySendSucceeded > 0 && evidence.binarySendFailed === 0;
|
||||
}
|
||||
|
||||
export function withDeadline<T>(
|
||||
operation: () => Promise<T>,
|
||||
deadline: number,
|
||||
now: () => number = Date.now,
|
||||
): Promise<T> {
|
||||
const remaining = Math.max(0, deadline - now());
|
||||
return new Promise<T>((resolveValue, rejectValue) => {
|
||||
const timeout = setTimeout(() => rejectValue(new Error("Operation deadline exceeded")), remaining);
|
||||
Promise.resolve().then(operation).then(
|
||||
(value) => { clearTimeout(timeout); resolveValue(value); },
|
||||
(error: unknown) => { clearTimeout(timeout); rejectValue(error); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
type JsonFetch = (
|
||||
input: string,
|
||||
init: { signal: AbortSignal },
|
||||
) => Promise<{ ok: boolean; json(): Promise<unknown> }>;
|
||||
|
||||
export async function fetchJsonBefore<T>(
|
||||
url: string,
|
||||
deadline: number,
|
||||
fetcher: JsonFetch = fetch,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const abort = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now()));
|
||||
try {
|
||||
const response = await withDeadline(
|
||||
() => fetcher(url, { signal: controller.signal }),
|
||||
deadline,
|
||||
);
|
||||
if (!response.ok) throw new Error("HTTP response was not successful");
|
||||
return await withDeadline(() => response.json() as Promise<T>, deadline);
|
||||
} finally {
|
||||
clearTimeout(abort);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForSample<T>(
|
||||
sample: (deadline: number) => Promise<T>,
|
||||
retainAndAccept: (value: T) => boolean,
|
||||
timeoutMs: number,
|
||||
pollMs = 100,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
let value: T | undefined;
|
||||
try { value = await withDeadline(() => sample(deadline), deadline); } catch {}
|
||||
if (value !== undefined && retainAndAccept(value)) return;
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
|
||||
}
|
||||
throw new Error("Bounded browser stage timed out");
|
||||
}
|
||||
|
||||
export async function finalizeGate(
|
||||
report: FinalizableReport,
|
||||
gateSucceeded: boolean,
|
||||
cleanup: () => Promise<CleanupResult>,
|
||||
): Promise<void> {
|
||||
let result: CleanupResult;
|
||||
try {
|
||||
result = await cleanup();
|
||||
} catch {
|
||||
result = { browserExited: false, nativeExited: false, serverClosed: false,
|
||||
portsClosed: false, profileRemoved: false };
|
||||
}
|
||||
const complete = Object.values(result).every(Boolean);
|
||||
report.stages.cleanup = { ...result, complete };
|
||||
if (gateSucceeded && complete) {
|
||||
report.status = "passed";
|
||||
report.failedStage = null;
|
||||
} else if (!complete) {
|
||||
report.status = "failed";
|
||||
report.failedStage = "cleanup";
|
||||
}
|
||||
}
|
||||
|
||||
export function isExactGateProfile(profile: string, systemTemp: string): boolean {
|
||||
const candidate = resolve(profile);
|
||||
const temp = resolve(systemTemp);
|
||||
const same = process.platform === "win32"
|
||||
? dirname(candidate).toLowerCase() === temp.toLowerCase()
|
||||
: dirname(candidate) === temp;
|
||||
return same && /^screener-native-one-viewer-[A-Za-z0-9_-]{6}$/.test(basename(candidate));
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { closeSync, fsyncSync, openSync, writeFileSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { createConnection, createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import WebSocket from "ws";
|
||||
import { createScreenerServer, type ScreenerServer } from "../src/server/app";
|
||||
import { loadConfig } from "../src/server/config";
|
||||
import {
|
||||
RoomStore,
|
||||
type ConnectedParticipant,
|
||||
type ConnectParticipantInput,
|
||||
type RoomStoreOptions,
|
||||
} from "../src/server/room-store";
|
||||
import {
|
||||
SenderStartLedger,
|
||||
waitForSenderStart,
|
||||
type SenderStartObservation,
|
||||
} from "./native-one-viewer-ledger";
|
||||
import {
|
||||
captureOneViewerIdentity,
|
||||
fetchJsonBefore,
|
||||
finalizeGate,
|
||||
isExactGateProfile,
|
||||
retainsFirstBridgeSend,
|
||||
retainsOneViewerIdentity,
|
||||
waitForSample,
|
||||
withDeadline,
|
||||
type CleanupResult,
|
||||
type OneViewerIdentity,
|
||||
type SenderIdentityEvidence,
|
||||
type ViewerIdentityEvidence,
|
||||
} from "./native-one-viewer-gate-core";
|
||||
|
||||
type Stage = "preflight" | "sender-start" | "node-host" | "viewer-signal" | "viewer-media" | "cleanup";
|
||||
|
||||
interface PageHandle {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface CdpResult<T> {
|
||||
result: { value?: T };
|
||||
exceptionDetails?: unknown;
|
||||
}
|
||||
|
||||
interface GateReport {
|
||||
schemaVersion: 1;
|
||||
status: "passed" | "failed";
|
||||
failedStage: Stage | null;
|
||||
stages: Record<string, Record<string, boolean | number | string | null> | undefined>;
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
interface SenderSnapshot {
|
||||
getDisplayMediaRequested: boolean;
|
||||
getDisplayMediaResolved: boolean;
|
||||
ready: number;
|
||||
configAccepted: number;
|
||||
encoderInstances: number;
|
||||
encoderOutputs: number;
|
||||
encoderErrors: number;
|
||||
fatalEvents: number;
|
||||
bridgeGeneration: number;
|
||||
binarySendAttempts: number;
|
||||
binarySendSucceeded: number;
|
||||
binarySendFailed: number;
|
||||
activeViewers: number;
|
||||
maxPeers: number;
|
||||
config: {
|
||||
codec: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
fps: number | null;
|
||||
bitrate: number | null;
|
||||
encoderInstances: number | null;
|
||||
} | null;
|
||||
media: {
|
||||
framesWritten: number;
|
||||
sourceRtpPacketsWritten: number;
|
||||
sourceRtpBytesWritten: number;
|
||||
} | null;
|
||||
peers: Array<{
|
||||
slot: number;
|
||||
edgeGeneration: number;
|
||||
connectionState: string;
|
||||
iceConnectionState: string;
|
||||
route: string;
|
||||
packetsSent: number;
|
||||
bytesSent: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ViewerSnapshot {
|
||||
signaling: {
|
||||
authenticateSent: number;
|
||||
authenticatedReceived: number;
|
||||
offersReceived: number;
|
||||
answersSent: number;
|
||||
candidatesReceived: number;
|
||||
candidatesSent: number;
|
||||
};
|
||||
identity: ViewerIdentityEvidence;
|
||||
connectionState: string;
|
||||
iceConnectionState: string;
|
||||
packetsReceived: number;
|
||||
bytesReceived: number;
|
||||
framesDecoded: number;
|
||||
videoReadyState: number;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
videoCurrentTime: number;
|
||||
renderedFrames: number;
|
||||
}
|
||||
|
||||
class CdpConnection {
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<number, {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}>();
|
||||
|
||||
private constructor(private readonly socket: WebSocket) {
|
||||
socket.on("message", (raw) => this.onMessage(raw.toString()));
|
||||
socket.on("close", () => this.closePending());
|
||||
socket.on("error", () => this.closePending());
|
||||
}
|
||||
|
||||
static async connect(url: string, deadline: number): Promise<CdpConnection> {
|
||||
const socket = new WebSocket(url, { maxPayload: 16 * 1024 * 1024 });
|
||||
try {
|
||||
await withDeadline(() => new Promise<void>((resolveOpen, rejectOpen) => {
|
||||
socket.once("open", resolveOpen);
|
||||
socket.once("error", rejectOpen);
|
||||
}), deadline);
|
||||
} catch (error) {
|
||||
socket.terminate();
|
||||
throw error;
|
||||
}
|
||||
return new CdpConnection(socket);
|
||||
}
|
||||
|
||||
call<T>(method: string, params: Record<string, unknown>, sessionId: string | undefined, deadline: number): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
return new Promise<T>((resolveCall, rejectCall) => {
|
||||
const remaining = Math.max(0, deadline - Date.now());
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
rejectCall(new Error("CDP command timed out"));
|
||||
}, remaining);
|
||||
this.pending.set(id, {
|
||||
resolve: (value) => { clearTimeout(timeout); resolveCall(value as T); },
|
||||
reject: (error) => { clearTimeout(timeout); rejectCall(error); },
|
||||
timeout,
|
||||
});
|
||||
this.socket.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.socket.close();
|
||||
}
|
||||
|
||||
private onMessage(raw: string): void {
|
||||
let message: { id?: number; result?: unknown; error?: unknown };
|
||||
try {
|
||||
message = JSON.parse(raw) as typeof message;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.id === undefined) return;
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(new Error("CDP command failed"));
|
||||
} else {
|
||||
pending.resolve(message.result ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
private closePending(): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error("CDP connection closed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
|
||||
class ObservedRoomStore extends RoomStore {
|
||||
constructor(options: RoomStoreOptions, private readonly onHostAuthenticated: () => void) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
override connectParticipant(input: ConnectParticipantInput): ConnectedParticipant {
|
||||
const participant = super.connectParticipant(input);
|
||||
if (input.role === "host") this.onHostAuthenticated();
|
||||
return participant;
|
||||
}
|
||||
}
|
||||
|
||||
function observeNodeStages(server: ScreenerServer, ledger: SenderStartLedger): void {
|
||||
server.httpServer.prependListener("request", (request, response) => {
|
||||
const path = new URL(request.url ?? "/", "http://gate.invalid").pathname;
|
||||
if (request.method === "POST" && path === "/api/host-admission") {
|
||||
response.once("finish", () => {
|
||||
if (response.statusCode === 200) ledger.record({ hostAdmissionAccepted: true });
|
||||
});
|
||||
} else if (request.method === "POST" && path === "/api/rooms") {
|
||||
response.once("finish", () => {
|
||||
if (response.statusCode === 201) ledger.record({ roomCreated: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function senderStartObservation(snapshot: SenderSnapshot): Partial<SenderStartObservation> {
|
||||
return {
|
||||
getDisplayMediaRequested: snapshot.getDisplayMediaRequested,
|
||||
getDisplayMediaResolved: snapshot.getDisplayMediaResolved,
|
||||
bridgeConnected: snapshot.ready > 0,
|
||||
fixedHighConfigured: snapshot.configAccepted > 0 &&
|
||||
snapshot.config?.codec === "vp8" && snapshot.config.width === 1280 &&
|
||||
snapshot.config.height === 720 && snapshot.config.fps === 30 &&
|
||||
snapshot.config.bitrate === 3_000_000 && snapshot.config.encoderInstances === 1,
|
||||
firstEncodedChunk: snapshot.encoderOutputs > 0,
|
||||
bridgeGeneration: snapshot.bridgeGeneration,
|
||||
binarySendAttempts: snapshot.binarySendAttempts,
|
||||
binarySendSucceeded: snapshot.binarySendSucceeded,
|
||||
binarySendFailed: snapshot.binarySendFailed,
|
||||
encoderInstances: snapshot.encoderInstances,
|
||||
framesWritten: snapshot.media?.framesWritten ?? 0,
|
||||
sourceRtpPacketsWritten: snapshot.media?.sourceRtpPacketsWritten ?? 0,
|
||||
sourceRtpBytesWritten: snapshot.media?.sourceRtpBytesWritten ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function requiredEnvironmentPath(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function appendLedgerRecord(path: string, record: object): void {
|
||||
const descriptor = openSync(path, "a");
|
||||
try {
|
||||
writeFileSync(descriptor, `${JSON.stringify(record)}\n`, { encoding: "utf8" });
|
||||
fsyncSync(descriptor);
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const chromePath = requiredEnvironmentPath("SCREENER_NATIVE_GATE_CHROME");
|
||||
const goPath = process.env.SCREENER_NATIVE_GATE_GO?.trim() || "go";
|
||||
const accessKey = randomBytes(9).toString("base64url");
|
||||
const report: GateReport = {
|
||||
schemaVersion: 1,
|
||||
status: "failed",
|
||||
failedStage: "preflight",
|
||||
stages: {},
|
||||
limitations: [
|
||||
"The source is a real animated Chrome tab captured through getDisplayMedia, not a game workload.",
|
||||
"This run proves one WebCodecs object, not one physical or hardware encoder.",
|
||||
"The loopback run does not prove public STUN, restrictive networks, TURN, audio, packaging, or endurance.",
|
||||
],
|
||||
};
|
||||
let server: ScreenerServer | null = null;
|
||||
let native: ChildProcessWithoutNullStreams | null = null;
|
||||
let chrome: ChildProcessWithoutNullStreams | null = null;
|
||||
let cdp: CdpConnection | null = null;
|
||||
let profile: string | null = null;
|
||||
let ledgerPath: string | null = null;
|
||||
let appPort: number | null = null;
|
||||
let debugPort: number | null = null;
|
||||
let nativePort: number | null = null;
|
||||
let gateSucceeded = false;
|
||||
const senderLedger = new SenderStartLedger((record) => {
|
||||
if (!ledgerPath) return;
|
||||
appendLedgerRecord(ledgerPath, record);
|
||||
});
|
||||
|
||||
try {
|
||||
appPort = await reservePort();
|
||||
debugPort = await reservePort();
|
||||
profile = await mkdtemp(join(tmpdir(), "screener-native-one-viewer-"));
|
||||
ledgerPath = join(profile, "sender-start-ledger.jsonl");
|
||||
writeFileSync(ledgerPath, "", { encoding: "utf8", flag: "wx" });
|
||||
const baseUrl = `http://127.0.0.1:${appPort}`;
|
||||
const config = loadConfig({
|
||||
NODE_ENV: "development",
|
||||
PORT: String(appPort),
|
||||
LISTEN_HOST: "127.0.0.1",
|
||||
PUBLIC_BASE_URL: baseUrl,
|
||||
ALLOWED_ORIGINS: baseUrl,
|
||||
HOST_ADMISSION_PASSWORD: accessKey,
|
||||
ROOM_DATABASE_PATH: "",
|
||||
PEER_ASSISTED_MEDIA: "false",
|
||||
PEER_ASSISTED_ROOM_IDS: "",
|
||||
STUN_URLS: "",
|
||||
});
|
||||
const roomStore = new ObservedRoomStore({
|
||||
ttlMs: config.roomTtlMs,
|
||||
maxRooms: config.maxRooms,
|
||||
maxViewersPerRoom: config.maxViewersPerRoom,
|
||||
}, () => senderLedger.record({ hostWssAuthenticated: true }));
|
||||
server = await createScreenerServer({ config, roomStore });
|
||||
observeNodeStages(server, senderLedger);
|
||||
await server.listen(appPort, "127.0.0.1");
|
||||
|
||||
native = spawn(goPath, ["run", "./cmd/screener-one-viewer-gate"], {
|
||||
cwd: join(root, "native", "sender"),
|
||||
stdio: "pipe",
|
||||
windowsHide: true,
|
||||
});
|
||||
native.stderr.resume();
|
||||
const launchUrl = await readLaunchUrl(native);
|
||||
nativePort = Number(new URL(launchUrl).port) || null;
|
||||
chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${debugPort}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-extensions",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--auto-select-tab-capture-source-by-title=Native Gate Source",
|
||||
"--window-size=1280,900",
|
||||
"about:blank",
|
||||
], { stdio: "pipe", windowsHide: true });
|
||||
chrome.stdout.resume();
|
||||
chrome.stderr.resume();
|
||||
const version = await waitForVersion(debugPort, chrome);
|
||||
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 5_000);
|
||||
report.stages.preflight = {
|
||||
serverInMemory: config.roomDatabasePath === undefined,
|
||||
peerAssistedDisabled: !config.peerAssistedMedia,
|
||||
oneViewerOnly: true,
|
||||
chromeMajor: Number(version.Browser.match(/Chrome\/(\d+)/)?.[1] ?? 0),
|
||||
};
|
||||
report.failedStage = "sender-start";
|
||||
|
||||
await createPage(cdp, animatedSourceUrl());
|
||||
const senderPage = await createPage(cdp, launchUrl, senderProbe());
|
||||
await evaluate<void>(cdp, senderPage, `(() => {
|
||||
document.querySelector('#server-url').value = ${JSON.stringify(baseUrl)};
|
||||
document.querySelector('#password').value = ${JSON.stringify(accessKey)};
|
||||
document.querySelector('#start').click();
|
||||
})()`, Date.now() + 5_000);
|
||||
await waitForSenderStart(
|
||||
async (deadline) => senderStartObservation(await senderSnapshot(cdp!, senderPage, deadline)),
|
||||
senderLedger,
|
||||
{ timeoutMs: 20_000 },
|
||||
);
|
||||
const senderReady = await senderSnapshot(cdp, senderPage, Date.now() + 3_000);
|
||||
const senderStartChecks = {
|
||||
oneEncoderObject: senderReady.encoderInstances === 1,
|
||||
currentBridge: retainsFirstBridgeSend(senderReady),
|
||||
noCriticalErrors: senderReady.encoderErrors === 0 && senderReady.fatalEvents === 0,
|
||||
};
|
||||
report.stages["sender-start"] = { ...senderLedger.snapshot(), ...senderStartChecks };
|
||||
requireChecks(senderStartChecks);
|
||||
report.failedStage = "node-host";
|
||||
const inviteUrl = await evaluate<string>(cdp, senderPage,
|
||||
`document.querySelector('#invite-link').href`, Date.now() + 3_000);
|
||||
const invite = new URL(inviteUrl);
|
||||
const roomId = invite.pathname.match(/^\/r\/([1-9][0-9]{0,11})$/)?.[1] ?? "";
|
||||
const viewerGrant = new URLSearchParams(invite.hash.slice(1)).get("v") ?? "";
|
||||
const nodeHost = {
|
||||
privateGrantBoundToRoom: new RegExp(`^g1\\.${roomId}\\.[1-9][0-9]{0,12}\\.[A-Za-z0-9_-]{43}$`).test(viewerGrant),
|
||||
provisionalRandomRoom: roomId !== "1",
|
||||
};
|
||||
report.stages["node-host"] = nodeHost;
|
||||
requireChecks(nodeHost);
|
||||
|
||||
report.failedStage = "viewer-signal";
|
||||
const viewerPage = await createPage(cdp, inviteUrl, viewerProbe());
|
||||
let identity: OneViewerIdentity | null = null;
|
||||
await waitForSample(async (deadline) => ({
|
||||
viewer: await viewerSnapshot(cdp!, viewerPage, deadline),
|
||||
sender: await senderSnapshot(cdp!, senderPage, deadline),
|
||||
}), ({ viewer, sender }) => {
|
||||
identity = captureOneViewerIdentity(viewer.identity, senderIdentity(sender));
|
||||
const checks = {
|
||||
viewerAuthenticated: viewer.signaling.authenticateSent === 1 &&
|
||||
viewer.signaling.authenticatedReceived === 1,
|
||||
peerJoined: sender.activeViewers === 1 && roomStore.getConnectedViewers(roomId).length === 1,
|
||||
offerAnswer: viewer.signaling.offersReceived >= 1 && viewer.signaling.answersSent >= 1,
|
||||
iceExchange: viewer.signaling.candidatesReceived >= 1 && viewer.signaling.candidatesSent >= 1,
|
||||
connected: viewer.connectionState === "connected" &&
|
||||
["connected", "completed"].includes(viewer.iceConnectionState),
|
||||
pionConnected: sender.peers.length === 1 && sender.peers[0]?.connectionState === "connected" &&
|
||||
["connected", "completed"].includes(sender.peers[0]?.iceConnectionState ?? ""),
|
||||
sameGeneration: identity !== null,
|
||||
sameBridge: retainsFirstBridgeSend(sender),
|
||||
edgeBound: sender.maxPeers <= 1 && sender.peers.length === 1,
|
||||
};
|
||||
report.stages["viewer-signal"] = {
|
||||
...checks,
|
||||
offersReceived: viewer.signaling.offersReceived,
|
||||
answersSent: viewer.signaling.answersSent,
|
||||
candidatesReceived: viewer.signaling.candidatesReceived,
|
||||
candidatesSent: viewer.signaling.candidatesSent,
|
||||
socketOrdinal: identity?.socketOrdinal ?? 0,
|
||||
authGeneration: identity?.authGeneration ?? 0,
|
||||
connectionOrdinal: identity?.connectionOrdinal ?? 0,
|
||||
pcOrdinal: identity?.pcOrdinal ?? 0,
|
||||
trackOrdinal: identity?.trackOrdinal ?? 0,
|
||||
pionSlot: identity?.pionSlot ?? -1,
|
||||
pionEdgeGeneration: identity?.pionEdgeGeneration ?? 0,
|
||||
};
|
||||
return Object.values(checks).every(Boolean);
|
||||
}, 20_000);
|
||||
|
||||
report.failedStage = "viewer-media";
|
||||
if (!identity) throw new Error("Viewer generation was not established");
|
||||
const mediaBaselineDeadline = Date.now() + 3_000;
|
||||
const [beforeViewer, beforeSender] = await Promise.all([
|
||||
viewerSnapshot(cdp, viewerPage, mediaBaselineDeadline),
|
||||
senderSnapshot(cdp, senderPage, mediaBaselineDeadline),
|
||||
]);
|
||||
if (!retainsOneViewerIdentity(identity, beforeViewer.identity, senderIdentity(beforeSender))) {
|
||||
throw new Error("Viewer generation changed before media sampling");
|
||||
}
|
||||
await waitForSample(async (deadline) => ({
|
||||
viewer: await viewerSnapshot(cdp!, viewerPage, deadline),
|
||||
sender: await senderSnapshot(cdp!, senderPage, deadline),
|
||||
}), ({ viewer, sender }) => {
|
||||
const checks = {
|
||||
inboundPackets: viewer.packetsReceived > beforeViewer.packetsReceived,
|
||||
inboundBytes: viewer.bytesReceived > beforeViewer.bytesReceived,
|
||||
decodedFrames: viewer.framesDecoded > beforeViewer.framesDecoded,
|
||||
videoReady: viewer.videoReadyState >= 2 && viewer.videoWidth > 0 && viewer.videoHeight > 0,
|
||||
playbackClock: viewer.videoCurrentTime > beforeViewer.videoCurrentTime,
|
||||
renderedFrames: viewer.renderedFrames > beforeViewer.renderedFrames,
|
||||
pionOutbound: (sender.peers[0]?.packetsSent ?? 0) > (beforeSender.peers[0]?.packetsSent ?? 0) &&
|
||||
(sender.peers[0]?.bytesSent ?? 0) > (beforeSender.peers[0]?.bytesSent ?? 0),
|
||||
sameGeneration: retainsOneViewerIdentity(identity!, viewer.identity, senderIdentity(sender)),
|
||||
sameBridge: retainsFirstBridgeSend(sender),
|
||||
oneEncoderObject: sender.encoderInstances === 1,
|
||||
criticalErrors: sender.fatalEvents === 0 && sender.encoderErrors === 0,
|
||||
};
|
||||
report.stages["viewer-media"] = {
|
||||
...checks,
|
||||
packetDelta: viewer.packetsReceived - beforeViewer.packetsReceived,
|
||||
byteDelta: viewer.bytesReceived - beforeViewer.bytesReceived,
|
||||
decodedDelta: viewer.framesDecoded - beforeViewer.framesDecoded,
|
||||
renderedDelta: viewer.renderedFrames - beforeViewer.renderedFrames,
|
||||
pionPacketDelta: (sender.peers[0]?.packetsSent ?? 0) - (beforeSender.peers[0]?.packetsSent ?? 0),
|
||||
pcOrdinal: viewer.identity.pcOrdinal,
|
||||
pionSlot: sender.peers[0]?.slot ?? -1,
|
||||
pionEdgeGeneration: sender.peers[0]?.edgeGeneration ?? 0,
|
||||
route: safeRoute(sender.peers[0]?.route),
|
||||
videoWidth: viewer.videoWidth,
|
||||
videoHeight: viewer.videoHeight,
|
||||
};
|
||||
return Object.values(checks).every(Boolean);
|
||||
}, 10_000);
|
||||
const finalSender = await senderSnapshot(cdp, senderPage, Date.now() + 3_000);
|
||||
const finalChecks = { finalBridge: retainsFirstBridgeSend(finalSender) };
|
||||
report.stages["viewer-media"] = { ...report.stages["viewer-media"], ...finalChecks };
|
||||
requireChecks(finalChecks);
|
||||
gateSucceeded = true;
|
||||
} catch {
|
||||
// The bounded report retains only the first stage and sanitized counters.
|
||||
} finally {
|
||||
report.stages["sender-start"] = {
|
||||
...report.stages["sender-start"],
|
||||
...senderLedger.snapshot(),
|
||||
};
|
||||
await finalizeGate(report, gateSucceeded, () => cleanupRun({
|
||||
cdp, native, chrome, server, profile,
|
||||
ports: [appPort, debugPort, nativePort].filter((port): port is number => port !== null),
|
||||
}));
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (report.status === "failed") process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function senderProbe(): string {
|
||||
return `(() => {
|
||||
const state = { getDisplayMediaRequested: false, getDisplayMediaResolved: false,
|
||||
ready: 0, configAccepted: 0,
|
||||
encoderInstances: 0, encoderOutputs: 0, encoderErrors: 0, fatalEvents: 0,
|
||||
bridgeGeneration: 0, binarySendAttempts: 0, binarySendSucceeded: 0, binarySendFailed: 0,
|
||||
activeViewers: 0, maxPeers: 0, edgeGeneration: 0, activeEdgeSlot: null,
|
||||
config: null, media: null, peers: [] };
|
||||
const finite = (value) => Number.isFinite(value) ? value : 0;
|
||||
const mark = (key) => { state[key] = 1; };
|
||||
let activeBridgeSocket = null;
|
||||
const nativeGetDisplayMedia = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices);
|
||||
Object.defineProperty(navigator.mediaDevices, 'getDisplayMedia', {
|
||||
configurable: true,
|
||||
value: (...args) => {
|
||||
state.getDisplayMediaRequested = true;
|
||||
return nativeGetDisplayMedia(...args).then((stream) => {
|
||||
state.getDisplayMediaResolved = true;
|
||||
return stream;
|
||||
});
|
||||
},
|
||||
});
|
||||
const NativeWebSocket = globalThis.WebSocket;
|
||||
globalThis.WebSocket = class GateWebSocket extends NativeWebSocket {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.__gateMedia = new URL(String(args[0]), location.href).pathname === '/media';
|
||||
if (!this.__gateMedia) return;
|
||||
state.bridgeGeneration = Math.min(2, state.bridgeGeneration + 1);
|
||||
this.__gateBridgeGeneration = state.bridgeGeneration;
|
||||
activeBridgeSocket = state.bridgeGeneration === 1 ? this : null;
|
||||
this.addEventListener('message', (event) => {
|
||||
if (typeof event.data !== 'string') return;
|
||||
let message; try { message = JSON.parse(event.data); } catch { return; }
|
||||
if (message.kind === 'ready') state.ready += 1;
|
||||
if (message.kind === 'config-accepted') state.configAccepted += 1;
|
||||
if (message.kind === 'fatal') state.fatalEvents += 1;
|
||||
if (message.kind === 'ready' || message.kind === 'viewer-count') {
|
||||
state.activeViewers = finite(message.activeViewers);
|
||||
}
|
||||
if (message.kind === 'diagnostics') {
|
||||
state.media = message.media ? {
|
||||
framesWritten: finite(message.media.framesWritten),
|
||||
sourceRtpPacketsWritten: finite(message.media.sourceRtpPacketsWritten),
|
||||
sourceRtpBytesWritten: finite(message.media.sourceRtpBytesWritten),
|
||||
} : null;
|
||||
const peers = Array.isArray(message.peers) ? message.peers.slice(0, 3) : [];
|
||||
const nextSlot = peers.length === 1 ? finite(peers[0].slot) : null;
|
||||
if (nextSlot !== null && nextSlot !== state.activeEdgeSlot) state.edgeGeneration += 1;
|
||||
state.activeEdgeSlot = nextSlot;
|
||||
state.peers = peers.map((peer) => ({
|
||||
slot: finite(peer.slot), edgeGeneration: state.edgeGeneration,
|
||||
connectionState: String(peer.connectionState || 'unknown'),
|
||||
iceConnectionState: String(peer.iceConnectionState || 'unknown'),
|
||||
route: String(peer.route || 'unknown'),
|
||||
packetsSent: finite(peer.packetsSent), bytesSent: finite(peer.bytesSent),
|
||||
}));
|
||||
state.maxPeers = Math.max(state.maxPeers, state.peers.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
send(value) {
|
||||
if (this.__gateMedia && this === activeBridgeSocket &&
|
||||
this.__gateBridgeGeneration === 1 && value instanceof ArrayBuffer) {
|
||||
mark('binarySendAttempts');
|
||||
try {
|
||||
const result = super.send(value);
|
||||
mark('binarySendSucceeded');
|
||||
return result;
|
||||
} catch (error) {
|
||||
mark('binarySendFailed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (this.__gateMedia) {
|
||||
if (typeof value === 'string') {
|
||||
let message; try { message = JSON.parse(value); } catch { message = null; }
|
||||
if (message?.kind === 'config') state.config = {
|
||||
codec: typeof message.codec === 'string' ? message.codec : null,
|
||||
width: finite(message.width), height: finite(message.height), fps: finite(message.fps),
|
||||
bitrate: finite(message.bitrate), encoderInstances: finite(message.encoderInstances),
|
||||
};
|
||||
}
|
||||
}
|
||||
return super.send(value);
|
||||
}
|
||||
};
|
||||
if (typeof globalThis.VideoEncoder === 'function') {
|
||||
const NativeVideoEncoder = globalThis.VideoEncoder;
|
||||
globalThis.VideoEncoder = new Proxy(NativeVideoEncoder, {
|
||||
construct(Target, args) {
|
||||
const init = args[0] || {};
|
||||
const wrapped = { ...init,
|
||||
output: (...values) => { state.encoderOutputs += 1; return init.output(...values); },
|
||||
error: (...values) => { state.encoderErrors += 1; return init.error(...values); },
|
||||
};
|
||||
const encoder = Reflect.construct(Target, [wrapped], Target);
|
||||
state.encoderInstances += 1;
|
||||
return encoder;
|
||||
},
|
||||
get(Target, property, receiver) {
|
||||
const value = Reflect.get(Target, property, receiver);
|
||||
return typeof value === 'function' && property === 'isConfigSupported' ? value.bind(Target) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
Object.defineProperty(globalThis, '__NATIVE_ONE_VIEWER_GATE__', {
|
||||
value: { snapshot: () => structuredClone(state) }, configurable: false,
|
||||
});
|
||||
})();`;
|
||||
}
|
||||
|
||||
function viewerProbe(): string {
|
||||
return `(() => {
|
||||
const state = { signaling: { authenticateSent: 0, authenticatedReceived: 0,
|
||||
offersReceived: 0, answersSent: 0, candidatesReceived: 0, candidatesSent: 0 },
|
||||
socketCount: 0, authenticateSocketOrdinal: 0, authSocketOrdinal: 0, authGeneration: 0,
|
||||
connectionIds: [], activeConnectionId: null, consistent: true, pendingOffer: null,
|
||||
pcs: [], boundPcOrdinal: 0, boundConnectionOrdinal: 0, boundSocketOrdinal: 0,
|
||||
boundAuthGeneration: 0, trackCount: 0, trackOrdinal: 0, trackPcOrdinal: 0,
|
||||
renderedFrames: 0, observedVideo: null };
|
||||
const connectionOrdinal = (value) => {
|
||||
let index = state.connectionIds.indexOf(value);
|
||||
if (index < 0) { state.connectionIds.push(value); index = state.connectionIds.length - 1; }
|
||||
return index + 1;
|
||||
};
|
||||
const inspect = (data, direction, socketOrdinal) => {
|
||||
if (typeof data !== 'string') return;
|
||||
let message; try { message = JSON.parse(data); } catch { return; }
|
||||
if (direction === 'out' && message.type === 'authenticate') {
|
||||
state.signaling.authenticateSent += 1; state.authenticateSocketOrdinal = socketOrdinal; return;
|
||||
}
|
||||
if (direction === 'in' && message.type === 'authenticated') {
|
||||
state.signaling.authenticatedReceived += 1; state.authGeneration += 1;
|
||||
state.authSocketOrdinal = socketOrdinal;
|
||||
if (state.authenticateSocketOrdinal !== socketOrdinal) state.consistent = false;
|
||||
return;
|
||||
}
|
||||
if (message.type !== 'signal' || !message.payload) return;
|
||||
const rawConnectionId = message.payload.connectionId;
|
||||
if (typeof rawConnectionId !== 'string' || state.authSocketOrdinal !== socketOrdinal) {
|
||||
state.consistent = false; return;
|
||||
}
|
||||
const ordinal = connectionOrdinal(rawConnectionId);
|
||||
if (state.activeConnectionId === null && direction === 'in' &&
|
||||
message.payload.kind === 'description' && message.payload.description?.type === 'offer') {
|
||||
state.activeConnectionId = rawConnectionId;
|
||||
}
|
||||
if (state.activeConnectionId !== rawConnectionId) state.consistent = false;
|
||||
if (direction === 'in' && message.payload.kind === 'description' && message.payload.description?.type === 'offer') {
|
||||
state.signaling.offersReceived += 1;
|
||||
state.pendingOffer = { rawConnectionId, ordinal, socketOrdinal, authGeneration: state.authGeneration };
|
||||
}
|
||||
if (direction === 'out' && message.payload.kind === 'description' && message.payload.description?.type === 'answer') {
|
||||
state.signaling.answersSent += 1;
|
||||
if (state.boundConnectionOrdinal !== ordinal || state.boundSocketOrdinal !== socketOrdinal ||
|
||||
state.boundAuthGeneration !== state.authGeneration) state.consistent = false;
|
||||
}
|
||||
if (direction === 'in' && message.payload.kind === 'candidate') state.signaling.candidatesReceived += 1;
|
||||
if (direction === 'out' && message.payload.kind === 'candidate') state.signaling.candidatesSent += 1;
|
||||
};
|
||||
const NativeWebSocket = globalThis.WebSocket;
|
||||
globalThis.WebSocket = class GateWebSocket extends NativeWebSocket {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.__gateSignal = new URL(String(args[0]), location.href).pathname === '/signal';
|
||||
if (this.__gateSignal) {
|
||||
this.__gateSocketOrdinal = ++state.socketCount;
|
||||
this.addEventListener('message', (event) => inspect(event.data, 'in', this.__gateSocketOrdinal));
|
||||
}
|
||||
}
|
||||
send(value) {
|
||||
if (this.__gateSignal) inspect(value, 'out', this.__gateSocketOrdinal);
|
||||
return super.send(value);
|
||||
}
|
||||
};
|
||||
const NativePeerConnection = globalThis.RTCPeerConnection;
|
||||
globalThis.RTCPeerConnection = class GatePeerConnection extends NativePeerConnection {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
state.pcs.push(this);
|
||||
this.__gatePcOrdinal = state.pcs.length;
|
||||
this.addEventListener('track', (event) => {
|
||||
if (event.track?.kind !== 'video') return;
|
||||
state.trackCount += 1; state.trackOrdinal = state.trackCount;
|
||||
state.trackPcOrdinal = this.__gatePcOrdinal;
|
||||
});
|
||||
}
|
||||
async setRemoteDescription(description) {
|
||||
const result = await super.setRemoteDescription(description);
|
||||
if (description?.type === 'offer') {
|
||||
const offer = state.pendingOffer;
|
||||
if (!offer || offer.rawConnectionId !== state.activeConnectionId) state.consistent = false;
|
||||
else {
|
||||
state.boundPcOrdinal = this.__gatePcOrdinal;
|
||||
state.boundConnectionOrdinal = offer.ordinal;
|
||||
state.boundSocketOrdinal = offer.socketOrdinal;
|
||||
state.boundAuthGeneration = offer.authGeneration;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
async function snapshot() {
|
||||
const video = document.querySelector('.remote-stage video');
|
||||
if (video && state.observedVideo !== video && typeof video.requestVideoFrameCallback === 'function') {
|
||||
state.observedVideo = video;
|
||||
const frame = () => { state.renderedFrames += 1; video.requestVideoFrameCallback(frame); };
|
||||
video.requestVideoFrameCallback(frame);
|
||||
}
|
||||
const pc = state.pcs[state.boundPcOrdinal - 1];
|
||||
let packetsReceived = 0, bytesReceived = 0, framesDecoded = 0;
|
||||
if (pc) {
|
||||
const stats = await pc.getStats();
|
||||
stats.forEach((entry) => {
|
||||
if (entry.type === 'inbound-rtp' && entry.kind === 'video' && entry.isRemote !== true) {
|
||||
packetsReceived += finite(entry.packetsReceived);
|
||||
bytesReceived += finite(entry.bytesReceived);
|
||||
framesDecoded += finite(entry.framesDecoded);
|
||||
}
|
||||
});
|
||||
}
|
||||
const activeOrdinal = state.activeConnectionId === null ? 0 :
|
||||
state.connectionIds.indexOf(state.activeConnectionId) + 1;
|
||||
const identity = { socketCount: state.socketCount, socketOrdinal: state.authSocketOrdinal,
|
||||
authGeneration: state.authGeneration, connectionCount: state.connectionIds.length,
|
||||
connectionOrdinal: activeOrdinal, pcCount: state.pcs.length, pcOrdinal: state.boundPcOrdinal,
|
||||
trackCount: state.trackCount, trackOrdinal: state.trackOrdinal,
|
||||
consistent: state.consistent && state.boundConnectionOrdinal === activeOrdinal &&
|
||||
state.boundSocketOrdinal === state.authSocketOrdinal &&
|
||||
state.boundAuthGeneration === state.authGeneration && state.trackPcOrdinal === state.boundPcOrdinal };
|
||||
return { signaling: structuredClone(state.signaling), identity,
|
||||
connectionState: pc?.connectionState || 'none',
|
||||
iceConnectionState: pc?.iceConnectionState || 'none', packetsReceived, bytesReceived, framesDecoded,
|
||||
videoReadyState: finite(video?.readyState), videoWidth: finite(video?.videoWidth),
|
||||
videoHeight: finite(video?.videoHeight), videoCurrentTime: finite(video?.currentTime),
|
||||
renderedFrames: state.renderedFrames };
|
||||
}
|
||||
const finite = (value) => Number.isFinite(value) ? value : 0;
|
||||
Object.defineProperty(globalThis, '__NATIVE_VIEWER_GATE__', {
|
||||
value: { snapshot }, configurable: false,
|
||||
});
|
||||
})();`;
|
||||
}
|
||||
|
||||
async function createPage(cdp: CdpConnection, url: string, probe?: string): Promise<PageHandle> {
|
||||
const deadline = Date.now() + 15_000;
|
||||
const created = await cdp.call<{ targetId: string }>("Target.createTarget", { url: "about:blank", background: false }, undefined, deadline);
|
||||
const attached = await cdp.call<{ sessionId: string }>("Target.attachToTarget", { targetId: created.targetId, flatten: true }, undefined, deadline);
|
||||
const page = { sessionId: attached.sessionId };
|
||||
await Promise.all([
|
||||
cdp.call("Page.enable", {}, page.sessionId, deadline),
|
||||
cdp.call("Runtime.enable", {}, page.sessionId, deadline),
|
||||
]);
|
||||
if (probe) await cdp.call("Page.addScriptToEvaluateOnNewDocument", { source: probe }, page.sessionId, deadline);
|
||||
await cdp.call("Page.navigate", { url }, page.sessionId, deadline);
|
||||
await waitForSample(
|
||||
(sampleDeadline) => evaluate(cdp, page, "document.readyState === 'complete'", sampleDeadline),
|
||||
Boolean,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
);
|
||||
return page;
|
||||
}
|
||||
|
||||
async function evaluate<T>(cdp: CdpConnection, page: PageHandle, expression: string, deadline: number): Promise<T> {
|
||||
const value = await cdp.call<CdpResult<T>>("Runtime.evaluate", {
|
||||
expression, awaitPromise: true, returnByValue: true, userGesture: true,
|
||||
}, page.sessionId, deadline);
|
||||
if (value.exceptionDetails) throw new Error("Browser evaluation failed");
|
||||
return value.result.value as T;
|
||||
}
|
||||
|
||||
function senderSnapshot(cdp: CdpConnection, page: PageHandle, deadline: number): Promise<SenderSnapshot> {
|
||||
return evaluate(cdp, page, "globalThis.__NATIVE_ONE_VIEWER_GATE__.snapshot()", deadline);
|
||||
}
|
||||
|
||||
function viewerSnapshot(cdp: CdpConnection, page: PageHandle, deadline: number): Promise<ViewerSnapshot> {
|
||||
return evaluate(cdp, page, "globalThis.__NATIVE_VIEWER_GATE__.snapshot()", deadline);
|
||||
}
|
||||
|
||||
function senderIdentity(sender: SenderSnapshot): SenderIdentityEvidence {
|
||||
const peer = sender.peers[0];
|
||||
return { peerCount: sender.peers.length, maxPeers: sender.maxPeers,
|
||||
slot: peer?.slot ?? -1, edgeGeneration: peer?.edgeGeneration ?? 0 };
|
||||
}
|
||||
|
||||
function requireChecks(checks: Record<string, boolean>): void {
|
||||
if (!Object.values(checks).every(Boolean)) throw new Error("Gate check failed");
|
||||
}
|
||||
|
||||
function safeRoute(value: string | undefined): string {
|
||||
return new Set(["direct-udp", "direct-tcp", "turn-udp", "turn-tcp", "turn-tls", "turn-unknown"])
|
||||
.has(value ?? "") ? value! : "unknown";
|
||||
}
|
||||
|
||||
async function reservePort(): Promise<number> {
|
||||
const server = createServer();
|
||||
return new Promise((resolvePort, rejectPort) => {
|
||||
server.once("error", rejectPort);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") return rejectPort(new Error("Port reservation failed"));
|
||||
server.close((error) => error ? rejectPort(error) : resolvePort(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readLaunchUrl(child: ChildProcessWithoutNullStreams): Promise<string> {
|
||||
let buffer = "";
|
||||
return new Promise((resolveUrl, rejectUrl) => {
|
||||
const timeout = setTimeout(() => rejectUrl(new Error("Native runner startup timed out")), 20_000);
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const end = buffer.indexOf("\n");
|
||||
if (end < 0) return;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
const parsed = JSON.parse(buffer.slice(0, end)) as { launchUrl?: unknown };
|
||||
if (typeof parsed.launchUrl !== "string") throw new Error();
|
||||
resolveUrl(parsed.launchUrl);
|
||||
} catch {
|
||||
rejectUrl(new Error("Native runner handoff was invalid"));
|
||||
}
|
||||
});
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
rejectUrl(new Error("Native runner exited before handoff"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForVersion(port: number, child: ChildProcessWithoutNullStreams): Promise<{ Browser: string; webSocketDebuggerUrl: string }> {
|
||||
const deadline = Date.now() + 20_000;
|
||||
while (Date.now() < deadline && child.exitCode === null) {
|
||||
try {
|
||||
return await fetchJsonBefore<{ Browser: string; webSocketDebuggerUrl: string }>(
|
||||
`http://127.0.0.1:${port}/json/version`, deadline,
|
||||
);
|
||||
} catch {}
|
||||
await delay(Math.min(100, Math.max(0, deadline - Date.now())));
|
||||
}
|
||||
throw new Error("Chrome startup timed out");
|
||||
}
|
||||
|
||||
interface CleanupResources {
|
||||
cdp: CdpConnection | null;
|
||||
native: ChildProcessWithoutNullStreams | null;
|
||||
chrome: ChildProcessWithoutNullStreams | null;
|
||||
server: ScreenerServer | null;
|
||||
profile: string | null;
|
||||
ports: number[];
|
||||
}
|
||||
|
||||
async function cleanupRun(resources: CleanupResources): Promise<CleanupResult> {
|
||||
if (resources.cdp) {
|
||||
try { await resources.cdp.call("Browser.close", {}, undefined, Date.now() + 2_000); } catch {}
|
||||
resources.cdp.close();
|
||||
}
|
||||
const browserExited = resources.chrome ? await stopProcessTree(resources.chrome) : true;
|
||||
if (resources.native && processRunning(resources.native)) {
|
||||
try { resources.native.stdin.write("\n"); } catch {}
|
||||
}
|
||||
const nativeExited = resources.native ? await stopProcessTree(resources.native) : true;
|
||||
let serverClosed = true;
|
||||
if (resources.server) {
|
||||
try { await withDeadline(() => resources.server!.close(), Date.now() + 5_000); } catch { serverClosed = false; }
|
||||
}
|
||||
const portsClosed = await waitForPortsClosed([...new Set(resources.ports)], 5_000);
|
||||
const profileRemoved = resources.profile ? await removeGateProfile(resources.profile) : true;
|
||||
return { browserExited, nativeExited, serverClosed, portsClosed, profileRemoved };
|
||||
}
|
||||
|
||||
function processRunning(child: ChildProcessWithoutNullStreams): boolean {
|
||||
return child.exitCode === null && child.signalCode === null;
|
||||
}
|
||||
|
||||
async function stopProcessTree(child: ChildProcessWithoutNullStreams): Promise<boolean> {
|
||||
if (!processRunning(child)) return true;
|
||||
if (await waitForExit(child, 2_000)) return true;
|
||||
if (process.platform !== "win32" || child.pid === undefined) return false;
|
||||
await runBounded("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], 5_000);
|
||||
return waitForExit(child, 3_000);
|
||||
}
|
||||
|
||||
async function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<boolean> {
|
||||
if (!processRunning(child)) return true;
|
||||
return new Promise<boolean>((resolveExit) => {
|
||||
const onExit = () => { clearTimeout(timeout); resolveExit(true); };
|
||||
const timeout = setTimeout(() => {
|
||||
child.off("exit", onExit);
|
||||
resolveExit(!processRunning(child));
|
||||
}, timeoutMs);
|
||||
child.once("exit", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
async function runBounded(command: string, args: string[], timeoutMs: number,
|
||||
environment: NodeJS.ProcessEnv = process.env): Promise<boolean> {
|
||||
const child = spawn(command, args, { stdio: "pipe", windowsHide: true, env: environment });
|
||||
child.stdout.resume();
|
||||
child.stderr.resume();
|
||||
try {
|
||||
const code = await withDeadline(() => new Promise<number | null>((resolveExit, rejectExit) => {
|
||||
child.once("error", rejectExit);
|
||||
child.once("exit", resolveExit);
|
||||
}), Date.now() + timeoutMs);
|
||||
return code === 0;
|
||||
} catch {
|
||||
child.kill();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPortsClosed(ports: number[], timeoutMs: number): Promise<boolean> {
|
||||
if (ports.length === 0) return true;
|
||||
try {
|
||||
await waitForSample(
|
||||
(deadline) => Promise.all(ports.map((port) => portClosed(port, deadline))),
|
||||
(closed) => closed.every(Boolean),
|
||||
timeoutMs,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function portClosed(port: number, deadline: number): Promise<boolean> {
|
||||
return withDeadline(() => new Promise<boolean>((resolveClosed) => {
|
||||
const socket = createConnection({ host: "127.0.0.1", port });
|
||||
const finish = (closed: boolean) => { socket.destroy(); resolveClosed(closed); };
|
||||
socket.once("connect", () => finish(false));
|
||||
socket.once("error", () => finish(true));
|
||||
}), deadline);
|
||||
}
|
||||
|
||||
export const profileCleanupScript = String.raw`
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = [IO.Path]::GetFullPath($env:SCREENER_GATE_PROFILE_TO_REMOVE)
|
||||
$temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar)
|
||||
$parent = [IO.Path]::GetDirectoryName($root).TrimEnd([IO.Path]::DirectorySeparatorChar)
|
||||
$name = [IO.Path]::GetFileName($root)
|
||||
if ($parent -ine $temp -or $name -notmatch '^screener-native-one-viewer-[A-Za-z0-9_-]{6}$') { exit 31 }
|
||||
$rootEntries = @([IO.Directory]::EnumerateFileSystemEntries($parent, $name, [IO.SearchOption]::TopDirectoryOnly))
|
||||
if ($rootEntries.Count -eq 0) { exit 0 }
|
||||
if ($rootEntries.Count -ne 1) { exit 34 }
|
||||
$root = $rootEntries[0]
|
||||
$rootAttributes = [IO.File]::GetAttributes($root)
|
||||
if (($rootAttributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 }
|
||||
if (($rootAttributes -band [IO.FileAttributes]::Directory) -eq 0) { exit 34 }
|
||||
$pending = [Collections.Generic.Stack[string]]::new()
|
||||
$pending.Push($root)
|
||||
while ($pending.Count -gt 0) {
|
||||
$directory = $pending.Pop()
|
||||
$attributes = [IO.File]::GetAttributes($directory)
|
||||
if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 }
|
||||
foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($directory)) {
|
||||
$attributes = [IO.File]::GetAttributes($entry)
|
||||
if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { exit 32 }
|
||||
if (($attributes -band [IO.FileAttributes]::Directory) -ne 0) { $pending.Push($entry) }
|
||||
}
|
||||
}
|
||||
Remove-Item -LiteralPath $root -Recurse -Force
|
||||
$rootEntries = @([IO.Directory]::EnumerateFileSystemEntries($parent, $name, [IO.SearchOption]::TopDirectoryOnly))
|
||||
if ($rootEntries.Count -ne 0) { exit 33 }
|
||||
`;
|
||||
|
||||
async function removeGateProfile(profile: string): Promise<boolean> {
|
||||
if (process.platform !== "win32" || !isExactGateProfile(profile, tmpdir())) return false;
|
||||
return runBounded("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", profileCleanupScript],
|
||||
10_000, { ...process.env, SCREENER_GATE_PROFILE_TO_REMOVE: profile });
|
||||
}
|
||||
|
||||
function animatedSourceUrl(): string {
|
||||
const html = `<!doctype html><title>Native Gate Source</title><canvas width="1280" height="720"></canvas>
|
||||
<script>const c=document.querySelector('canvas'),x=c.getContext('2d');let f=0;
|
||||
setInterval(()=>{f++;x.fillStyle='hsl('+f%360+' 80% 35%)';x.fillRect(0,0,1280,720);
|
||||
x.fillStyle='white';x.fillRect(f*13%1280,0,20,720);x.font='64px monospace';x.fillText(f,40,90)},33);<\/script>`;
|
||||
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
export interface SenderStartObservation {
|
||||
getDisplayMediaRequested: boolean;
|
||||
getDisplayMediaResolved: boolean;
|
||||
hostAdmissionAccepted: boolean;
|
||||
roomCreated: boolean;
|
||||
hostWssAuthenticated: boolean;
|
||||
bridgeConnected: boolean;
|
||||
fixedHighConfigured: boolean;
|
||||
firstEncodedChunk: boolean;
|
||||
bridgeGeneration: number;
|
||||
binarySendAttempts: number;
|
||||
binarySendSucceeded: number;
|
||||
binarySendFailed: number;
|
||||
encoderInstances: number;
|
||||
framesWritten: number;
|
||||
sourceRtpPacketsWritten: number;
|
||||
sourceRtpBytesWritten: number;
|
||||
}
|
||||
|
||||
export interface SenderStartLedgerRecord extends SenderStartObservation {
|
||||
schemaVersion: 1;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
type LedgerSink = (record: Readonly<SenderStartLedgerRecord>) => void;
|
||||
|
||||
const initialObservation: SenderStartObservation = {
|
||||
getDisplayMediaRequested: false,
|
||||
getDisplayMediaResolved: false,
|
||||
hostAdmissionAccepted: false,
|
||||
roomCreated: false,
|
||||
hostWssAuthenticated: false,
|
||||
bridgeConnected: false,
|
||||
fixedHighConfigured: false,
|
||||
firstEncodedChunk: false,
|
||||
bridgeGeneration: 0,
|
||||
binarySendAttempts: 0,
|
||||
binarySendSucceeded: 0,
|
||||
binarySendFailed: 0,
|
||||
encoderInstances: 0,
|
||||
framesWritten: 0,
|
||||
sourceRtpPacketsWritten: 0,
|
||||
sourceRtpBytesWritten: 0,
|
||||
};
|
||||
|
||||
const booleanKeys = [
|
||||
"getDisplayMediaRequested",
|
||||
"getDisplayMediaResolved",
|
||||
"hostAdmissionAccepted",
|
||||
"roomCreated",
|
||||
"hostWssAuthenticated",
|
||||
"bridgeConnected",
|
||||
"fixedHighConfigured",
|
||||
"firstEncodedChunk",
|
||||
] as const;
|
||||
|
||||
const counterKeys = [
|
||||
"binarySendAttempts",
|
||||
"binarySendSucceeded",
|
||||
"binarySendFailed",
|
||||
"encoderInstances",
|
||||
"framesWritten",
|
||||
"sourceRtpPacketsWritten",
|
||||
"sourceRtpBytesWritten",
|
||||
] as const;
|
||||
|
||||
// The first positive value is enough for this stage and bounds the append-only
|
||||
// ledger to at most one record per field transition.
|
||||
const counterLimit = 1_000_000_000;
|
||||
|
||||
export class SenderStartLedger {
|
||||
private observation = { ...initialObservation };
|
||||
private sequence = 0;
|
||||
|
||||
constructor(private readonly sink: LedgerSink = () => undefined) {}
|
||||
|
||||
record(patch: Partial<SenderStartObservation>): void {
|
||||
const next = { ...this.observation };
|
||||
for (const key of booleanKeys) {
|
||||
if (patch[key] === true) next[key] = true;
|
||||
}
|
||||
for (const key of counterKeys) {
|
||||
const value = patch[key];
|
||||
if (next[key] === 0 && typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
next[key] = Math.min(Math.floor(value), counterLimit);
|
||||
}
|
||||
}
|
||||
const bridgeGeneration = patch.bridgeGeneration;
|
||||
if (typeof bridgeGeneration === "number" && Number.isFinite(bridgeGeneration) &&
|
||||
bridgeGeneration > next.bridgeGeneration) {
|
||||
next.bridgeGeneration = Math.min(Math.floor(bridgeGeneration), 2);
|
||||
}
|
||||
if (JSON.stringify(next) === JSON.stringify(this.observation)) return;
|
||||
this.observation = next;
|
||||
this.sequence += 1;
|
||||
this.sink(Object.freeze(this.snapshot()));
|
||||
}
|
||||
|
||||
snapshot(): SenderStartLedgerRecord {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
sequence: this.sequence,
|
||||
...this.observation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function senderStartComplete(record: SenderStartObservation): boolean {
|
||||
return booleanKeys.every((key) => record[key]) && record.encoderInstances === 1 &&
|
||||
record.bridgeGeneration === 1 && record.binarySendAttempts > 0 &&
|
||||
record.binarySendSucceeded > 0 && record.binarySendFailed === 0 &&
|
||||
record.framesWritten > 0 && record.sourceRtpPacketsWritten > 0;
|
||||
}
|
||||
|
||||
interface WaitOptions {
|
||||
timeoutMs: number;
|
||||
pollMs?: number;
|
||||
now?: () => number;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function waitForSenderStart(
|
||||
sample: (deadline: number) => Promise<Partial<SenderStartObservation>>,
|
||||
ledger: SenderStartLedger,
|
||||
options: WaitOptions,
|
||||
): Promise<SenderStartLedgerRecord> {
|
||||
const now = options.now ?? Date.now;
|
||||
const sleep = options.sleep ?? delay;
|
||||
const deadline = now() + options.timeoutMs;
|
||||
for (;;) {
|
||||
let observation: Partial<SenderStartObservation> | undefined;
|
||||
try {
|
||||
observation = await settleBefore(sample(deadline), Math.max(0, deadline - now()));
|
||||
} catch {
|
||||
// A failed sample cannot erase the last synchronously committed stage.
|
||||
}
|
||||
if (observation) ledger.record(observation);
|
||||
const current = ledger.snapshot();
|
||||
if (senderStartComplete(current)) return current;
|
||||
if (now() >= deadline) throw new Error("Sender-start stage timed out");
|
||||
await sleep(options.pollMs ?? 100);
|
||||
}
|
||||
}
|
||||
|
||||
function settleBefore<T>(pending: Promise<T>, milliseconds: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Sender-start sample timed out")), milliseconds);
|
||||
pending.then(
|
||||
(value) => { clearTimeout(timeout); resolve(value); },
|
||||
(error: unknown) => { clearTimeout(timeout); reject(error); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { join } from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
captureOneViewerIdentity,
|
||||
fetchJsonBefore,
|
||||
finalizeGate,
|
||||
isExactGateProfile,
|
||||
retainsFirstBridgeSend,
|
||||
retainsOneViewerIdentity,
|
||||
waitForSample,
|
||||
type CleanupResult,
|
||||
type SenderIdentityEvidence,
|
||||
type ViewerIdentityEvidence,
|
||||
} from "../scripts/native-one-viewer-gate-core";
|
||||
import { profileCleanupScript, senderProbe } from "../scripts/native-one-viewer-gate";
|
||||
|
||||
const viewer: ViewerIdentityEvidence = {
|
||||
socketCount: 1, socketOrdinal: 1, authGeneration: 1,
|
||||
connectionCount: 1, connectionOrdinal: 1, pcCount: 1, pcOrdinal: 1,
|
||||
trackCount: 1, trackOrdinal: 1, consistent: true,
|
||||
};
|
||||
const sender: SenderIdentityEvidence = {
|
||||
peerCount: 1, maxPeers: 1, slot: 1, edgeGeneration: 1,
|
||||
};
|
||||
const clean: CleanupResult = {
|
||||
browserExited: true, nativeExited: true, serverClosed: true,
|
||||
portsClosed: true, profileRemoved: true,
|
||||
};
|
||||
|
||||
interface BridgeProbeSnapshot {
|
||||
bridgeGeneration: number;
|
||||
binarySendAttempts: number;
|
||||
binarySendSucceeded: number;
|
||||
binarySendFailed: number;
|
||||
}
|
||||
|
||||
class ProbeWebSocket {
|
||||
failBinary = false;
|
||||
|
||||
constructor(..._args: unknown[]) {}
|
||||
addEventListener(..._args: unknown[]): void {}
|
||||
send(value: unknown): void {
|
||||
if (this.failBinary && value instanceof ArrayBuffer) throw new Error("not retained");
|
||||
}
|
||||
}
|
||||
|
||||
function bridgeProbeContext(): {
|
||||
WebSocket: typeof ProbeWebSocket;
|
||||
snapshot: () => BridgeProbeSnapshot;
|
||||
} {
|
||||
const context: Record<string, unknown> = {
|
||||
ArrayBuffer,
|
||||
URL,
|
||||
structuredClone,
|
||||
location: { href: "http://127.0.0.1/" },
|
||||
navigator: { mediaDevices: { getDisplayMedia: async () => ({}) } },
|
||||
WebSocket: ProbeWebSocket,
|
||||
};
|
||||
runInNewContext(senderProbe(), context);
|
||||
return {
|
||||
WebSocket: context.WebSocket as typeof ProbeWebSocket,
|
||||
snapshot: (context.__NATIVE_ONE_VIEWER_GATE__ as { snapshot(): BridgeProbeSnapshot }).snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
describe("native one-viewer gate invariants", () => {
|
||||
it("rejects cumulative evidence from a rebuilt Viewer PC", () => {
|
||||
const baseline = captureOneViewerIdentity(viewer, sender);
|
||||
expect(baseline).not.toBeNull();
|
||||
expect(retainsOneViewerIdentity(baseline!, {
|
||||
...viewer, connectionCount: 2, connectionOrdinal: 2, pcCount: 2, pcOrdinal: 2,
|
||||
}, sender)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing Pion slot", () => {
|
||||
expect(captureOneViewerIdentity(viewer, { ...sender, slot: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it("records only the current local bridge binary-send outcome", () => {
|
||||
const successful = bridgeProbeContext();
|
||||
const first = new successful.WebSocket("ws://127.0.0.1/media");
|
||||
first.send("config");
|
||||
first.send(new ArrayBuffer(1));
|
||||
expect(successful.snapshot()).toMatchObject({
|
||||
bridgeGeneration: 1,
|
||||
binarySendAttempts: 1,
|
||||
binarySendSucceeded: 1,
|
||||
binarySendFailed: 0,
|
||||
});
|
||||
|
||||
const replacement = new successful.WebSocket("ws://127.0.0.1/media");
|
||||
replacement.send(new ArrayBuffer(1));
|
||||
expect(successful.snapshot()).toMatchObject({
|
||||
bridgeGeneration: 2,
|
||||
binarySendAttempts: 1,
|
||||
binarySendSucceeded: 1,
|
||||
binarySendFailed: 0,
|
||||
});
|
||||
|
||||
const failed = bridgeProbeContext();
|
||||
const throwing = new failed.WebSocket("ws://127.0.0.1/media");
|
||||
throwing.failBinary = true;
|
||||
expect(() => throwing.send(new ArrayBuffer(1))).toThrow("not retained");
|
||||
expect(failed.snapshot()).toMatchObject({
|
||||
bridgeGeneration: 1,
|
||||
binarySendAttempts: 1,
|
||||
binarySendSucceeded: 0,
|
||||
binarySendFailed: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a late bridge replacement at every post-start gate", () => {
|
||||
const senderStart = {
|
||||
bridgeGeneration: 1,
|
||||
binarySendAttempts: 1,
|
||||
binarySendSucceeded: 1,
|
||||
binarySendFailed: 0,
|
||||
};
|
||||
expect(retainsFirstBridgeSend(senderStart)).toBe(true);
|
||||
|
||||
const replaced = { ...senderStart, bridgeGeneration: 2 };
|
||||
expect({
|
||||
viewerSignal: retainsFirstBridgeSend(replaced),
|
||||
viewerMedia: retainsFirstBridgeSend(replaced),
|
||||
finalSuccess: retainsFirstBridgeSend(replaced),
|
||||
}).toEqual({ viewerSignal: false, viewerMedia: false, finalSuccess: false });
|
||||
});
|
||||
|
||||
it("bounds Chrome version headers and body with one abort deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const signals: AbortSignal[] = [];
|
||||
const headers = fetchJsonBefore("http://gate.invalid", Date.now() + 25,
|
||||
async (_url, init) => {
|
||||
signals.push(init.signal);
|
||||
return new Promise<never>(() => undefined);
|
||||
});
|
||||
const body = fetchJsonBefore("http://gate.invalid", Date.now() + 25,
|
||||
async (_url, init) => {
|
||||
signals.push(init.signal);
|
||||
return { ok: true, json: () => new Promise<never>(() => undefined) };
|
||||
});
|
||||
const headerAssertion = expect(headers).rejects.toThrow("Operation deadline exceeded");
|
||||
const bodyAssertion = expect(body).rejects.toThrow("Operation deadline exceeded");
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
await Promise.all([headerAssertion, bodyAssertion]);
|
||||
expect(signals).toHaveLength(2);
|
||||
expect(signals.every((signal) => signal.aborted)).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds a hung browser sample by the stage deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const pending = waitForSample(() => new Promise<never>(() => undefined), () => true, 25, 5);
|
||||
const assertion = expect(pending).rejects.toThrow("Bounded browser stage timed out");
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
await assertion;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("cannot pass before cleanup and fails when profile removal fails", async () => {
|
||||
const report = { status: "failed" as const, failedStage: "viewer-media" as string | null,
|
||||
stages: {} };
|
||||
let release!: (result: CleanupResult) => void;
|
||||
const cleanup = new Promise<CleanupResult>((resolve) => { release = resolve; });
|
||||
const finishing = finalizeGate(report, true, () => cleanup);
|
||||
await Promise.resolve();
|
||||
expect(report.status).toBe("failed");
|
||||
release(clean);
|
||||
await finishing;
|
||||
expect(report).toMatchObject({ status: "passed", failedStage: null });
|
||||
|
||||
const failed = { status: "failed" as const, failedStage: null as string | null, stages: {} };
|
||||
await finalizeGate(failed, true, async () => ({ ...clean, profileRemoved: false }));
|
||||
expect(failed).toMatchObject({ status: "failed", failedStage: "cleanup" });
|
||||
});
|
||||
|
||||
it("accepts only the exact task profile directly under system Temp", () => {
|
||||
const temp = join("C:", "Temp");
|
||||
expect(isExactGateProfile(join(temp, "screener-native-one-viewer-a1B_2-"), temp)).toBe(true);
|
||||
expect(isExactGateProfile(join(temp, "nested", "screener-native-one-viewer-a1B_2-"), temp)).toBe(false);
|
||||
expect(isExactGateProfile(join(temp, "other-a1B_2-"), temp)).toBe(false);
|
||||
expect(profileCleanupScript).toContain("EnumerateFileSystemEntries($parent, $name");
|
||||
expect(profileCleanupScript).toContain("GetAttributes($root)");
|
||||
expect(profileCleanupScript).not.toMatch(/\[IO\.(?:File|Directory)\]::Exists/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SenderStartLedger,
|
||||
senderStartComplete,
|
||||
waitForSenderStart,
|
||||
type SenderStartObservation,
|
||||
} from "../scripts/native-one-viewer-ledger";
|
||||
|
||||
describe("native one-viewer sender-start ledger", () => {
|
||||
it("retains the last sanitized stage when an intermediate wait times out", async () => {
|
||||
const committed: ReturnType<SenderStartLedger["snapshot"]>[] = [];
|
||||
const ledger = new SenderStartLedger((record) => committed.push({ ...record }));
|
||||
const samples: Array<Partial<SenderStartObservation>> = [
|
||||
{ getDisplayMediaRequested: true },
|
||||
{
|
||||
getDisplayMediaResolved: true,
|
||||
hostAdmissionAccepted: true,
|
||||
roomCreated: true,
|
||||
},
|
||||
{
|
||||
hostWssAuthenticated: true,
|
||||
bridgeConnected: true,
|
||||
fixedHighConfigured: true,
|
||||
firstEncodedChunk: true,
|
||||
bridgeGeneration: 1,
|
||||
binarySendAttempts: 1,
|
||||
binarySendSucceeded: 1,
|
||||
encoderInstances: 1,
|
||||
framesWritten: 4,
|
||||
},
|
||||
];
|
||||
let elapsed = 0;
|
||||
|
||||
await expect(
|
||||
waitForSenderStart(
|
||||
async () => samples.shift() ?? {},
|
||||
ledger,
|
||||
{
|
||||
timeoutMs: 10,
|
||||
pollMs: 5,
|
||||
now: () => elapsed,
|
||||
sleep: async (milliseconds) => { elapsed += milliseconds; },
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("Sender-start stage timed out");
|
||||
|
||||
expect(committed.at(-1)).toEqual({
|
||||
schemaVersion: 1,
|
||||
sequence: 3,
|
||||
getDisplayMediaRequested: true,
|
||||
getDisplayMediaResolved: true,
|
||||
hostAdmissionAccepted: true,
|
||||
roomCreated: true,
|
||||
hostWssAuthenticated: true,
|
||||
bridgeConnected: true,
|
||||
fixedHighConfigured: true,
|
||||
firstEncodedChunk: true,
|
||||
bridgeGeneration: 1,
|
||||
binarySendAttempts: 1,
|
||||
binarySendSucceeded: 1,
|
||||
binarySendFailed: 0,
|
||||
encoderInstances: 1,
|
||||
framesWritten: 4,
|
||||
sourceRtpPacketsWritten: 0,
|
||||
sourceRtpBytesWritten: 0,
|
||||
});
|
||||
expect(JSON.stringify(committed)).not.toMatch(/token|url|ip|sdp|error/i);
|
||||
});
|
||||
|
||||
it("fails closed and bounds evidence when the bridge generation changes", () => {
|
||||
const ledger = new SenderStartLedger();
|
||||
ledger.record({
|
||||
getDisplayMediaRequested: true,
|
||||
getDisplayMediaResolved: true,
|
||||
hostAdmissionAccepted: true,
|
||||
roomCreated: true,
|
||||
hostWssAuthenticated: true,
|
||||
bridgeConnected: true,
|
||||
fixedHighConfigured: true,
|
||||
firstEncodedChunk: true,
|
||||
bridgeGeneration: 1,
|
||||
binarySendAttempts: Number.MAX_SAFE_INTEGER,
|
||||
binarySendSucceeded: Number.MAX_SAFE_INTEGER,
|
||||
encoderInstances: 1,
|
||||
framesWritten: 1,
|
||||
sourceRtpPacketsWritten: 1,
|
||||
});
|
||||
ledger.record({ bridgeGeneration: 99, binarySendFailed: 1 });
|
||||
|
||||
const snapshot = ledger.snapshot();
|
||||
expect(snapshot).toMatchObject({
|
||||
bridgeGeneration: 2,
|
||||
binarySendAttempts: 1_000_000_000,
|
||||
binarySendSucceeded: 1_000_000_000,
|
||||
binarySendFailed: 1,
|
||||
});
|
||||
expect(senderStartComplete({ ...snapshot, binarySendFailed: 0 })).toBe(false);
|
||||
expect(senderStartComplete({ ...snapshot, bridgeGeneration: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds a sender snapshot that never settles", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const pending = waitForSenderStart(
|
||||
() => new Promise<never>(() => undefined),
|
||||
new SenderStartLedger(),
|
||||
{ timeoutMs: 25 },
|
||||
);
|
||||
const assertion = expect(pending).rejects.toThrow("Sender-start stage timed out");
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
await assertion;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -15,5 +15,11 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts", "tests"]
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"scripts/native-one-viewer-gate.ts",
|
||||
"scripts/native-one-viewer-ledger.ts",
|
||||
"tests"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user