fix: preserve room authority and repair Browser/Client recovery (#386)

* fix: preserve room authority and recover current media connections

* docs: close room authority and recovery integration
This commit is contained in:
AAA authored and GitHub committed 2026-09-10 01:30:07 +08:00
1 parent c9a6642ac1
commit 35f9251088
67 files changed
+1553 -1441

No files matched your search

+4 -6
View File
@@ -9,12 +9,10 @@ ALLOWED_ORIGINS=http://localhost:8787
# Production-required site access secret. Local development/tests may leave it
# empty. A non-empty value must contain 8-128 visible ASCII bytes (0x21-0x7e).
SITE_ACCESS_PASSWORD=
# Dormant rooms remain resumable by the exact Host token for this lease.
# Active sharing does not expire.
ROOM_LEASE_SECONDS=86400
# Optional stable room authority. Leave empty for process-memory rooms. Use one
# access-restricted absolute local file path per application process; production
# activation requires a verified writable directory and database recovery procedure.
# Rooms persist in rooms.sqlite under the working directory by default (also
# when blank). Set :memory: explicitly for process-memory rooms. Deployments
# should use an access-restricted absolute path outside the release directory,
# preserving the existing database and its writable parent across updates.
ROOM_DATABASE_PATH=
MAX_VIEWERS_PER_ROOM=8
# Bounded standard-WebRTC relay/SFU controller is active for every room.
+2 -3
View File
@@ -77,8 +77,7 @@ func fail(err error) {
}
// serve runs the application until the first SIGINT or SIGTERM, then stops it
// with Close rather than End: Hosted rooms are leased in the database and must
// survive a restart (DECISIONS D6).
// with Close rather than End: durable room authority survives a restart.
func serve(debug bool) (returnedErr error) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ReplaceAttr: diagnostics.ReplaceAttr}))
var recorder *diagnostics.Recorder
@@ -117,7 +116,7 @@ func serve(debug bool) (returnedErr error) {
"sqlite": configuration.RoomDatabasePath != "", "siteAccessProtected": configuration.SiteAccessPassword != "",
"maxViewers": configuration.MaxViewersPerRoom, "endpointCapacity": configuration.EndpointMediaCopyCapacity,
"sfu": configuration.SFU, "stunURLs": configuration.STUNURLs,
"natPrediction": configuration.NATPredictionEnabled, "roomLeaseMs": configuration.RoomLeaseMs,
"natPrediction": configuration.NATPredictionEnabled,
})
}
server, err := app.New(app.Options{
+1 -1
View File
@@ -2,7 +2,7 @@ FROM gcr.io/distroless/static-debian13:nonroot-amd64@sha256:e754765ad9e167b0677b
WORKDIR /app
COPY --chmod=0555 screener-server /app/
COPY --chmod=0444 LICENSE THIRD-PARTY-NOTICES.txt REVISION /app/
ENV LISTEN_HOST=0.0.0.0 SCREENER_LOG_DIR=/home/nonroot/logs
ENV LISTEN_HOST=0.0.0.0 SCREENER_LOG_DIR=/home/nonroot/logs ROOM_DATABASE_PATH=/home/nonroot/rooms.sqlite
USER 65532:65532
EXPOSE 8787/tcp 3478/udp 3479/udp 3480/udp 7882/udp
ENTRYPOINT ["/app/screener-server"]
+1
View File
@@ -9,6 +9,7 @@ User=screener
Group=screener
WorkingDirectory=/opt/screener/current
Environment=SCREENER_ENV=production
Environment=ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite
EnvironmentFile=/etc/screener/screener.env
ExecStart=/opt/screener/current/screener-server
Restart=on-failure
@@ -1,7 +1,7 @@
# ADR-0002: Optional Durable Room Authority And Scoped Viewer Access
# ADR-0002: Durable Room Authority And Scoped Viewer Access
- Status: Accepted; implemented; stable mode deployed
- Date: 2026-08-26
- Status: Accepted; implemented
- Date: 2026-09-10
## Context
@@ -26,40 +26,41 @@ Every room owns:
- one random free code from `1000` through `9999`;
- a SHA-256 Host-token digest and Viewer-grant digest;
- a monotonically increasing Viewer authorization generation;
- `open | private` code entry and optional salted scrypt password material;
- one absolute dormant lease deadline, or an active-Host marker.
- `open | private` code entry and optional salted scrypt password material.
`ROOM_LEASE_SECONDS` defaults to 86,400 seconds. An authenticated Host keeps the
room active; stop or disconnect starts the dormant lease. The exact Host token
may resume before expiry. Viewer activity never renews the room. Expiry or
explicit replacement invalidates every credential and releases the code.
Room authority has no time limit. Stop, disconnect and inactivity do not change
it. Explicit replacement or deletion invalidates its credentials and releases
the code; grant rotation or revocation retires the invitation independently.
There is no lease, renewal, expiry timer or replacement garbage collector.
The existing 9,000-code bound remains; a full store rejects new rooms.
Site-access cookies retain their separate 24-hour rolling idle lifetime.
### Lightweight And Stable Storage
`ROOM_DATABASE_PATH` is optional and absent by default:
Hosted Server uses SQLite by default:
- when absent, the bounded RoomStore is process memory and restart loses every
room and credential;
- when present, the same RoomStore persists its authority aggregate in one
SQLite file. The production target enables this stable mode after its
persistent-state deployment and recovery checks pass.
- absent or empty `ROOM_DATABASE_PATH` resolves `rooms.sqlite` in the working
directory; service/container templates select a writable persistent directory;
- an explicit absolute path selects that SQLite file;
- `ROOM_DATABASE_PATH=:memory:` explicitly selects process-only rooms, whose
authority ends at process exit. Client Local uses this memory composition
directly and does not inherit Hosted environment defaults.
Stable mode stores only `roomId`, Host-token digest, Viewer-grant digest,
authorization generation, code-entry policy, optional password verifier, and
dormant lease deadline. It never stores raw tokens, grants, or passwords.
authorization generation, code-entry policy and optional password verifier.
It never stores raw tokens, grants, or passwords.
The schema is one exact current version on an embedded SQLite driver, one
connection, one writer, and transactional room mutations. An unknown schema,
wrong application identity, corrupt row, inaccessible path, or second owner
fails startup before signaling or LiveKit mutation. This private pre-release
contract has no legacy reader or migration chain; an incompatible database must
be explicitly replaced from a verified recovery boundary.
contract has no legacy reader or migration chain. A schema cutover must preserve
existing authority through an explicit offline operation on a verified copy,
with the previous database and application retained for rollback. Removing the
lease column never deletes rooms or rotates credentials.
On restart, expired rows are deleted. A saved dormant deadline remains exact. A
row last observed with an active Host becomes dormant until
`startup time + ROOM_LEASE_SECONDS`; restart never claims that the old Host,
socket, share, or media route is still online. Successful Host reauthentication
marks it active again.
On restart, every saved room authority is restored. No Host, socket, share or
media route is considered online until fresh sessions establish it again.
Participants, display names, client/peer/session IDs, share generation, pause,
quality settings, codec decisions, route graph, pending operations, first-frame
@@ -78,7 +79,7 @@ never identity.
The browser also remembers one non-secret preferred room code with no local
expiry or renewal timer. It is only a future allocation hint. The server remains
the sole owner of room existence, expiry and code allocation: it reuses the hint
the sole owner of room existence and code allocation: it reuses the hint
only when free, otherwise allocates another random free code and the browser
replaces its preference.
@@ -104,7 +105,7 @@ the digest and authorization generation.
`open` admits site-authorized code entry without a room password. `private`
disables passwordless code entry; without a password it is invitation-only, and
with a password it additionally admits a matching code-and-password attempt.
Missing or expired codes return `ROOM_NOT_FOUND`; expected existing-room denial
Missing or deleted codes return `ROOM_NOT_FOUND`; expected existing-room denial
returns `ROOM_ACCESS_DENIED`.
## Consequences
@@ -112,7 +113,7 @@ returns `ROOM_ACCESS_DENIED`.
- Lightweight mode remains the smallest complete server and leaves no durable
room authority after process exit.
- Stable mode preserves room code, Host ownership, invitations, revocation,
password policy and lease across application releases without persisting live
password policy across application releases without persisting live
topology or media state.
- Application restart in stable mode is a bounded reauthentication and media
rebuild, not uninterrupted playback. LiveKit high availability remains a
@@ -124,10 +125,9 @@ returns `ROOM_ACCESS_DENIED`.
## Acceptance Gates
- Both modes pass the same creation, ownership, admission, password,
rotate/revoke, expiry, replacement and 9,000-code-capacity behavior.
- Stable restart preserves exact room authority and dormant deadlines, converts
crash-active rooms to one configured dormant lease, and restores no participant
or media authority.
rotate/revoke, replacement and 9,000-code-capacity behavior.
- Inactivity cannot invalidate room authority. Stable restart preserves it
exactly and restores no participant or media authority.
- A surviving Host and Viewer can reauthenticate after restart, receive fresh
sessions/routes, and recommit media; an old revoked grant stays revoked.
- Replacement never returns the old code, never partially retires the old room,
@@ -246,6 +246,11 @@ WebRTC and LiveKit first own ICE/consent and transient reconnect on the current
route. Only framework recovery exhaustion, hard failure, a non-paused decoded-
frame stall, confirmed departure, or capacity invalidation enters route
reconciliation. Manual reconnect remains on the exact current route.
The retained connection and a same-parent candidate keep separate signaling
authority. A replacement offer from the retained parent supersedes optional
preparation for that child at adoption, including an SFU bootstrap carrier's
trial. This retires its reservation and fences late candidate signaling;
required availability work keeps priority and candidate budgets do not reopen.
Authoritative Host pause aborts the pending operation and reservations, preserves
the committed graph, suppresses decoded-stall authority, and leaves new Viewers
+36 -3
View File
@@ -135,6 +135,39 @@ through the operator environment; never place it in the repository or command
line. The command does not mutate files, services, containers, or persistent
state. A different current-revision file may be supplied as its only argument.
## Permanent-Room Schema Cutover
Schema 2 / signaling `screener-v23` requires matching Web/Client/Server builds
and an accepted active-session interruption. Native control stays v9; Browser
credential keys stay unchanged. This is not an app-only release.
1. Verify the candidate; record the current release, environment, absolute DB
path and ownership. Stop ingress and the old application; take and retain a
SQLite backup. Require `application_id=1396920910`, `user_version=1`, expected
columns and `PRAGMA quick_check='ok'` before touching a separate protected copy.
2. On that copy, use SQLite with `DROP COLUMN` support and stop on SQL errors:
```sql
BEGIN EXCLUSIVE;
ALTER TABLE rooms DROP COLUMN lease_expires_at_ms;
PRAGMA user_version = 2;
COMMIT;
```
3. Verify integrity, exact columns/version and equal row counts. Compare retained
columns in both directions against the backup using `EXCEPT`; both must be
empty. Record only counts/pass/fail, not private verifiers. The candidate must
open this copy successfully; no room is dropped because of its former deadline.
4. Retain the absolute `ROOM_DATABASE_PATH`; remove `ROOM_LEASE_SECONDS`. Install
the verified copy and release while stopped, retaining permissions/ownership.
Start; check health and Host/Viewer reauthentication before reopening ingress.
5. On failure, stop and restore prior database, environment and release together.
Once new authority mutations are accepted, restoring the backup could revive
revoked grants: preserve those changes or make an explicit recovery decision.
The runtime reads only schema 2. SQLite's [column removal](https://www.sqlite.org/lang_altertable.html#altertabdropcol)
and [backup guidance](https://www.sqlite.org/backup.html) define this offline operation.
## Atomic Cutover
### First embedded-media cutover prerequisite
@@ -142,7 +175,7 @@ state. A different current-revision file may be supplied as its only argument.
The first move from external services to embedded STUN/SFU is a coordinated
infrastructure and protocol transaction, including Node-to-Go where still
needed. Complete the [self-hosting cutover procedure](./operations/self-hosting.md#coordinated-embedded-media-cutover)
with matching `screener-v22` Web/Server and native protocol v9 Client builds,
with matching Web/Server signaling and native protocol v9 Client builds,
accepted active-session interruption, released UDP ports, and exact
unit/environment/proxy/service recovery. The routine wrapper does not perform
that transaction; it requires an already running packaged Go release and cannot
@@ -178,8 +211,8 @@ strict-shell error traps.
Stable mode stores only room authority at the configured SQLite path. The
systemd service owns its state directory and file permissions. Application
restart retains room ownership, invitations, policy, password verifier, and
lease; participants, signaling, routes, SFU state, and media reconnect from fresh
restart retains room ownership, invitations, policy and password verifier;
participants, signaling, routes, SFU state and media reconnect from fresh
process state.
Lightweight mode has no persistent room state and starts empty. A release does
+239
View File
@@ -0,0 +1,239 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Piik · Brand study / 品牌预览</title>
<style>
:root { color-scheme: light; --wall:#e9f2ee; --wall2:#dcebe4; --ink:#22303e; --paper:#fff; --orange:#e4572e; --green:#1f7a4a; --spark:#d08a22; }
:root[data-theme="dark"] { color-scheme:dark; --wall:#141e29; --wall2:#0e1620; --ink:#dde8f3; --paper:#22303e; --green:#9de8bf; --spark:#f4c14d; }
* { box-sizing:border-box; }
body { margin:0; background:var(--wall); color:var(--ink); font:16px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; }
a { color:inherit; text-underline-offset:3px; }
main { max-width:1100px; margin:auto; padding:28px 28px 44px; }
header, .controls, .section-head, .card-heading, footer { display:flex; align-items:center; justify-content:space-between; gap:16px; }
header { padding-bottom:27px; border-bottom:1px solid color-mix(in srgb,var(--ink) 18%,transparent); }
.identity { display:flex; align-items:center; gap:10px; font-weight:750; letter-spacing:-.03em; }
.identity svg { width:32px; height:32px; }
.eyebrow, .number, .small-label { font-size:12px; font-weight:650; letter-spacing:.09em; text-transform:uppercase; }
.eyebrow { color:var(--green); }
h1,h2,h3,p { margin:0; }
h1 { font-size:clamp(32px,4.5vw,52px); line-height:1.1; letter-spacing:-.055em; max-width:520px; margin:12px 0 16px; }
h2 { font-size:25px; letter-spacing:-.035em; }
h3 { font-size:17px; letter-spacing:-.02em; }
.intro { display:grid; grid-template-columns:1fr 1fr; align-items:center; gap:32px; margin:44px 0 32px; }
.intro p { max-width:440px; }
.muted { opacity:.73; }
.hero { border:2px solid var(--ink); border-radius:22px; background:var(--paper); overflow:hidden; box-shadow:0 6px 0 var(--wall2); }
.stage { position:relative; padding:12px 16px 0; background:radial-gradient(ellipse at center,var(--wall2),transparent 70%); }
#morph { display:block; width:100%; height:auto; max-height:275px; overflow:visible; }
.caption { text-align:center; font-size:13px; padding:0 18px 17px; }
.controls { flex-wrap:wrap; justify-content:center; gap:8px; padding:15px; border-top:1px solid color-mix(in srgb,var(--ink) 15%,transparent); }
button { font:inherit; font-size:13px; font-weight:650; cursor:pointer; color:var(--ink); background:var(--paper); border:1.5px solid var(--ink); border-radius:9px; min-height:38px; padding:7px 12px; }
button:hover { background:var(--wall2); }
button:active { transform:translateY(1px); }
button:disabled { opacity:.45; cursor:default; }
button.primary { background:var(--ink); color:var(--paper); }
button:focus-visible,input:focus-visible { outline:3px solid var(--orange); outline-offset:3px; }
.motion-option { display:flex; align-items:center; justify-content:center; gap:7px; font-size:12px; padding:0 12px 13px; }
input { accent-color:var(--green); }
.section-head { margin:39px 0 15px; align-items:baseline; }
.section-head p { font-size:13px; }
.choices { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; }
article { padding:20px; border:1px solid color-mix(in srgb,var(--ink) 24%,transparent); border-radius:15px; background:var(--paper); }
article.recommended { border:2px solid var(--ink); padding:19px; }
.tag { padding:3px 8px; border-radius:20px; font-size:11px; color:var(--green); background:var(--wall); white-space:nowrap; }
.word { display:block; width:100%; height:105px; margin:10px 0 8px; }
article p { font-size:13px; margin-top:7px; }
.detail { display:grid; grid-template-columns:1fr 1.2fr; gap:30px; align-items:center; padding:24px; margin-top:18px; background:var(--wall2); border-radius:15px; }
.detail p { font-size:14px; margin-top:10px; }
.samples { width:100%; border-collapse:collapse; table-layout:fixed; text-align:left; }
th,td { border-bottom:1px solid color-mix(in srgb,var(--ink) 15%,transparent); padding:9px 6px; vertical-align:middle; }
th { font-size:12px; font-weight:650; }
th:first-child { width:58px; }
td svg { display:block; max-width:100%; }
.size { font-size:12px; white-space:nowrap; opacity:.7; }
.aside { display:flex; align-items:center; gap:12px; margin-top:15px; padding-top:13px; border-top:1px solid color-mix(in srgb,var(--ink) 15%,transparent); }
.aside svg { width:32px; height:32px; flex:none; }
.aside span { font-size:12px; }
.zh { display:block; margin-top:6px; font-size:13px; font-weight:400; letter-spacing:0; line-height:1.6; opacity:.75; }
.application { margin-top:18px; border:2px solid var(--ink); border-radius:18px; overflow:hidden; background:var(--paper); }
.application header { padding:16px 24px; }
.lockup { display:flex; align-items:center; gap:12px; }
.lockup .tv { width:36px; height:36px; }
.lockup .word { width:68px; height:38px; margin:0; }
.sample-nav { display:flex; flex-wrap:wrap; gap:16px; font-size:12px; opacity:.75; }
.site-hero { display:grid; grid-template-columns:1fr auto; align-items:center; gap:24px; padding:36px 32px; }
.site-hero h3 { font-size:clamp(25px,3vw,38px); line-height:1.2; letter-spacing:-.04em; }
.site-hero p { max-width:430px; margin-top:12px; font-size:14px; }
.site-hero > .tv { width:128px; height:128px; color:var(--ink); }
.steps { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); border-top:1px solid color-mix(in srgb,var(--ink) 18%,transparent); }
.steps > div { padding:18px 24px; }
.steps > div + div { border-left:1px solid color-mix(in srgb,var(--ink) 18%,transparent); }
.steps p { font-size:13px; margin-top:6px; }
.implementation { display:flex; flex-wrap:wrap; gap:10px 24px; margin:14px 0 0; font-size:12px; }
code { font-family:ui-monospace,"Cascadia Mono",monospace; }
footer { margin-top:27px; padding-top:18px; border-top:1px solid color-mix(in srgb,var(--ink) 18%,transparent); font-size:12px; flex-wrap:wrap; }
.fallback { font-weight:750; font-size:42px; letter-spacing:-.045em; }
.js .fallback { display:none; }
.no-js { font-size:13px; }
@media(max-width:760px) { main { padding:20px 18px 32px; } .intro { grid-template-columns:1fr; gap:24px; margin-top:30px; } .intro p { max-width:560px; } .hero { max-width:550px; width:100%; } .detail { grid-template-columns:1fr; gap:15px; } }
@media(max-width:500px) { .choices { grid-template-columns:1fr; gap:10px; } article { display:grid; grid-template-columns:1fr 1.3fr; column-gap:14px; align-items:center; } .card-heading { grid-column:1/-1; } .word { height:90px; grid-row:2/4; } article h3 { margin-top:12px; } article p { margin:4px 0; } .section-head { align-items:start; flex-direction:column; gap:3px; } .detail { padding:18px; } .eyebrow { font-size:11px; } .application header { padding:14px; } .sample-nav { gap:9px; font-size:11px; } .site-hero { padding:24px 18px; gap:12px; } .site-hero > .tv { width:70px; height:70px; } .steps { grid-template-columns:1fr; } .steps > div + div { border-left:0; border-top:1px solid color-mix(in srgb,var(--ink) 18%,transparent); } }
@media(prefers-reduced-motion:reduce) { button:active { transform:none; } }
</style>
<main>
<header>
<div class="identity"><svg class="tv" aria-hidden="true" viewBox="0 0 32 32"></svg><span>Piik / 品牌预览</span></div>
<button id="theme" type="button" aria-label="Switch to dark theme">Dark room</button>
</header>
<section class="intro" aria-labelledby="intro-title">
<div>
<div class="eyebrow">01 / A name taking shape</div>
<h1 id="intro-title">A little peek.<br>A familiar face.</h1>
<p>From <strong>“Peek” in games</strong> to a little TV for watching together. Keep the wink. Give it a name.</p>
<p class="muted" style="font-size:14px;margin-top:14px" lang="zh-CN">探个头,看看朋友在玩什么。名字换了,小电视还是那个小电视。</p>
</div>
<div class="hero">
<div class="stage">
<span class="fallback">Piik → TV</span>
<svg id="morph" viewBox="70 20 330 210" role="img" aria-labelledby="morph-title morph-desc">
<title id="morph-title">Piik becomes the winking TV</title>
<desc id="morph-desc">The P bowl opens into the screen, with the outer letter stems joining its sides. The two i dots become eyes, their stems become feet, and the k arms open into the TV antennae.</desc>
</svg>
</div>
<p class="caption">Same strokes. Familiar wink.<span class="zh" lang="zh-CN">P 撑开屏幕,ii 变成眼睛和脚,k 的两笔转成电视天线。</span></p>
<div class="controls">
<button id="replay" class="primary" type="button">重播 / Replay</button>
<button id="pause" type="button">暂停 / Pause</button>
<button id="word-state" type="button">字标 / Wordmark</button>
<button id="tv-state" type="button">电视 / TV</button>
</div>
<label class="motion-option"><input id="reduce" type="checkbox"> 减少动态 / Reduced motion</label>
</div>
</section>
<noscript><p class="no-js">Motion is optional. Piik is the recommended spelling; PiiK and piik are the two alternatives below. The original TV remains the small icon.</p></noscript>
<div class="section-head"><h2>Three ways to write it<span class="zh" lang="zh-CN">三个名字写法,同一套笔画</span></h2><p class="muted">One drawing style. Only the casing changes.</p></div>
<section class="choices" aria-label="Casing comparison">
<article class="recommended">
<div class="card-heading"><span class="number">01</span><span class="tag">Recommended</span></div>
<span class="fallback">Piik</span><svg class="word" data-case="Piik" role="img" aria-label="Piik, with an uppercase P and lowercase i i k"></svg>
<h3>Piik</h3><p>A clear name, with two curious eyes.<span class="zh" lang="zh-CN">主方案。首字母清楚,两个 i 留一点好奇心。</span></p>
</article>
<article>
<div class="card-heading"><span class="number">02</span><span class="tag">More contrast</span></div>
<span class="fallback">PiiK</span><svg class="word" data-case="PiiK" role="img" aria-label="PiiK, with uppercase P and K"></svg>
<h3>PiiK</h3><p>A stronger K gives the name a sharper silhouette.<span class="zh" lang="zh-CN">两侧更高、更硬朗,也更抢眼。</span></p>
</article>
<article>
<div class="card-heading"><span class="number">03</span><span class="tag">Softer</span></div>
<span class="fallback">piik</span><svg class="word" data-case="piik" role="img" aria-label="piik, all lowercase"></svg>
<h3>piik</h3><p>Relaxed and compact, with a lower centre of gravity.<span class="zh" lang="zh-CN">更轻松。小写 p 会伸到基线下方。</span></p>
</article>
</section>
<section class="detail" aria-labelledby="detail-title">
<div>
<div class="eyebrow">02 / The two little details</div>
<h2 id="detail-title">Two dots. Two curious eyes.</h2>
<p>Round dots keep the <strong>ii</strong> easy to read, then become the familiar TV eyes.</p>
<p class="muted">The wordmark below is shown at everyday sizes.</p>
<p class="zh" lang="zh-CN">两个圆点保留 ii 的辨识度,再变成小电视的眼睛。下面按实际尺寸查看。</p>
<div class="aside"><svg class="tv" aria-hidden="true" viewBox="0 0 32 32"></svg><span>Keep the existing TV for the tiny icon.<br>No four-letter favicon puzzle.</span></div>
</div>
<table class="samples" aria-label="Piik at actual sizes">
<thead><tr><th>Height</th><th>Piik</th></tr></thead>
<tbody>
<tr><td class="size">64 px</td><td><svg data-size="64" role="img" aria-label="Piik at 64 pixels"></svg></td></tr>
<tr><td class="size">32 px</td><td><svg data-size="32" role="img" aria-label="Piik at 32 pixels"></svg></td></tr>
<tr><td class="size">24 px</td><td><svg data-size="24" role="img" aria-label="Piik at 24 pixels"></svg></td></tr>
</tbody>
</table>
</section>
<div class="section-head"><h2>At home on a page<span class="zh" lang="zh-CN">放到页面上:官网首屏的方向草图</span></h2><p class="muted">A static website. The sharing app stays a tool.</p></div>
<section class="application" aria-label="Static homepage composition, not a live sharing service">
<header>
<div class="lockup" role="img" aria-label="TV mascot and Piik wordmark"><svg class="tv" aria-hidden="true" viewBox="0 0 32 32"></svg><svg class="word" data-case="Piik" aria-hidden="true"></svg></div>
<div class="sample-nav"><span>Download</span><span>Guide</span><span>GitHub</span></div>
</header>
<div class="site-hero">
<div><h3>Your game.<br>Their front row.</h3><p>Share your screen. Bring your friends.<br>They watch right in their browsers.</p><p class="zh" lang="zh-CN">开一局,朋友坐前排。<br>发个邀请,打开浏览器就能看。</p></div>
<svg class="tv" aria-hidden="true" viewBox="0 0 32 32"></svg>
</div>
<div class="steps">
<div><span class="number">01 / Pick</span><p>A screen, a window, a game.<span class="zh" lang="zh-CN">选好想分享的画面。</span></p></div>
<div><span class="number">02 / Invite</span><p>Send a link to your friends.<span class="zh" lang="zh-CN">把邀请链接发给朋友。</span></p></div>
<div><span class="number">03 / Hang out</span><p>A little room for your people.<span class="zh" lang="zh-CN">人到齐,就一起看。</span></p></div>
</div>
</section>
<div class="implementation"><span>Display / 显示名称 <strong>Piik</strong></span><span>Technical / 技术名称 <code>piik-client</code></span><span>Website / 官网 <code>piik.tv</code></span><span>Page title / 页签 <code>Piik | 6020 · 📺</code></span></div>
<footer><span>Design study · 09 Sep 2026 · 待讨论,尚未替换产品</span><span>Layout references: <a href="https://localsend.org/">LocalSend</a> · <a href="https://rustdesk.com/">RustDesk</a></span></footer>
</main>
<script>
const $ = (id) => document.getElementById(id);
const ns = 'http://www.w3.org/2000/svg';
const tv = '<g fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m11 4 5 4 5-4"/><rect x="4" y="8" width="24" height="18" rx="4"/><circle cx="11.5" cy="16.5" r="1.25" fill="currentColor" stroke="none"/><path d="M19 17c1.5-1.8 3.2-1.8 4.7 0M10 29l2-3m10 3-2-3"/></g>';
document.querySelectorAll('.tv').forEach((svg) => svg.innerHTML = tv);
// The k diagonals become antennae; the outer stems join the screen edges.
const parts = [
['rect', {x:108,y:72,width:58,height:48,rx:20}, {x:172,y:76,width:136,height:102,rx:22.667}],
['line', {x1:108,y1:72,x2:108,y2:164}, {x1:172,y1:98.667,x2:172,y2:155.333}],
['line', {x1:194,y1:118,x2:194,y2:164}, {x1:217.333,y1:178,x2:206,y2:195}],
['line', {x1:236,y1:118,x2:236,y2:164}, {x1:262.667,y1:178,x2:274,y2:195}],
['line', {x1:282,y1:72,x2:282,y2:164}, {x1:308,y1:98.667,x2:308,y2:155.333}],
['line', {x1:284,y1:139,x2:318,y2:111}, {x1:240,y1:76,x2:268.333,y2:53.333}],
['line', {x1:294,y1:131,x2:326,y2:164}, {x1:211.667,y1:53.333,x2:240,y2:76}],
];
const attributes = (node, values) => Object.entries(values).forEach(([key,value]) => node.setAttribute(key,String(value)));
function element(parent, tag, values) { const node = document.createElementNS(ns,tag); attributes(node,values); parent.append(node); return node; }
function animate(node, name, from, to, begin=.65, dur=1.25) {
return element(node,'animate',{attributeName:name,from,to,begin:begin+'s',dur:dur+'s',fill:'freeze',calcMode:'spline',keyTimes:'0;1',keySplines:'.4 0 .2 1'});
}
function drawWord(svg, casing='Piik', moving=false) {
if (!moving) svg.setAttribute('viewBox','96 58 244 138');
const group = element(svg,'g',{fill:'none',stroke:'currentColor','stroke-width':10,'stroke-linecap':'round','stroke-linejoin':'round'});
parts.forEach(([tag,start,end],index) => {
const shape = {...start};
if (casing === 'piik') { if(index===0) {shape.y=112;shape.height=48;} if(index===1) {shape.y1=112;shape.y2=188;} }
if (casing === 'PiiK') { if(index===5) {shape.y1=122;shape.y2=72;} if(index===6) {shape.x1=286;shape.y1=120;} }
const node = element(group,tag,shape);
if(moving) Object.keys(end).forEach(key => animate(node,key,shape[key],end[key]));
});
[194,236].forEach((cx,index) => {
const eyeX = index?270.317:214.5;
const eye = element(group,'ellipse',{cx,cy:95,rx:6.5,ry:6.5,fill:'currentColor',stroke:'none'});
if(moving) { animate(eye,'cx',cx,eyeX); animate(eye,'cy',95,124.167); animate(eye,'rx',6.5,7.083); animate(eye,'ry',6.5,7.083); if(index) animate(eye,'ry',7.083,0,2.2,.16); }
});
if(moving) {
animate(group,'stroke-width',10,13.6);
const wink = element(group,'path',{d:'M257 127c8.5-10.2 18.133-10.2 26.633 0',pathLength:1,'stroke-dasharray':1,'stroke-dashoffset':1});
animate(wink,'stroke-dashoffset',1,0,2.3,.23);
const sparkles = element(group,'g',{stroke:'var(--spark)','stroke-width':7.933,opacity:0});
element(sparkles,'path',{d:'M291 95.8L295.5 106.6L306.3 111.1L295.5 115.7L291 126.4L286.5 115.7L275.7 111.1L286.5 106.6Z'});
element(sparkles,'path',{d:'M291 128.1L293.6 134.4L299.8 136.9L293.6 139.5L291 145.7L288.5 139.5L282.2 136.9L288.5 134.4Z'});
animate(sparkles,'opacity',0,1,2.4,.15);
animate(sparkles,'opacity',1,0,2.8,.4);
}
}
drawWord($('morph'),'Piik',true);
document.querySelectorAll('[data-case]').forEach(svg => drawWord(svg,svg.dataset.case));
document.querySelectorAll('[data-size]').forEach(svg => { const height=Number(svg.dataset.size); attributes(svg,{height,width:height*244/138}); drawWord(svg); });
const morph=$('morph'), reduce=$('reduce'), pause=$('pause'), motion=matchMedia('(prefers-reduced-motion: reduce)');
const duration=3.3;
let frame;
function still(at) { cancelAnimationFrame(frame); morph.pauseAnimations(); morph.setCurrentTime(at); pause.textContent='继续 / Resume'; pause.disabled=reduce.checked||at>=duration; }
function watch() { if(morph.getCurrentTime()>=duration) { still(duration); return; } frame=requestAnimationFrame(watch); }
function replay() { still(0); if(reduce.checked) { still(duration); return; } pause.textContent='暂停 / Pause'; pause.disabled=false; morph.unpauseAnimations(); watch(); }
$('replay').onclick=replay;
pause.onclick=() => { if(morph.animationsPaused()) { pause.textContent='暂停 / Pause'; morph.unpauseAnimations(); watch(); } else still(morph.getCurrentTime()); };
$('word-state').onclick=() => still(0);
$('tv-state').onclick=() => still(duration);
reduce.checked=motion.matches;
reduce.onchange=() => still(reduce.checked?duration:0);
motion.addEventListener('change',event => { reduce.checked=event.matches; reduce.onchange(); });
const dark=matchMedia('(prefers-color-scheme: dark)');
function theme(isDark) { document.documentElement.dataset.theme=isDark?'dark':'light'; $('theme').textContent=isDark?'Light room':'Dark room'; $('theme').setAttribute('aria-label',isDark?'Switch to light theme':'Switch to dark theme'); }
theme(dark.matches);
$('theme').onclick=() => theme(document.documentElement.dataset.theme!=='dark');
document.documentElement.classList.add('js');
if(reduce.checked) still(0); else replay();
</script>
</html>
+7 -5
View File
@@ -64,9 +64,11 @@ domains, certificates, users, and resource limits required by the host.
`/opt/screener/current` to it before enabling the unit. The tracked release
wrapper is upgrade-only and deliberately refuses a missing prior release;
this repository does not provide a generic first-install transaction.
5. When stable room authority is enabled, use the service-owned
`/var/lib/screener` StateDirectory and include the SQLite file in the host's
state backup/recovery policy.
5. SQLite is the Hosted default. The service template selects
`/var/lib/screener/rooms.sqlite` in its service-owned StateDirectory; retain
that absolute path in the environment file and include it in the host's
backup/recovery policy. Use `ROOM_DATABASE_PATH=:memory:` only for intentional
process-only rooms. Do not leave an empty override in a service environment.
6. Install the tracked service and proxy templates. Bind HTTP to loopback on
bare metal; STUN and SFU retain their independent public IPv4 binds.
Containers may bind HTTP to `0.0.0.0` only when publishing/firewall rules
@@ -127,7 +129,7 @@ docker run -d --name screener --restart unless-stopped --stop-timeout 20 \
The image's `/home/nonroot` is owned by UID/GID `65532:65532` with mode `0700`.
A new named volume inherits that directory; an existing volume or bind mount
must already be writable by that identity. Keep this volume when replacing the
container. Omit `ROOM_DATABASE_PATH` for memory-only room authority. Diagnostics
container. Set `ROOM_DATABASE_PATH=:memory:` for memory-only room authority. Diagnostics
remain opt-in and use `/home/nonroot/logs`; [export and retention](../reference/configuration.md#diagnostics)
remain the operator's responsibility. Do not mount application files writable.
With diagnostics enabled, `docker kill --signal=USR1 screener` requests a local
@@ -148,7 +150,7 @@ Replacing the external STUN/SFU services, including Node-to-Go where still
needed, is one explicitly authorized infrastructure and protocol transaction.
The routine application wrapper cannot perform or recover it.
1. Prepare and verify matching `screener-v22` Web/Server and native protocol v9
1. Prepare and verify matching Web/Server signaling and native protocol v9
Client artifacts. Check active sessions and obtain the owner's acceptance of
share interruption before cutover; old pages and Clients must reload or
update together.
+17 -3
View File
@@ -25,6 +25,19 @@ Browser picker and any exact Client-owned native windows. It never chooses a
window automatically. A reproduced Browser-window, capture, or background
failure is required before introducing an embedded Web runtime.
A remembered Client activation permits discovery when choosing a source or
receiving media; it does not make Client a prerequisite for sharing. Idle Host
pages hold no Client control session. Ordinary Web entry opens the Browser picker
directly. Activated origins offer the shared source selector, initially on its
Browser tab unless Client is already connected. Discovery never blocks Browser
selection or changes the chosen tab. Native tabs and refresh remain reachable
after absence or disconnection; pending attempts are shared, completed failures
do not suppress later discovery. Cancelling the selector or ending a share
releases its unused control session; an obsolete operation cannot install or
retire another operation's media. Optional native ingress uses an already
available connection without another scan. If it is not ready at Browser
startup, that share keeps Browser senders.
## Visual Language
The interface presents one small shared living room rather than an operations
@@ -101,7 +114,7 @@ black screen, ICE-connected state, or unproved `playing` event.
- Host signaling loss alone does not invalidate media that is still healthy.
- Exact media failure or terminal route failure invalidates current-frame proof
before showing a retained frozen background and recovery or failure state.
- Room not found/expired is distinct from current-room access denial and does not
- Room not found is distinct from current-room access denial and does not
prompt for a password.
- Manual reconnect recovers the same route. It does not select a new parent.
- An actionable overlay may appear while fullscreen remains active; application
@@ -136,8 +149,9 @@ that reported adaptation; they do not by themselves locate the physical
bottleneck or describe the Viewer's receive path.
Locally exposed selected-candidate addresses may be shown only on the Browser
that owns that PeerConnection and are never uploaded, persisted, or used for
identity or route selection. The product does not offer a diagnostic-download
button or a raw stats dump.
identity or route selection. Explicit Debug mode can export a bounded diagnostic
report through the separate [diagnostic owner](../reference/configuration.md#diagnostics);
ordinary connection details do not collect or expose that report.
Current scope includes Web Host, Web Viewer, Browser relay, and optional Native
Host/Viewer media beneath those same pages. Desktop and mobile Browsers are
+14 -12
View File
@@ -19,16 +19,17 @@ the storage decision; implementation detail belongs in code and tests.
but duplicated-tab exclusion is not guaranteed. Closing the tab releases its
claim; losing Host authority clears that tab without erasing another tab's
resume hint.
- Dormant rooms have a configurable lease, default 24 hours. An authenticated
online Host clears the lease deadline whether or not it is currently sharing;
Host absence starts the dormant lease. The exact Host token resumes an
unexpired room; Viewer activity never renews it.
- Room identity and credentials do not expire with inactivity. The exact Host
token resumes the room until explicit replacement or deletion. Grant rotation
or revocation ends an invitation without changing the room code. No presence
event renews or shortens room authority.
- The Host may replace its room code. Replacement atomically creates a different
room and invalidates the old ownership, invitations, password, sessions,
routes, and media resources. It ends an active share and stops its capture
tracks; it does not migrate or automatically restart capture.
- A locally remembered preferred code is only a request. The server remains the
authority and allocates another code when that code is unavailable or expired.
authority and allocates another code when that code is unavailable. The store
remains bounded by 9,000 codes; it rejects new rooms when full.
## Admission Paths
@@ -64,7 +65,7 @@ Code-only admission is independent of invitations:
- `private` with a password additionally admits a site-authorized matching
code-and-password attempt.
An unknown, reclaimed, or expired code returns `ROOM_NOT_FOUND`. A current room
An unknown or deleted code returns `ROOM_NOT_FOUND`. A current room
that refuses a code-only attempt returns the generic `ROOM_ACCESS_DENIED` result
without exposing its exact policy. Invalid grants and Host ownership failures
remain separate.
@@ -79,10 +80,11 @@ code-only attempts and does not silently revoke invitations.
## Persistence Modes
- **Lightweight mode:** without `ROOM_DATABASE_PATH`, room authority is process
memory and all rooms disappear on restart.
- **Stable mode:** with an explicit SQLite path, one exact-schema owner persists
room authority across application restart.
- **Stable mode (Hosted default):** one exact-schema SQLite owner persists room
authority across application restart. `ROOM_DATABASE_PATH` selects an absolute
file path; absent or empty uses `rooms.sqlite` in the working directory.
- **Lightweight mode:** explicit `ROOM_DATABASE_PATH=:memory:` keeps room authority
in process memory and all rooms disappear on restart.
- **Client Local mode:** the packaged Client composes the same memory RoomStore
and ends every room when its local authority exits. It persists only its Site
choice and optional Local site-access password, not rooms or media state. A
@@ -99,8 +101,8 @@ synchronizing two stores.
Stable mode does not persist participants, signaling sessions, sharing state,
routes, codec decisions, quality evidence, or SFU state. After restart, clients
reauthenticate and rebuild media; a room that was active becomes dormant with a
fresh lease. [Status](../status.md) owns the selected deployment mode and
reauthenticate and rebuild media. No restored room is considered online until
fresh sessions connect. [Status](../status.md) owns the selected deployment mode and
ADR-0002 owns storage validation.
The Host Browser may persist its creation preferences, including policy and an
+3
View File
@@ -113,6 +113,9 @@ without admitted overlap.
Framework reconnect runs before route reassignment. Manual media reconnect also
recovers only the current P2P parent or current SFU subscription; it does not
perform quality selection or choose another route.
Current-edge P2P signaling remains valid during candidate overlap. Accepting a
replacement from that parent cancels conflicting optional preparation so the
replacement can complete; a retired candidate cannot take its place afterward.
## SFU Fallback
+11 -8
View File
@@ -1,6 +1,6 @@
# Project Memory
Last updated: 2026-09-08
Last updated: 2026-09-10
Screener is private, low-latency game screen sharing for one Host and up to 20
authenticated friends. The current product surface is Web Host, Web Viewer, and
@@ -40,7 +40,7 @@ blank keeps the self-contained site open.
## Product Map
- [Rooms and access](./product/rooms-access.md) owns room codes, leases,
- [Rooms and access](./product/rooms-access.md) owns room codes,
invitations, code entry, credentials, and lightweight/SQLite persistence.
- [Routing and transport](./product/routing-transport.md) owns P2P-first
distribution, endpoint capacity, SFU fallback, recovery, and privacy limits.
@@ -56,8 +56,11 @@ implementation and routine UI detail.
## Core Invariants
- Rooms use random four-digit codes, exact Host ownership, room-scoped Viewer
invitations, independent `open | private` code entry, and a 24-hour dormant
lease. Production enables optional SQLite stable room authority; live
invitations and independent `open | private` code entry. Room authority ends
through explicit replacement/deletion, with separate invitation rotation or
revocation. Hosted defaults to SQLite, with explicit process-memory opt-out;
Client Local remains process-only. Site access has a separate 24-hour idle
cookie lifetime. Production persists SQLite room authority; live
participants, routes, and media remain process-only.
- Media is automatic and distributed. Ordinary peers are STUN-only and prefer
direct/peer UDP. A deployment may enable same-host auxiliary STUN for bounded,
@@ -91,11 +94,11 @@ implementation and routine UI detail.
## Current Snapshot
The candidate uses strict `screener-v22` Browser/server signaling, Native control
v9 and capture v7 together. Embedded SFU SDP/ICE and demand travel over the
The permanent-room candidate uses strict `screener-v23` Browser/server signaling,
Native control v9 and capture v7 together. Embedded SFU SDP/ICE and demand travel over the
authenticated room WebSocket, without an external room service or media token.
Committed first-frame readiness, bounded candidate-relative progress, optional
SQLite room authority and the H.264/VP8 sender gate remain. [Status](./status.md)
Committed first-frame readiness, bounded candidate-relative progress, durable
room authority and the H.264/VP8 sender gate remain. [Status](./status.md)
separates this source from last known production; protocol rollback requires
matching Browser and Client artifacts, not only an application symlink.
+9 -3
View File
@@ -14,8 +14,7 @@ service secret store or an untracked access-restricted environment file.
| `PUBLIC_BASE_URL` | Exact public HTTP(S) origin; production requires HTTPS. |
| `ALLOWED_ORIGINS` | Comma-separated exact HTTP(S) origins; wildcard is invalid. |
| `SITE_ACCESS_PASSWORD` | Production-required independent 8-128 visible-ASCII byte secret. |
| `ROOM_LEASE_SECONDS` | Positive dormant lease, default `86400`; active Host prevents expiry. |
| `ROOM_DATABASE_PATH` | Optional absolute SQLite file path; unset selects memory mode. |
| `ROOM_DATABASE_PATH` | Hosted defaults to `rooms.sqlite` in its working directory when unset or blank. An explicit absolute file path selects another SQLite file; `:memory:` opts into process-memory room authority. Client Local remains in memory. |
| `MAX_VIEWERS_PER_ROOM` | `1..20`, default `8`. |
| `ENDPOINT_MEDIA_COPY_CAPACITY` | Shared endpoint steady-copy cap `1..3`, default `2`. |
| `STUN_URLS` | Comma-separated advertised `stun:` discovery URLs; at least one is required in production. These are not local bind addresses and may use an unproxied DNS name separate from the Web origin. |
@@ -35,7 +34,14 @@ separate control origin or infrastructure credentials are configured. Local and
public-link Client construction create no SFU listener. The relay does not
provide application E2EE.
Removed access, room TTL, endpoint-tier, room-rollout, and TURN variables fail
The SQLite parent directory must exist and be writable. The systemd template
sets `/var/lib/screener/rooms.sqlite` under its managed state directory; the
container image sets `/home/nonroot/rooms.sqlite` under its writable data
directory. Keep the existing database path and data across application updates.
Room authority has no idle expiry; the separate site-access cookie keeps its
24-hour idle lifetime.
Removed access, room TTL/lease, endpoint-tier, room-rollout, and TURN variables fail
startup even when blank. A present `NODE_ENV` fails the same way, so a stale
environment file cannot silently drop a deployment out of production. The
private deployment is upgraded atomically; there are no compatibility aliases or
+20 -15
View File
@@ -1,19 +1,19 @@
# Cross-Restart Room Recovery
- Research date: 2026-08-26
- Status: SQLite stable room-authority mode implemented; production enabled
- Research date: 2026-08-26; permanent-room revision reviewed 2026-09-10
- Status: SQLite authority verified; current behavior is owned by
[rooms/access](../product/rooms-access.md)
## Current Conclusion
SQLite has one cohesive current consumer: the room authorization aggregate. It
is justified when application releases should retain not only a preferred code,
but the exact Host ownership, existing Viewer invitation, revocation high-water,
code-entry policy, password verifier and dormant lease. It is not a generic
code-entry policy and password verifier. It is not a generic
store for future features.
`ROOM_DATABASE_PATH` absent remains the default lightweight mode. Configuring an
exact file enables stable mode over the same RoomStore contract. Production uses
stable mode after passing its persistent-state and restart-recovery gate.
Hosted defaults to SQLite over the same RoomStore contract. Process-only mode
remains an explicit composition; neither mode needs time-based room expiry.
## Stored Authority
@@ -25,10 +25,9 @@ stable mode after passing its persistent-state and restart-recovery gate.
| Viewer authorization generation | Prevents rotate/revoke from rolling back across restart. |
| `open | private` | Preserves code-entry policy. |
| 48-byte scrypt material | Preserves optional password admission without plaintext. |
| Dormant lease deadline or active marker | Retains an exact dormant deadline; crash-active becomes dormant for one configured lease from startup. |
These values form one atomic row. Create, password/policy update, rotate/revoke,
lease transition, expiry, abandon and room replacement update the row in the same
abandon and room replacement update the row in the same
RoomStore mutation that changes memory authority.
## Deliberately Ephemeral State
@@ -62,7 +61,7 @@ A signed Host capsule is smaller only when recovery may issue a new invitation.
It cannot prove that an older capsule is not replaying a Viewer grant revoked by
a newer one. Stable old invitations plus strong rotate/revoke therefore require
a durable latest-authorization-generation high-water mark. Once that durable
owner exists, keeping the code, credential digests, policy, verifier and lease in
owner exists, keeping the code, credential digests, policy and verifier in
the same SQLite row is simpler than a client capsule, signing key and second
truth path.
@@ -76,19 +75,25 @@ second owner fails before signaling or LiveKit mutation.
Production activation is not an app-only cutover. It requires an access-restricted
writable directory, configuration backup, database backup/restore procedure and
rollback that removes the new environment setting before starting a release that
does not understand it. Old database backups can revive expired credentials, so
rollback that restores matching data, environment and application together.
Old database backups can revive revoked credentials, so
restoring one later requires an explicit credential-invalidating decision.
For the permanent-room revision, an offline `DROP COLUMN` transaction on a
copy of the prior synthetic database preserved both active and formerly expired
rooms. All six remaining authority fields matched exactly; the source backup
was unchanged. The current reader accepted the converted schema without a
compatibility path. The [deployment procedure](../deployment.md#permanent-room-schema-cutover)
owns the operational steps and recovery boundary.
## Acceptance Matrix
1. Both storage modes pass identical room behavior within one process.
2. Stable restart preserves code, token/grant validation, password, policy,
generation and lease while restoring zero participants or media state.
generation while restoring zero participants or media state.
3. A grant revoked before restart remains revoked afterward.
4. Active-at-crash rows become dormant once; repeated restart does not repeatedly
extend an already stored dormant deadline.
5. Expired rooms are deleted and their codes become free before readiness.
4. Presence and elapsed time do not mutate durable authority.
5. Explicit deletion/replacement releases codes; the allocation bound is unchanged.
6. Corrupt/mismatched/multiply owned state fails before accepting traffic.
7. Surviving Host/Viewer tabs reauthenticate and rebuild a fresh route; no test
labels that bounded rebuild as uninterrupted media.
+29 -39
View File
@@ -1,53 +1,43 @@
# Current Status
Last updated: 2026-09-09
Last updated: 2026-09-10
This is the compact execution/deployment index. Product modules own behavior,
[verification status](./verification-status.md) owns unresolved physical limits,
and Git/PRs own completed history.
## Production
## Accepted Release Contract
The coordinated embedded-media cutover is complete. The immutable release
descriptor, runtime REVISION and operator deployment record own exact identity.
- Browser/server v23, Native control v9 and capture v7 form one private contract.
The release requires matching Web/Client/Server artifacts and a reload of old
active pages at cutover.
- Hosted Server defaults to SQLite schema 2. Room authority has no inactivity
expiry; explicit replacement/deletion or grant rotation/revocation ends the
corresponding authority. Explicit memory mode and Client Local end rooms at
process exit. Site access retains its separate 24-hour idle lifetime.
- Browser/Client entry lifecycle and current-edge reconnect ownership repairs
are implemented. Browser node-local pooling and detailed local Debug export
remain part of the accepted media surface. Product modules own their behavior;
[configuration](./reference/configuration.md#diagnostics) owns Debug activation,
report contents and disclosure.
- One Go process serves the Web application, room authority/signaling,
Binding-only STUN on UDP 3478/3479/3480 and optional SFU on UDP 7882.
External LiveKit/coturn services are stopped and disabled. nginx retains HTTPS.
- Browser/server v22, Native control v9 and capture v7 form the current private
contract. Old pages and Clients must use matching artifacts.
- SQLite persists stable room authority; participants, routes and media remain
process-only. Lightweight mode remains available without a database path.
- Public health/assets and all three STUN Binding endpoints pass postflight.
Synthetic H264/VP8 media crosses the public SFU, retains decoded Opus through
720p-to-480p changes, and retires cleanly. Public signaling also passes a direct
Peer check; both direct endpoints were on the acceptance machine.
- Initial SFU startup exposed the systemd address-family restriction on Linux
interface discovery. The old release/unit/environment/proxy/media-service
state was restored successfully, then the corrected unit passed media
postflight. AF_NETLINK is needed by Go/Pion interface enumeration; no new
administrative capability was granted. The template correction is carried
in the current Debug supplement.
- This private deployment is not a public demonstration service.
## Deployment
## Current Supplement
The owner authorized integration, coordinated cutover and cleanup before the
Piik appearance phase. Authorization is not deployment evidence: the immutable
release descriptor, runtime `REVISION` and operator deployment record own the
actual deployed identity and postflight result. The
[schema cutover procedure](./deployment.md#permanent-room-schema-cutover) preserves
existing room authority and Browser credential keys with a database/application
rollback boundary.
Browser node-local pooling, the detailed Debug supplement and scoped Native
ablation passed the combined checks and matching Client/Server packaging. The
owner authorized complete integration and deployment on 2026-09-09, with later
hands-on feedback. Runtime release metadata owns the actual cutover identity.
The existing deployment uses one Go process for Web, room authority/signaling,
Binding-only STUN on UDP 3478/3479/3480 and optional SFU on UDP 7882. nginx owns
HTTPS; external LiveKit/coturn services remain disabled. Participants, routes and
media remain process-only. This private service is not a public demonstration.
The supplement retains the existing local recorder/export owners and adds
correlated requests, meaningful error causes, media/dependency observations,
selected runtime context and explicit partial-report/history information.
[Configuration](./reference/configuration.md#diagnostics) owns exact activation,
report contents and disclosure. The Linux service-template correction belongs
to this same supplement.
English/Chinese README, Quick Start, documentation map and license guidance are
prepared. The piik naming/visual discussion, static GitHub Pages website and
whole-repository ablation review follow closeout. No public site or bulk rename
is part of this release. [TODO](./todo.md) is the only work ledger.
[TODO](./todo.md) owns closeout and subsequent appearance, renaming, distribution
and ablation work. No public site or bulk rename belongs to this release.
## Media Evidence And Limits
@@ -69,7 +59,7 @@ is part of this release. [TODO](./todo.md) is the only work ledger.
- The Linux runtime container has non-root/read-only, memory/SQLite, diagnostic
export, STUN and listener-lifecycle evidence. This does not establish every
operator's proxy/firewall or public-media configuration.
- The independent Browser producer in ADR-0014 is integrated on the candidate.
- The independent Browser producer in ADR-0014 is integrated.
Bounded H264/VP8 checks cover direct and received-track sharing, weak-child
isolation/recovery, live/quiet settings, pause, source change and ordinary
fallback. Its native allocation and complete-frame output replace the earlier
+74 -20
View File
@@ -1,20 +1,43 @@
# Current TODO Ledger
Last reviewed: 2026-09-09
Last reviewed: 2026-09-10
Only **Now** is executable. Product modules own behavior, research owns evidence,
and Git/PRs own completed history. A parked idea is not implementation authority.
## Now
The owner authorized full closeout of Browser encoding pooling, detailed Debug,
Native ablation and release-entry documentation on 2026-09-09. Implementation,
bounded media checks and matching Client/Server package checks are complete.
Finish one squash integration, application-only deployment, scoped postflight
and cleanup; the owner's later hands-on acceptance does not block this closeout.
Runtime descriptors and deployment output own exact released identity.
Finish the owner-authorized PR386 squash, matched Web/Client/Server cutover and
cleanup, then resume Piik appearance. Entry/Client lifecycle repairs, confirmed
reconnect ownership fixes and the permanent-room/Hosted SQLite-default revision
are implemented and verified. Product modules and
[ADR-0002](./adr/0002-memory-resident-protected-rooms.md) own that accepted behavior.
Immediately afterward, enter the **piik discussion**. The owner owns **piik.tv**.
Use the [coordinated cutover](./deployment.md#permanent-room-schema-cutover) to
preserve existing SQLite authority and unchanged Browser credential keys; old
active pages reload at the signaling v23 boundary. Runtime release metadata and
the operator record own deployment completion. Do not recreate databases or
add a replacement garbage collector. Later manual feedback is nonblocking.
## Next — Awaiting Owner Direction
1. Resume the [Piik brand study](./design/piik-brand.html) after the experience
repairs. Keep round i dots; the tiny-e experiment was rejected. Try using the
two outer stems as small headphones/earcups on the TV, retaining its familiar
silhouette and the k diagonals as antennae. Display `Piik` and technical
`piik` remain proposals. Settle identity before coordinated renaming or the
public-site implementation.
2. After the brand discussion, rename display copy, Go/npm source identity,
Client/Server packages and OS icons, strict protocol/service identifiers,
diagnostic redaction, configuration and deployment tooling in one coordinated
phase. Inventory is complete. Browser storage contains room authority/grants
and Client configuration contains a saved Site; decide preservation/reset
explicitly before renaming their keys or directories. Preserve SQLite room
authority when changing deployment paths. Use matching Web/Client/Server
artifacts and one current reader/writer, without compatibility aliases.
Keep third-party names/notices and historical evidence attribution accurate.
The owner owns **piik.tv**.
Agree on casing, TV mascot/wordmark treatment, repository and runtime identifiers,
release names and the GitHub Pages website before implementing the rename.
Keep one current internal contract and remove replaced names in the coordinated
@@ -29,9 +52,40 @@ The primary goal is lower total complexity, not perfect performance: small gains
do not justify permanent mechanisms or larger maintenance cost. Preserve viewing
quality and product behavior; fewer lines alone do not justify a rewrite.
Use the Client discovery and settings-ownership findings as leads for a
repository-wide lifecycle audit across Web UI, shared Go server, Client and
media adapters. This is future work, not an expansion of the current entry fix.
Look for these recurring patterns:
- Permission, remembered activation, capability, connection readiness and live
operation ownership represented by the same flag or inferred from UI state.
- Cached promises retaining `null`, rejection or a closed resource indefinitely;
failure latches whose lifetime exceeds the evidence that justified them.
- Results after `await`, callbacks and `finally` writing shared state or closing
the current resource without checking the original operation/resource owner.
- Cancellation, failure and absent observation sharing a return value that a
caller interprets as success; review the complete caller chain.
- Connections, slots, listeners and queues acquired before actual demand, kept
after their consumer ends, or retired while another consumer still owns them.
- Draft, requested, applied and server-acknowledged settings overwriting one
another; duplicated writers or status models that can contradict real media.
Search results are leads, not defect evidence. Trace acquisition through use,
commit and retirement, including error paths and shared consumers. Reproduce
representative interleavings: A starts, is cancelled/replaced, B starts, then A
completes or fails. Check both obsolete writes and obsolete cleanup, plus
stop/restart and transient-failure recovery. Fix the owning boundary and remove
redundant state rather than add case-specific flags, timers or a generic manager.
Keep confirmed repairs separate from documented tradeoffs and unsupported
suspicions; retain only focused checks that demonstrate a meaningful failure.
English/Chinese Quick Start, documentation map and license guidance are prepared.
Review final release assets, update/replacement guidance and public distribution
alongside the new brand; existing packagers and Docker recipe are the baseline.
Keep the README short and friendly, with the logo and a small usage illustration;
detailed deployment/development guidance belongs in the linked guides. Draft a
static GitHub Pages homepage with tutorials and self-contained demonstrations
matching the living-room UI. Review final release assets, update/replacement
guidance and public distribution alongside the new brand; existing packagers and
Docker recipe are the baseline.
Keep expensive packaging manual and branch CI quiet. No broad media redesign is
part of naming or documentation work.
@@ -42,6 +96,16 @@ executable paths and cleanup before the next.
## Parked Product Work
The reported persistent low resolution after Viewer backgrounding and square
black video remain unconfirmed incident leads, separate from the closed,
reproduced reconnect ownership bug. They do not block this release. Reopen from
matching upstream/receiver evidence; local H264 relay background checks did not
reproduce them. Missing NAT attempt text alone does not prove skipped attempts.
If Native adaptation is implicated, compare actual VSE limitations with sender
quality evidence before changing policy. Do not add retry budgets, visibility
resets or resolution heuristics without proof. Sanitized aggregates and the
isolated reconnect reproduction remain locally in `build/room7534-investigation/`.
1. **Public distribution and updates.** Candidate packagers, immutable descriptors,
notices, the runtime-only OCI recipe and release checks exist. Choose the
public asset set and release notes before publishing a full-SHA GitHub Release
@@ -78,13 +142,3 @@ executable paths and cleanup before the next.
Cast and AirPlay do not provide a portable live MediaStream receiver.
Reopen for a registered receiver acting as an ordinary Viewer after the
[platform-output gate](./research/platform-output.md) passes.
8. **Public presentation and piik rename.** The owner chose the future product
name piik; final casing and its integration with the TV mascot remain a design
review. Keep the two i letters readable while exploring eyes/antenna/feet.
Prepare short, friendly English/Chinese README and Quick Start content;
detailed implementation and deployment reference belong in developer docs.
Draft a static GitHub Pages homepage with tutorials and self-contained visual
demonstrations matching the living-room UI. The private production service
is not a public demo and must not be advertised as one. Inventory names,
package/module/config identifiers and assets before one coordinated rename;
do not perform it during the Debug supplement or preserve old private aliases.
+2 -6
View File
@@ -66,7 +66,6 @@ type Options struct {
AuthenticationTimeoutMs int
ViewerDisconnectGraceMs int
HeartbeatIntervalMs int
CleanupIntervalMs int
MaxSignalConnections int
MaxUnauthenticatedSignalConnections int
SiteAccessTTLSeconds int
@@ -125,7 +124,7 @@ func New(options Options) (*Server, error) {
logger = slog.Default()
}
store, err := newRoomStore(options, now)
store, err := newRoomStore(options)
if err != nil {
return nil, err
}
@@ -171,7 +170,6 @@ func New(options Options) (*Server, error) {
AuthenticationTimeoutMs: options.AuthenticationTimeoutMs,
ViewerDisconnectGraceMs: options.ViewerDisconnectGraceMs,
HeartbeatIntervalMs: options.HeartbeatIntervalMs,
CleanupIntervalMs: options.CleanupIntervalMs,
MaxConnections: options.MaxSignalConnections,
MaxUnauthenticatedConnections: options.MaxUnauthenticatedSignalConnections,
AfterFunc: options.AfterFunc,
@@ -189,7 +187,7 @@ func New(options Options) (*Server, error) {
// newRoomStore builds the room store createScreenerServer would otherwise take
// from Options.RoomStore. An empty RoomDatabasePath is the memory-only mode.
func newRoomStore(options Options, now func() int64) (*room.Store, error) {
func newRoomStore(options Options) (*room.Store, error) {
if options.RoomStore != nil {
return options.RoomStore, nil
}
@@ -204,11 +202,9 @@ func newRoomStore(options Options, now func() int64) (*room.Store, error) {
database = opened
}
return room.New(room.Options{
LeaseMs: options.Config.RoomLeaseMs,
MaxRooms: room.Capacity,
MaxViewersPerRoom: options.Config.MaxViewersPerRoom,
Database: database,
Now: now,
})
}
+11 -42
View File
@@ -58,7 +58,6 @@ func testConfig(t *testing.T) config.Config {
PublicBaseURL: publicBaseURL,
AllowedOrigins: map[string]struct{}{allowedOrigin: {}},
SiteAccessPassword: testAccessPassword,
RoomLeaseMs: 86_400_000,
MaxViewersPerRoom: 8,
EndpointMediaCopyCapacity: 2,
NATPredictionEnabled: false,
@@ -66,7 +65,7 @@ func testConfig(t *testing.T) config.Config {
}
// newServer builds a server without listening. It mirrors the TS harness: the
// heartbeat and cleanup intervals are pushed out of the way and a fake room
// heartbeat interval is pushed out of the way and a fake room
// control is injected whenever a LiveKit fallback is configured.
func newServer(t *testing.T, options Options) *Server {
t.Helper()
@@ -76,9 +75,6 @@ func newServer(t *testing.T, options Options) *Server {
if options.HeartbeatIntervalMs == 0 {
options.HeartbeatIntervalMs = 60_000
}
if options.CleanupIntervalMs == 0 {
options.CleanupIntervalMs = 60_000
}
server, err := New(options)
if err != nil {
t.Fatalf("New: %v", err)
@@ -734,18 +730,12 @@ func TestRoomCreationIssuesAnIndependentFragmentOnlyViewerGrant(t *testing.T) {
if created.CodeEntryPolicy != "open" {
t.Fatalf("codeEntryPolicy = %q", created.CodeEntryPolicy)
}
if created.ExpiresAt == nil || *created.ExpiresAt == "" {
t.Fatal("expiresAt is missing")
}
if created.RoomLeaseSeconds != 86_400 {
t.Fatalf("roomLeaseSeconds = %d", created.RoomLeaseSeconds)
}
// The response carries no viewer grant expiry and no ICE configuration.
// The response carries only room authority and no ICE configuration.
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(response.body), &raw); err != nil {
t.Fatalf("decode body: %v", err)
}
for _, absent := range []string{"viewerGrantExpiresAt", "iceConfig", "viewerGrant"} {
for _, absent := range []string{"iceConfig", "viewerGrant"} {
if _, present := raw[absent]; present {
t.Fatalf("response exposes %q", absent)
}
@@ -818,16 +808,10 @@ func TestRoomCreationAllocatesUniqueFourDigitCodesConcurrently(t *testing.T) {
if created[0].RoomID == created[1].RoomID {
t.Fatalf("both rooms took the code %q", created[0].RoomID)
}
for _, room := range created {
if room.ExpiresAt == nil {
t.Fatal("expiresAt is null")
}
}
}
func TestRoomCreationReusesAFreePreferredCodeAndNeverReplacesAnOccupiedRoom(t *testing.T) {
configuration := testConfig(t)
configuration.RoomLeaseMs = 90_000
server := start(t, Options{Config: configuration})
cookie := server.cookie()
@@ -839,9 +823,6 @@ func TestRoomCreationReusesAFreePreferredCodeAndNeverReplacesAnOccupiedRoom(t *t
if preferred.RoomID != "4321" {
t.Fatalf("preferred roomId = %q", preferred.RoomID)
}
if preferred.RoomLeaseSeconds != 90 {
t.Fatalf("roomLeaseSeconds = %d", preferred.RoomLeaseSeconds)
}
if fallback.RoomID == "4321" {
t.Fatal("the occupied preferred code was handed out twice")
}
@@ -933,7 +914,6 @@ func TestRoomMutationFailuresMapToTheHTTPTable(t *testing.T) {
{"replacement busy", replacementFailure, &room.Error{Code: room.CodeRoomBusy}, 503, "Room replacement unavailable", true},
{"replacement token", replacementFailure, &room.Error{Code: room.CodeInvalidToken}, 404, "Room not found", true},
{"replacement missing", replacementFailure, &room.Error{Code: room.CodeRoomNotFound}, 404, "Room not found", true},
{"replacement expired", replacementFailure, &room.Error{Code: room.CodeRoomExpired}, 404, "Room not found", true},
{"replacement full", replacementFailure, &room.Error{Code: room.CodeRoomFull}, 0, "", false},
{"replacement other", replacementFailure, otherError, 0, "", false},
{"access busy", accessFailure, &room.Error{Code: room.CodeRoomBusy}, 503, "Room access update unavailable", true},
@@ -965,10 +945,9 @@ func TestRoomMutationFailuresMapToTheHTTPTable(t *testing.T) {
}
}
func TestRoomAccessManagesADormantRoomWithoutStartingSharingOrRenewing(t *testing.T) {
func TestRoomAccessManagesADormantRoomWithoutStartingSharing(t *testing.T) {
var now atomic.Int64
configuration := testConfig(t)
configuration.RoomLeaseMs = 1_000
server := start(t, Options{Config: configuration, Now: now.Load})
cookie := server.cookie()
created := server.createRoom(roomRequest{cookie: cookie}).
@@ -1024,20 +1003,11 @@ func TestRoomAccessManagesADormantRoomWithoutStartingSharingOrRenewing(t *testin
t.Fatal("managing access started a sharing session")
}
now.Store(1_001)
expired, err := server.Store().ExpireRooms(1_001)
if err != nil {
t.Fatalf("ExpireRooms: %v", err)
}
found := false
for _, closed := range expired {
if closed.RoomID == created.RoomID {
found = true
}
}
if !found {
t.Fatalf("access management renewed the lease; expired = %v", expired)
}
now.Store(30 * 24 * 60 * 60 * 1_000)
server.updateRoomAccess(accessRequest{
roomID: created.RoomID, hostToken: created.HostToken, cookie: server.cookie(),
body: `{"action":"set-code-entry-policy","policy":"open"}`,
}).expectStatus(http.StatusOK)
}
func TestRoomAccessRequiresSameOriginSiteAccessAndTheExactHostToken(t *testing.T) {
@@ -1112,7 +1082,7 @@ func TestRoomCreationReturnsServiceUnavailableAtTheGlobalRoomBound(t *testing.T)
configuration := testConfig(t)
configuration.SiteAccessPassword = ""
store, err := room.New(room.Options{
LeaseMs: 86_400_000, MaxRooms: 1, MaxViewersPerRoom: 8,
MaxRooms: 1, MaxViewersPerRoom: 8,
})
if err != nil {
t.Fatalf("room.New: %v", err)
@@ -1173,7 +1143,6 @@ func TestRestoresStableRoomAuthorityAcrossAnApplicationRestart(t *testing.T) {
configuration := testConfig(t)
configuration.SiteAccessPassword = ""
configuration.RoomDatabasePath = databasePath
configuration.RoomLeaseMs = 1_000
first := start(t, Options{Config: configuration, Now: now.Load})
created := first.createRoom(roomRequest{codeEntryPolicy: "private"}).
@@ -1182,7 +1151,7 @@ func TestRestoresStableRoomAuthorityAcrossAnApplicationRestart(t *testing.T) {
t.Fatalf("close first: %v", err)
}
now.Store(200)
now.Store(30 * 24 * 60 * 60 * 1_000)
second := start(t, Options{Config: configuration, Now: now.Load})
if size := second.Store().Size(); size != 1 {
t.Fatalf("restored room count = %d, want 1", size)
+1 -5
View File
@@ -371,10 +371,6 @@ func (s *Server) createRoomResponse(created room.CreatedRoom) protocol.CreateRoo
HostToken: created.HostToken,
InviteURL: signal.InviteURL(s.config.PublicBaseURL, created.RoomID, created.ViewerGrant),
CodeEntryPolicy: created.CodeEntryPolicy,
ExpiresAt: created.ExpiresAt,
// The lease is configured in whole seconds; TS divided by 1000 into a
// JS number and the response schema declares an integer.
RoomLeaseSeconds: protocol.Int(s.config.RoomLeaseMs / 1_000),
}
}
@@ -422,7 +418,7 @@ func replacementFailure(err error) (int, string, bool) {
return http.StatusServiceUnavailable, "Room capacity reached", true
case room.CodeRoomAccessDenied, room.CodeRoomBusy:
return http.StatusServiceUnavailable, "Room replacement unavailable", true
case room.CodeInvalidToken, room.CodeRoomNotFound, room.CodeRoomExpired:
case room.CodeInvalidToken, room.CodeRoomNotFound:
return http.StatusNotFound, "Room not found", true
}
return 0, "", false
+11 -18
View File
@@ -1,7 +1,7 @@
// Package config ports src/server/config.ts, src/server/local-config.ts and
// src/server/ice.ts: the Hosted environment contract, the Client's local
// composition, and the ICE configuration derived from either. It performs no
// I/O; the caller supplies the environment as a map.
// composition, and the ICE configuration derived from either. The caller
// supplies the environment as a map; loading opens no runtime resources.
package config
import (
@@ -33,7 +33,6 @@ const (
minSiteAccessPasswordBytes = 8
maxSiteAccessPasswordBytes = 128
defaultMaxViewersPerRoom = 8
defaultRoomLeaseSeconds = 86_400
defaultPort = 8787
maxPort = 65_535
)
@@ -62,7 +61,8 @@ var removedEnvironmentVariables = []struct{ name, reason string }{
{"PEER_ASSISTED_MEDIA", "peer-assisted media is always enabled"},
{"HOST_ADMISSION_PASSWORD", "use SITE_ACCESS_PASSWORD"},
{"MAX_PEER_RELAY_DOWNSTREAM_EDGES", "use ENDPOINT_MEDIA_COPY_CAPACITY"},
{"ROOM_TTL_SECONDS", "use ROOM_LEASE_SECONDS"},
{"ROOM_TTL_SECONDS", "rooms do not expire"},
{"ROOM_LEASE_SECONDS", "rooms do not expire"},
{"ACCESS_PASSWORD", "use SITE_ACCESS_PASSWORD"},
{"NODE_ENV", "use SCREENER_ENV"},
}
@@ -88,7 +88,6 @@ type Config struct {
AllowedOrigins map[string]struct{}
SiteAccessPassword string
RoomDatabasePath string
RoomLeaseMs int64
MaxViewersPerRoom int
EndpointMediaCopyCapacity int
SFU *SFUConfig
@@ -208,17 +207,10 @@ func Load(env map[string]string) (Config, error) {
return Config{}, errors.New("STUN is required in production")
}
// ALLOWED_ORIGINS and ROOM_LEASE_SECONDS are validated inside the returned
// object literal, i.e. after every check above; keep that order.
allowedOrigins, err := parseOrigins(env["ALLOWED_ORIGINS"], Origin(publicBaseURL))
if err != nil {
return Config{}, err
}
roomLeaseSeconds, err := parsePositiveInteger(env["ROOM_LEASE_SECONDS"],
defaultRoomLeaseSeconds, "ROOM_LEASE_SECONDS")
if err != nil {
return Config{}, err
}
return Config{
Env: environment,
@@ -228,7 +220,6 @@ func Load(env map[string]string) (Config, error) {
AllowedOrigins: allowedOrigins,
SiteAccessPassword: siteAccessPassword,
RoomDatabasePath: roomDatabasePath,
RoomLeaseMs: roomLeaseSeconds * 1_000,
MaxViewersPerRoom: int(maxViewersPerRoom),
EndpointMediaCopyCapacity: int(endpointMediaCopyCapacity),
SFU: sfu,
@@ -288,16 +279,18 @@ func parseBoundedInteger(value string, fallback int64, name string, minimum, max
return parsed, nil
}
// parseRoomDatabasePath ports parseRoomDatabasePath. DECISIONS D8: absoluteness
// is filepath.IsAbs, which differs from Node's path.isAbsolute on Windows,
// where "/var/lib/screener/rooms.sqlite" is relative to the current volume and
// is therefore rejected here. Hosted runs on Linux.
// Hosted defaults to durable room authority in its working directory. Explicit
// paths stay absolute so a deployment cannot relocate an existing database
// when its working directory changes. :memory: selects the in-memory RoomStore.
func parseRoomDatabasePath(value string) (string, error) {
path := strings.TrimFunc(value, protocol.IsJSWhitespace)
if path == "" {
return filepath.Abs("rooms.sqlite")
}
if path == ":memory:" {
return "", nil
}
if path == ":memory:" || strings.ContainsRune(path, 0) || !filepath.IsAbs(path) {
if strings.ContainsRune(path, 0) || !filepath.IsAbs(path) {
return "", errors.New("ROOM_DATABASE_PATH must be an absolute file path")
}
return path, nil
+35 -32
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"maps"
"os"
"path/filepath"
"reflect"
"slices"
@@ -64,7 +65,11 @@ func TestLoadDevelopmentDefaults(t *testing.T) {
if !reflect.DeepEqual(config.AllowedOrigins, want) {
t.Errorf("AllowedOrigins = %v", config.AllowedOrigins)
}
if config.RoomDatabasePath != "" {
defaultDatabasePath, err := filepath.Abs("rooms.sqlite")
if err != nil {
t.Fatal(err)
}
if config.RoomDatabasePath != defaultDatabasePath {
t.Errorf("RoomDatabasePath = %q", config.RoomDatabasePath)
}
if len(config.STUNURLs) != 0 {
@@ -88,14 +93,35 @@ func TestLoadDevelopmentDefaults(t *testing.T) {
if config.SFU != nil {
t.Errorf("SFU = %+v", config.SFU)
}
if config.RoomLeaseMs != 86_400_000 {
t.Errorf("RoomLeaseMs = %d", config.RoomLeaseMs)
}
if config.SiteAccessPassword != "" {
t.Error("SiteAccessPassword is set")
}
}
func TestLoadRoomStorage(t *testing.T) {
directory := t.TempDir()
t.Chdir(directory)
for _, base := range []map[string]string{nil, productionBase} {
for _, override := range []map[string]string{nil,
{"ROOM_DATABASE_PATH": ""}, {"ROOM_DATABASE_PATH": " \t"},
{"ROOM_DATABASE_PATH": ":memory:"},
} {
configuration := mustLoad(t, env(base, override))
want := filepath.Join(directory, "rooms.sqlite")
if override["ROOM_DATABASE_PATH"] == ":memory:" {
want = ""
}
if configuration.RoomDatabasePath != want {
t.Errorf("Load(%v): RoomDatabasePath = %q, want %q", override, configuration.RoomDatabasePath, want)
}
}
}
entries, err := os.ReadDir(directory)
if err != nil || len(entries) != 0 {
t.Fatalf("configuration loading created runtime files: %v, %v", entries, err)
}
}
func TestLoadSFU(t *testing.T) {
config := mustLoad(t, map[string]string{
"LISTEN_HOST": "127.0.0.1",
@@ -231,17 +257,6 @@ func TestLoadAccepts(t *testing.T) {
t.Error("NATPredictionEnabled = false")
}
}},
{"room lease default", map[string]string{}, func(t *testing.T, c Config) {
if c.RoomLeaseMs != 86_400_000 {
t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs)
}
}},
{"room lease override", map[string]string{"ROOM_LEASE_SECONDS": "3600"},
func(t *testing.T, c Config) {
if c.RoomLeaseMs != 3_600_000 {
t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs)
}
}},
{"file-backed room authority in development",
map[string]string{"ROOM_DATABASE_PATH": " " + developmentPath + " "},
func(t *testing.T, c Config) {
@@ -249,12 +264,6 @@ func TestLoadAccepts(t *testing.T) {
t.Errorf("RoomDatabasePath = %q", c.RoomDatabasePath)
}
}},
{"blank room database path", map[string]string{"ROOM_DATABASE_PATH": ""},
func(t *testing.T, c Config) {
if c.RoomDatabasePath != "" {
t.Errorf("RoomDatabasePath = %q", c.RoomDatabasePath)
}
}},
{"file-backed room authority in production",
env(productionBase, map[string]string{"ROOM_DATABASE_PATH": productionPath}),
func(t *testing.T, c Config) {
@@ -282,12 +291,6 @@ func TestLoadAccepts(t *testing.T) {
t.Errorf("Port = %d", c.Port)
}
}},
{"exponent room lease", map[string]string{"ROOM_LEASE_SECONDS": "1e3"},
func(t *testing.T, c Config) {
if c.RoomLeaseMs != 1_000_000 {
t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs)
}
}},
}
for _, testCase := range cases {
@@ -340,8 +343,6 @@ func TestLoadRejects(t *testing.T) {
"ENDPOINT_MEDIA_COPY_CAPACITY must be between 1 and 3"},
{"fractional copy capacity", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "1.5"},
"ENDPOINT_MEDIA_COPY_CAPACITY must be a positive integer"},
{"zero room lease", map[string]string{"ROOM_LEASE_SECONDS": "0"},
"ROOM_LEASE_SECONDS must be a positive integer"},
{"port zero", map[string]string{"PORT": "0"}, "PORT must be a positive integer"},
{"port not a number", map[string]string{"PORT": "eight"}, "PORT must be a positive integer"},
{"port above the TCP ceiling", map[string]string{"PORT": "65536"}, "PORT must be at most 65535"},
@@ -434,8 +435,6 @@ func TestLoadRejects(t *testing.T) {
"NAT_PREDICTION_ENABLED must be true or false"},
// Room database path.
{"memory room database path", map[string]string{"ROOM_DATABASE_PATH": ":memory:"},
"ROOM_DATABASE_PATH must be an absolute file path"},
{"room database path with a NUL", map[string]string{"ROOM_DATABASE_PATH": "rooms\x00.sqlite"},
"ROOM_DATABASE_PATH must be an absolute file path"},
{"bare room database file name", map[string]string{"ROOM_DATABASE_PATH": "rooms.sqlite"},
@@ -461,7 +460,11 @@ func TestLoadRejects(t *testing.T) {
{"removed peer-assisted toggle", map[string]string{"PEER_ASSISTED_MEDIA": "true"},
"PEER_ASSISTED_MEDIA is no longer supported; peer-assisted media is always enabled"},
{"removed room TTL", map[string]string{"ROOM_TTL_SECONDS": ""},
"ROOM_TTL_SECONDS is no longer supported; use ROOM_LEASE_SECONDS"},
"ROOM_TTL_SECONDS is no longer supported; rooms do not expire"},
{"removed room lease", map[string]string{"ROOM_LEASE_SECONDS": "86400"},
"ROOM_LEASE_SECONDS is no longer supported; rooms do not expire"},
{"blank removed room lease", map[string]string{"ROOM_LEASE_SECONDS": ""},
"ROOM_LEASE_SECONDS is no longer supported; rooms do not expire"},
{"removed access password", map[string]string{"ACCESS_PASSWORD": "legacy-password"},
"ACCESS_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD"},
{"blank access password", map[string]string{"ACCESS_PASSWORD": ""},
-4
View File
@@ -12,9 +12,6 @@ import (
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
)
// Ported from src/server/local-config.ts.
const localRoomLeaseMs = 24 * 60 * 60 * 1_000
// localPasswordPattern is VISIBLE_ASCII_PATTERN from local-config.ts. Because
// it admits one byte per character the TypeScript's extra Buffer.byteLength
// bounds are already implied by {8,128}.
@@ -109,7 +106,6 @@ func Local(options LocalOptions) (Config, error) {
PublicBaseURL: publicBaseURL,
AllowedOrigins: allowedOrigins,
SiteAccessPassword: siteAccessPassword,
RoomLeaseMs: localRoomLeaseMs,
MaxViewersPerRoom: protocol.MaxViewersPerRoomLimit,
EndpointMediaCopyCapacity: protocol.DefaultEndpointMediaCopyCapacity,
STUNURLs: stunURLs,
-3
View File
@@ -40,9 +40,6 @@ func TestLocalMapsClientInputs(t *testing.T) {
if config.SiteAccessPassword != "local-access-password" {
t.Errorf("SiteAccessPassword = %q", config.SiteAccessPassword)
}
if config.RoomLeaseMs != 86_400_000 {
t.Errorf("RoomLeaseMs = %d", config.RoomLeaseMs)
}
if config.MaxViewersPerRoom != 20 {
t.Errorf("MaxViewersPerRoom = %d", config.MaxViewersPerRoom)
}
+1 -1
View File
@@ -13,7 +13,7 @@ const (
MaxViewersPerRoomLimit = 20
MaxParticipantsPerRoomLimit = MaxViewersPerRoomLimit + 1
MaxSignalBytes = 64 * 1024
SignalingProtocol = "screener-v22"
SignalingProtocol = "screener-v23"
RoomCodeLength = 4
MaxMediaRouteRevision = MaxSafeInteger
MaxIceServerURLs = 8
+4 -8
View File
@@ -75,9 +75,8 @@ const (
func authenticatedHost() []member {
return []member{
{"type", `"authenticated"`},
{"protocol", `"screener-v22"`},
{"protocol", `"screener-v23"`},
{"peerId", `"host_12345678"`},
{"roomExpiresAt", `"2026-08-18T18:00:00.000Z"`},
{"maxViewers", `8`},
{"endpointMediaCopyCapacity", `2`},
{"hostOnline", `true`},
@@ -164,10 +163,7 @@ func TestAuthenticatedPasswordStateIsHostScoped(t *testing.T) {
object(with(viewer, member{"hostPaused", `true`})), true)
}
// "represents persistent rooms without a room expiry" and the revision bound.
func TestAuthenticatedRoomLifetimeAndRevision(t *testing.T) {
assertServerMessage(t, "no expiry",
object(with(authenticatedHost(), member{"roomExpiresAt", `null`})), true)
func TestAuthenticatedRoomRevision(t *testing.T) {
assertServerMessage(t, "revision at the ceiling",
object(with(authenticatedHost(), member{"routeRevision", `9007199254740991`})), true)
assertServerMessage(t, "revision past the ceiling",
@@ -584,7 +580,7 @@ func TestAuthenticatedKeyOrder(t *testing.T) {
}
}
want := []string{
"type", "protocol", "peerId", "roomExpiresAt", "maxViewers",
"type", "protocol", "peerId", "maxViewers",
"endpointMediaCopyCapacity", "hostOnline", "hostPaused", "connectionId",
"iceConfig", "routePolicy", "codeEntryPolicy", "viewerAuthorizationGeneration",
"mediaMode", "shareGeneration", "routeRevision", "routeAssignment",
@@ -622,7 +618,7 @@ func TestOptionalAndNullableAreExact(t *testing.T) {
// A host authenticate without routePolicy takes DEFAULT_ROUTE_POLICY.
func TestAuthenticateAppliesTheRoutePolicyDefault(t *testing.T) {
message, err := DecodeClientMessage([]byte(
`{"type":"authenticate","protocol":"screener-v22","roomId":"1234","role":"host",` +
`{"type":"authenticate","protocol":"screener-v23","roomId":"1234","role":"host",` +
`"token":"` + repeat("a", 43) + `","clientId":"client_12345678"}`))
if err != nil {
t.Fatal(err)
+4 -6
View File
@@ -158,12 +158,10 @@ func DecodeRoomAccessUpdateRequest(data []byte) (RoomAccessUpdateRequest, error)
// CreateRoomResponse mirrors createRoomResponseSchema.
type CreateRoomResponse struct {
RoomID string `json:"roomId"`
HostToken string `json:"hostToken"`
InviteURL string `json:"inviteUrl"`
CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"`
ExpiresAt *string `json:"expiresAt"`
RoomLeaseSeconds Int `json:"roomLeaseSeconds"`
RoomID string `json:"roomId"`
HostToken string `json:"hostToken"`
InviteURL string `json:"inviteUrl"`
CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"`
}
// RoomAccessUpdateResponse is one member of roomAccessUpdateResponseSchema.
-10
View File
@@ -77,16 +77,6 @@ func ValidViewerPassword(value string) bool {
viewerPasswordPattern.MatchString(value)
}
// isoDateTimePattern mirrors z.string().datetime(): an RFC 3339 UTC instant
// with a "Z" designator and no offset.
var isoDateTimePattern = regexp.MustCompile(
`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$`)
// ValidISODateTime reports whether value is a zod `.datetime()` string.
func ValidISODateTime(value string) bool {
return isoDateTimePattern.MatchString(value)
}
// forbiddenDisplayNameCharacters is FORBIDDEN_DISPLAY_NAME_CHARACTERS from
// src/shared/protocol.ts.
var forbiddenDisplayNameCharacters = regexp.MustCompile(
+5 -10
View File
@@ -19,7 +19,6 @@ type AuthenticatedHostMessage struct {
Type string `json:"type"`
Protocol string `json:"protocol"`
PeerID string `json:"peerId"`
RoomExpiresAt *string `json:"roomExpiresAt"`
MaxViewers Int `json:"maxViewers"`
EndpointMediaCopyCapacity Int `json:"endpointMediaCopyCapacity"`
HostOnline bool `json:"hostOnline"`
@@ -44,7 +43,6 @@ type AuthenticatedViewerMessage struct {
Type string `json:"type"`
Protocol string `json:"protocol"`
PeerID string `json:"peerId"`
RoomExpiresAt *string `json:"roomExpiresAt"`
MaxViewers Int `json:"maxViewers"`
EndpointMediaCopyCapacity Int `json:"endpointMediaCopyCapacity"`
HostOnline bool `json:"hostOnline"`
@@ -294,7 +292,7 @@ func decodeEmptyServerMessage(data []byte, message ServerMessage) (ServerMessage
func validateAuthenticatedBase(
present fields,
protocol string, peerID string, roomExpiresAt *string,
protocol string, peerID string,
maxViewers, endpointCapacity Int, connectionID *string,
codeEntryPolicy, viewerAuthorizationGeneration, mediaMode string,
shareGeneration *string, routeRevision Int,
@@ -306,7 +304,7 @@ func validateAuthenticatedBase(
return err
}
if err := present.requireNullable(
"roomExpiresAt", "connectionId", "shareGeneration"); err != nil {
"connectionId", "shareGeneration"); err != nil {
return err
}
if err := present.optional("hostPaused", "routePolicy"); err != nil {
@@ -318,9 +316,6 @@ func validateAuthenticatedBase(
if !ValidOpaqueID(peerID) {
return errors.New("peerId is not an opaque id")
}
if roomExpiresAt != nil && !ValidISODateTime(*roomExpiresAt) {
return errors.New("roomExpiresAt is not an ISO instant")
}
if !inRangeInt(maxViewers, 1, MaxViewersPerRoomLimit) {
return errors.New("maxViewers is out of range")
}
@@ -364,7 +359,7 @@ func decodeAuthenticated(data []byte) (ServerMessage, error) {
return nil, err
}
if err := validateAuthenticatedBase(present, message.Protocol, message.PeerID,
message.RoomExpiresAt, message.MaxViewers, message.EndpointMediaCopyCapacity,
message.MaxViewers, message.EndpointMediaCopyCapacity,
message.ConnectionID, message.CodeEntryPolicy,
message.ViewerAuthorizationGeneration, message.MediaMode,
message.ShareGeneration, message.RouteRevision); err != nil {
@@ -381,7 +376,7 @@ func decodeAuthenticated(data []byte) (ServerMessage, error) {
return nil, err
}
if err := validateAuthenticatedBase(present, message.Protocol, message.PeerID,
message.RoomExpiresAt, message.MaxViewers, message.EndpointMediaCopyCapacity,
message.MaxViewers, message.EndpointMediaCopyCapacity,
message.ConnectionID, message.CodeEntryPolicy,
message.ViewerAuthorizationGeneration, message.MediaMode,
message.ShareGeneration, message.RouteRevision); err != nil {
@@ -653,7 +648,7 @@ func decodeRoomClosed(data []byte) (ServerMessage, error) {
if err := present.require("type", "reason"); err != nil {
return nil, err
}
if !enumOf(message.Reason, "host-ended", "expired") {
if message.Reason != "host-ended" {
return nil, fmt.Errorf("unknown room-closed reason %q", message.Reason)
}
return message, nil
+1 -1
View File
@@ -45,7 +45,7 @@ var (
routeDiagnosticStages = []string{"admission", "first-frame", "quality-proof"}
senderQualityStates = []string{"unknown", "healthy", "degraded"}
senderQualityDegradedReasons = []string{"none", "bandwidth", "cpu"}
errorCodes = []string{"AUTH_REQUIRED", "INVALID_MESSAGE", "INVALID_TOKEN", "ROOM_ACCESS_DENIED", "ROOM_NOT_FOUND", "ROOM_EXPIRED", "ROOM_FULL", "HOST_ALREADY_CONNECTED", "PEER_NOT_FOUND", "FORBIDDEN", "SERVER_ERROR"}
errorCodes = []string{"AUTH_REQUIRED", "INVALID_MESSAGE", "INVALID_TOKEN", "ROOM_ACCESS_DENIED", "ROOM_NOT_FOUND", "ROOM_FULL", "HOST_ALREADY_CONNECTED", "PEER_NOT_FOUND", "FORBIDDEN", "SERVER_ERROR"}
)
func validRevision(revision Int) bool {
-155
View File
@@ -1,155 +0,0 @@
package room
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
)
// testdata/node-rooms.db was written once by node:sqlite (DatabaseSync) with
// the schema of src/server/room-database.ts and two synthetic rooms: 4321 is
// active (NULL lease) with a 48-byte password material, 5678 is dormant with a
// lease of 1000 ms. No real token, grant or password is involved. The extension
// is .db because .gitignore excludes *.sqlite repository-wide.
func copyNodeFixture(t *testing.T) string {
t.Helper()
fixture, err := os.ReadFile(filepath.Join("testdata", "node-rooms.db"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
path := filepath.Join(t.TempDir(), "rooms.sqlite")
if err := os.WriteFile(path, fixture, 0o600); err != nil {
t.Fatalf("write fixture copy: %v", err)
}
return path
}
func openFixture(t *testing.T, startupNowMs, activeLeaseExpiresAtMs int64) []StoredRoomAuthority {
t.Helper()
database, err := NewDatabase(copyNodeFixture(t))
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
restored, err := database.Initialize(startupNowMs, activeLeaseExpiresAtMs)
if err != nil {
t.Fatalf("Initialize: %v", err)
}
return restored
}
func TestRecoversANodeWrittenDatabase(t *testing.T) {
restored := openFixture(t, 5_000, 6_000)
if len(restored) != 1 {
t.Fatalf("restored %d rooms, want 1 (the dormant room was due at 1000)", len(restored))
}
room := restored[0]
if room.RoomID != "4321" || room.CodeEntryPolicy != protocol.CodeEntryPrivate {
t.Fatalf("restored %+v", room)
}
if room.LeaseExpiresAtMs == nil || *room.LeaseExpiresAtMs != 6_000 {
t.Fatalf("active room lease = %v, want the startup lease 6000", room.LeaseExpiresAtMs)
}
if !bytes.Equal(room.HostTokenDigest, bytes.Repeat([]byte{0x11}, 32)) ||
!bytes.Equal(room.ViewerGrantDigest, bytes.Repeat([]byte{0x22}, 32)) {
t.Fatal("digests did not survive the Node round trip")
}
if room.ViewerAuthorizationGeneration != "MzMzMzMzMzMzMzMzMzMzMw" {
t.Fatalf("generation = %q", room.ViewerAuthorizationGeneration)
}
if !bytes.Equal(room.ViewerPasswordMaterial, bytes.Repeat([]byte{0x44}, 48)) {
t.Fatalf("password material = %d bytes", len(room.ViewerPasswordMaterial))
}
}
func TestKeepsADormantNodeWrittenDeadline(t *testing.T) {
restored := openFixture(t, 500, 1_500)
if len(restored) != 2 {
t.Fatalf("restored %d rooms, want 2", len(restored))
}
// readStoredRooms orders by room_id.
if restored[0].RoomID != "4321" || restored[0].LeaseExpiresAtMs == nil ||
*restored[0].LeaseExpiresAtMs != 1_500 {
t.Fatalf("active room = %+v", restored[0])
}
if restored[1].RoomID != "5678" || restored[1].LeaseExpiresAtMs == nil ||
*restored[1].LeaseExpiresAtMs != 1_000 {
t.Fatalf("dormant room = %+v", restored[1])
}
if restored[1].ViewerPasswordMaterial != nil {
t.Fatalf("dormant room material = %v, want nil", restored[1].ViewerPasswordMaterial)
}
}
func TestStoreRestoresANodeWrittenDatabase(t *testing.T) {
testClock := &clock{nowMs: 5_000}
store := stableStore(t, copyNodeFixture(t), testClock, Options{LeaseMs: 1_000})
if store.Size() != 1 {
t.Fatalf("size = %d, want 1", store.Size())
}
// The recovered room holds the startup lease: 5000 + 1000.
testClock.nowMs = 6_001
expired, err := store.ExpireRooms(testClock.nowMs)
if err != nil {
t.Fatalf("ExpireRooms: %v", err)
}
if len(expired) != 1 || expired[0].RoomID != "4321" {
t.Fatalf("expired = %+v", expired)
}
}
// TestSchemaTextMatchesTheNodeFixture proves the CREATE TABLE text this package
// executes is byte for byte the one src/server/room-database.ts executed.
func TestSchemaTextMatchesTheNodeFixture(t *testing.T) {
path := databasePath(t)
database, err := NewDatabase(path)
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
if _, err := database.Initialize(0, 1_000); err != nil {
t.Fatalf("Initialize: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
created := readSchemaText(t, path)
fixture := readSchemaText(t, copyNodeFixture(t))
if created.sql != fixture.sql {
t.Fatalf("schema text differs\n go: %q\n node: %q", created.sql, fixture.sql)
}
if created.applicationID != fixture.applicationID ||
created.applicationID != roomDatabaseApplicationID {
t.Fatalf("application_id go=%d node=%d", created.applicationID, fixture.applicationID)
}
if created.userVersion != fixture.userVersion ||
created.userVersion != roomDatabaseSchemaVersion {
t.Fatalf("user_version go=%d node=%d", created.userVersion, fixture.userVersion)
}
}
type schemaText struct {
sql string
applicationID int64
userVersion int64
}
func readSchemaText(t *testing.T, path string) schemaText {
t.Helper()
raw := rawDatabase(t, path)
var text schemaText
if err := raw.QueryRow(
"SELECT sql FROM sqlite_schema WHERE name = 'rooms'").Scan(&text.sql); err != nil {
t.Fatalf("read schema: %v", err)
}
if err := raw.QueryRow("PRAGMA application_id").Scan(&text.applicationID); err != nil {
t.Fatalf("read application_id: %v", err)
}
if err := raw.QueryRow("PRAGMA user_version").Scan(&text.userVersion); err != nil {
t.Fatalf("read user_version: %v", err)
}
return text
}
+23 -170
View File
@@ -15,18 +15,14 @@ import (
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
)
// roomDatabaseApplicationID and roomDatabaseSchemaVersion are the
// APPLICATION_ID / SCHEMA_VERSION of src/server/room-database.ts, checked on
// open so another SQLite file cannot be adopted as the room authority. The ID
// is ASCII "SCRN"; roomSchema below repeats it in decimal because that literal
// has to match the TypeScript statement text byte for byte.
// Database identity and the single current schema are checked before adopting
// room authority. The application ID is ASCII "SCRN".
const (
roomDatabaseApplicationID = 0x5343524e
roomDatabaseSchemaVersion = 1
roomDatabaseSchemaVersion = 2
)
// roomSchema is the exact text src/server/room-database.ts executes, so a file
// created here and one created by the TypeScript hold the same sqlite_schema.
// roomSchema stores durable authority only, never presence or media state.
const roomSchema = `
CREATE TABLE rooms (
room_id TEXT PRIMARY KEY NOT NULL
@@ -46,15 +42,10 @@ const roomSchema = `
CHECK (
viewer_password_material IS NULL OR
(typeof(viewer_password_material) = 'blob' AND length(viewer_password_material) = 48)
),
lease_expires_at_ms INTEGER NULL
CHECK (
lease_expires_at_ms IS NULL OR
(lease_expires_at_ms > 0 AND lease_expires_at_ms <= 9007199254740991)
)
) STRICT;
PRAGMA application_id = 1396920910;
PRAGMA user_version = 1;
PRAGMA user_version = 2;
`
const selectStoredRooms = `SELECT
@@ -63,8 +54,7 @@ const selectStoredRooms = `SELECT
viewer_grant_digest,
viewer_authorization_generation,
code_entry_policy,
viewer_password_material,
lease_expires_at_ms
viewer_password_material
FROM rooms
ORDER BY room_id`
@@ -74,14 +64,12 @@ const insertStoredRoom = `INSERT INTO rooms (
viewer_grant_digest,
viewer_authorization_generation,
code_entry_policy,
viewer_password_material,
lease_expires_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)`
viewer_password_material
) VALUES (?, ?, ?, ?, ?, ?)`
const deleteRoomStatement = "DELETE FROM rooms WHERE room_id = ? AND host_token_digest = ?"
// StoredRoomAuthority is StoredRoomAuthority. A nil ViewerPasswordMaterial and
// a nil LeaseExpiresAtMs are the TypeScript nulls.
// StoredRoomAuthority excludes transient sessions and presence.
type StoredRoomAuthority struct {
RoomID string
HostTokenDigest []byte
@@ -89,13 +77,6 @@ type StoredRoomAuthority struct {
ViewerAuthorizationGeneration string
CodeEntryPolicy protocol.CodeEntryPolicy
ViewerPasswordMaterial []byte
LeaseExpiresAtMs *int64
}
// RoomIdentity is the {roomId, hostTokenDigest} pair deleteRooms takes.
type RoomIdentity struct {
RoomID string
HostTokenDigest []byte
}
// Database is RoomDatabase: the SQLite stable authority. It holds one
@@ -119,25 +100,11 @@ func NewDatabase(path string) (*Database, error) {
}
// Initialize opens the file, takes the exclusive lock, creates or verifies the
// schema and recovers the stored rooms: rooms whose lease has passed are
// deleted and rooms that were active at shutdown take activeLeaseExpiresAtMs.
func (d *Database) Initialize(
startupNowMs, activeLeaseExpiresAtMs int64,
) (restored []StoredRoomAuthority, err error) {
// schema and restores all stored room authority.
func (d *Database) Initialize() (restored []StoredRoomAuthority, err error) {
if d.conn != nil {
return nil, errors.New("Room database is already initialized")
}
if err := assertTimestamp(startupNowMs, "Room database startup time", true); err != nil {
return nil, err
}
if err := assertTimestamp(
activeLeaseExpiresAtMs, "Room database active-room lease deadline", false); err != nil {
return nil, err
}
if activeLeaseExpiresAtMs <= startupNowMs {
return nil, errors.New(
"Room database active-room lease deadline must be in the future")
}
// `new DatabaseSync(this.path, { timeout: 0 })`: a lock conflict must fail
// immediately instead of waiting.
@@ -174,7 +141,7 @@ func (d *Database) Initialize(
if err = d.begin("BEGIN EXCLUSIVE"); err != nil {
return nil, err
}
restored, err = d.recover(startupNowMs, activeLeaseExpiresAtMs)
restored, err = d.recover()
if err != nil {
d.rollbackIfNeeded()
return nil, err
@@ -193,63 +160,14 @@ func uriPath(path string) string {
return strings.NewReplacer("%", "%25", "#", "%23", "?", "%3F").Replace(filepath.ToSlash(path))
}
func (d *Database) recover(
startupNowMs, activeLeaseExpiresAtMs int64,
) ([]StoredRoomAuthority, error) {
func (d *Database) recover() ([]StoredRoomAuthority, error) {
if err := d.ensureExactSchema(); err != nil {
return nil, err
}
if err := d.assertDatabaseIntegrity(); err != nil {
return nil, err
}
stored, err := d.readStoredRooms()
if err != nil {
return nil, err
}
expiredCount, activeCount := 0, 0
for _, room := range stored {
switch {
case room.LeaseExpiresAtMs == nil:
activeCount++
case *room.LeaseExpiresAtMs <= startupNowMs:
expiredCount++
}
}
deleted, err := d.exec(
`DELETE FROM rooms
WHERE lease_expires_at_ms IS NOT NULL
AND lease_expires_at_ms <= ?`, startupNowMs)
if err != nil {
return nil, err
}
if deleted != int64(expiredCount) {
return nil, errors.New("Room database expiry cleanup changed an unexpected row count")
}
converted, err := d.exec(
`UPDATE rooms
SET lease_expires_at_ms = ?
WHERE lease_expires_at_ms IS NULL`, activeLeaseExpiresAtMs)
if err != nil {
return nil, err
}
if converted != int64(activeCount) {
return nil, errors.New("Room database active-room recovery changed an unexpected row count")
}
restored := make([]StoredRoomAuthority, 0, len(stored))
for _, room := range stored {
if room.LeaseExpiresAtMs != nil && *room.LeaseExpiresAtMs <= startupNowMs {
continue
}
if room.LeaseExpiresAtMs == nil {
lease := activeLeaseExpiresAtMs
room.LeaseExpiresAtMs = &lease
}
restored = append(restored, room)
}
return restored, nil
return d.readStoredRooms()
}
// InsertRoom is insertRoom.
@@ -264,8 +182,7 @@ func (d *Database) InsertRoom(room StoredRoomAuthority) error {
room.ViewerGrantDigest,
room.ViewerAuthorizationGeneration,
room.CodeEntryPolicy,
room.ViewerPasswordMaterial,
room.LeaseExpiresAtMs)
room.ViewerPasswordMaterial)
if err != nil {
return err
}
@@ -273,31 +190,6 @@ func (d *Database) InsertRoom(room StoredRoomAuthority) error {
})
}
// SetLeaseDeadline is setLeaseDeadline; a nil deadline marks the room active.
func (d *Database) SetLeaseDeadline(
roomID string, hostTokenDigest []byte, leaseExpiresAtMs *int64,
) error {
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
return err
}
if leaseExpiresAtMs != nil {
if err := assertTimestamp(*leaseExpiresAtMs, "Room lease deadline", false); err != nil {
return err
}
}
return d.transaction(func() error {
changes, err := d.exec(
`UPDATE rooms
SET lease_expires_at_ms = ?
WHERE room_id = ? AND host_token_digest = ?`,
leaseExpiresAtMs, roomID, hostTokenDigest)
if err != nil {
return err
}
return assertSingleChange(changes, "lease update")
})
}
// SetViewerPassword is setViewerPassword; nil material clears the password.
func (d *Database) SetViewerPassword(
roomID string, hostTokenDigest, viewerPasswordMaterial []byte,
@@ -374,31 +266,15 @@ func (d *Database) SetViewerGrant(
// DeleteRoom is deleteRoom.
func (d *Database) DeleteRoom(roomID string, hostTokenDigest []byte) error {
return d.DeleteRooms([]RoomIdentity{{RoomID: roomID, HostTokenDigest: hostTokenDigest}})
}
// DeleteRooms is deleteRooms: one transaction, and any room whose authority no
// longer matches rolls the whole batch back.
func (d *Database) DeleteRooms(rooms []RoomIdentity) error {
for _, room := range rooms {
if err := assertRoomIdentity(room.RoomID, room.HostTokenDigest); err != nil {
return err
}
}
if len(rooms) == 0 {
return nil
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
return err
}
return d.transaction(func() error {
for _, room := range rooms {
changes, err := d.exec(deleteRoomStatement, room.RoomID, room.HostTokenDigest)
if err != nil {
return err
}
if err := assertSingleChange(changes, "delete"); err != nil {
return err
}
changes, err := d.exec(deleteRoomStatement, roomID, hostTokenDigest)
if err != nil {
return err
}
return nil
return assertSingleChange(changes, "delete")
})
}
@@ -423,8 +299,7 @@ func (d *Database) ReplaceRoom(
replacement.ViewerGrantDigest,
replacement.ViewerAuthorizationGeneration,
replacement.CodeEntryPolicy,
replacement.ViewerPasswordMaterial,
replacement.LeaseExpiresAtMs)
replacement.ViewerPasswordMaterial)
if err != nil {
return err
}
@@ -548,7 +423,6 @@ func (d *Database) assertExactColumns() error {
{"viewer_authorization_generation", "TEXT", 1, 0},
{"code_entry_policy", "TEXT", 1, 0},
{"viewer_password_material", "BLOB", 0, 0},
{"lease_expires_at_ms", "INTEGER", 0, 0},
}
rows, err := d.conn.QueryContext(context.Background(), "PRAGMA table_info(rooms)")
if err != nil {
@@ -597,7 +471,6 @@ func (d *Database) readStoredRooms() ([]StoredRoomAuthority, error) {
var (
room StoredRoomAuthority
policy string
lease sql.NullInt64
material []byte
)
if err := rows.Scan(
@@ -606,8 +479,7 @@ func (d *Database) readStoredRooms() ([]StoredRoomAuthority, error) {
&room.ViewerGrantDigest,
&room.ViewerAuthorizationGeneration,
&policy,
&material,
&lease); err != nil {
&material); err != nil {
return nil, err
}
if err := readDigest(room.HostTokenDigest, "Host token"); err != nil {
@@ -626,12 +498,6 @@ func (d *Database) readStoredRooms() ([]StoredRoomAuthority, error) {
}
room.ViewerPasswordMaterial = material
}
if lease.Valid {
if lease.Int64 <= 0 || lease.Int64 > protocol.MaxSafeInteger {
return nil, errors.New("Room database contains an invalid lease deadline")
}
room.LeaseExpiresAtMs = &lease.Int64
}
if err := assertStoredRoom(room); err != nil {
return nil, err
}
@@ -721,9 +587,6 @@ func assertStoredRoom(room StoredRoomAuthority) error {
if err := assertPasswordMaterial(room.ViewerPasswordMaterial); err != nil {
return err
}
if room.LeaseExpiresAtMs != nil {
return assertTimestamp(*room.LeaseExpiresAtMs, "Room database lease deadline", false)
}
return nil
}
@@ -764,16 +627,6 @@ func assertAuthorizationGeneration(value string) error {
return nil
}
// assertTimestamp is the TypeScript Number.isSafeInteger guard: SQLite STRICT
// accepts an integral REAL and a numeric TEXT in an INTEGER column, so the
// range check has to stay on this side.
func assertTimestamp(value int64, name string, allowZero bool) error {
if value > protocol.MaxSafeInteger || (allowZero && value < 0) || (!allowZero && value <= 0) {
return fmt.Errorf("%s must be a safe positive integer", name)
}
return nil
}
func assertSingleChange(changes int64, operation string) error {
if changes != 1 {
return fmt.Errorf("Room database %s did not match current authority", operation)
+81 -172
View File
@@ -6,6 +6,7 @@ import (
"database/sql"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -21,21 +22,17 @@ func databasePath(t *testing.T) string {
return filepath.Join(t.TempDir(), "rooms.sqlite")
}
func stableStore(t *testing.T, path string, testClock *clock, options Options) *Store {
func stableStore(t *testing.T, path string, options Options) *Store {
t.Helper()
database, err := NewDatabase(path)
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
if options.LeaseMs == 0 {
options.LeaseMs = 1_000
}
if options.MaxRooms == 0 {
options.MaxRooms = 8
}
options.MaxViewersPerRoom = 8
options.Database = database
options.Now = func() int64 { return testClock.nowMs }
store, err := New(options)
if err != nil {
t.Fatalf("New: %v", err)
@@ -72,8 +69,7 @@ func dbViewerInput(roomID, sessionID, viewerGrant string) ConnectParticipantInpu
func TestDoesNotPersistARoomWhenPasswordDerivationIsBusy(t *testing.T) {
path := databasePath(t)
testClock := &clock{nowMs: 1_000}
first := stableStore(t, path, testClock, Options{MaxRooms: 1})
first := stableStore(t, path, Options{MaxRooms: 1})
withSaturatedGate(t, first, func() {
_, err := tryCreateRoom(first, protocol.CodeEntryPrivate, "room-password", "4321")
@@ -86,7 +82,7 @@ func TestDoesNotPersistARoomWhenPasswordDerivationIsBusy(t *testing.T) {
t.Fatalf("Close: %v", err)
}
second := stableStore(t, path, testClock, Options{MaxRooms: 1})
second := stableStore(t, path, Options{MaxRooms: 1})
if second.Size() != 0 {
t.Fatalf("restored size = %d, want 0", second.Size())
}
@@ -97,8 +93,7 @@ func TestDoesNotPersistARoomWhenPasswordDerivationIsBusy(t *testing.T) {
func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
path := databasePath(t)
testClock := &clock{nowMs: 1_000}
first := stableStore(t, path, testClock, Options{})
first := stableStore(t, path, Options{})
room := createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
if _, err := first.SetCodeEntryPolicy(
room.RoomID, protocol.CodeEntryPrivate, room.HostToken); err != nil {
@@ -124,8 +119,7 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
t.Fatalf("Close: %v", err)
}
testClock.nowMs = 2_000
second := stableStore(t, path, testClock, Options{})
second := stableStore(t, path, Options{})
if second.Size() != 1 {
t.Fatalf("restored size = %d, want 1", second.Size())
}
@@ -139,9 +133,6 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
expectCode(t, err, CodeInvalidToken)
granted := mustConnect(t, second, dbViewerInput(room.RoomID, "current-grant", rotated.ViewerGrant))
if granted.ExpiresAt == nil || *granted.ExpiresAt != "1970-01-01T00:00:03.000Z" {
t.Fatalf("expiresAt = %v", granted.ExpiresAt)
}
if granted.ViewerAuthorizationGeneration != rotated.ViewerAuthorizationGeneration {
t.Fatalf("generation = %q, want %q",
granted.ViewerAuthorizationGeneration, rotated.ViewerAuthorizationGeneration)
@@ -157,10 +148,7 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
}
_, err = second.ConnectParticipant(hostInput(room.RoomID, "wrong-token"))
expectCode(t, err, CodeInvalidToken)
resumed := mustConnect(t, second, hostInput(room.RoomID, room.HostToken, "recovered-host"))
if resumed.ExpiresAt != nil {
t.Fatalf("resumed expiresAt = %v, want null", *resumed.ExpiresAt)
}
mustConnect(t, second, hostInput(room.RoomID, room.HostToken, "recovered-host"))
if err := second.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
@@ -178,7 +166,7 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
want := []string{
"room_id", "host_token_digest", "viewer_grant_digest",
"viewer_authorization_generation", "code_entry_policy",
"viewer_password_material", "lease_expires_at_ms",
"viewer_password_material",
}
if strings.Join(columns, ",") != strings.Join(want, ",") {
t.Fatalf("columns = %v", columns)
@@ -187,16 +175,15 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
t.Fatal("no stored room")
}
var (
roomID string
hostDigest []byte
grantDigest []byte
generation string
policy string
material []byte
leaseExpiresAtMs sql.NullInt64
roomID string
hostDigest []byte
grantDigest []byte
generation string
policy string
material []byte
)
if err := rows.Scan(&roomID, &hostDigest, &grantDigest, &generation, &policy,
&material, &leaseExpiresAtMs); err != nil {
&material); err != nil {
t.Fatalf("Scan: %v", err)
}
hostTokenDigest := sha256.Sum256([]byte(room.HostToken))
@@ -219,58 +206,11 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
}
}
func TestConvertsAnActiveMarkerOnceAndPreservesDormantDeadlines(t *testing.T) {
func TestPreservesDisconnectedAuthorityAcrossRepeatedRestarts(t *testing.T) {
path := databasePath(t)
testClock := &clock{nowMs: 100}
first := stableStore(t, path, testClock, Options{})
room := createRoom(t, first, protocol.CodeEntryOpen, "", "5678")
mustConnect(t, first, hostInput(room.RoomID, room.HostToken))
if err := first.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
testClock.nowMs = 500
second := stableStore(t, path, testClock, Options{})
firstRestart := mustConnect(t, second, dbViewerInput(room.RoomID, "first-restart", room.ViewerGrant))
if firstRestart.ExpiresAt == nil || *firstRestart.ExpiresAt != "1970-01-01T00:00:01.500Z" {
t.Fatalf("expiresAt = %v", firstRestart.ExpiresAt)
}
if err := second.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
testClock.nowMs = 700
third := stableStore(t, path, testClock, Options{})
secondRestart := mustConnect(t, third, dbViewerInput(room.RoomID, "second-restart", room.ViewerGrant))
if secondRestart.ExpiresAt == nil || *secondRestart.ExpiresAt != "1970-01-01T00:00:01.500Z" {
t.Fatalf("expiresAt = %v", secondRestart.ExpiresAt)
}
if err := third.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
testClock.nowMs = 1_501
fourth := stableStore(t, path, testClock, Options{})
if fourth.Size() != 0 {
t.Fatalf("size = %d, want 0", fourth.Size())
}
replacement := createRoom(t, fourth, protocol.CodeEntryOpen, "", room.RoomID)
if replacement.RoomID != room.RoomID {
t.Fatalf("replacement room = %q", replacement.RoomID)
}
_, err := fourth.ConnectParticipant(hostInput(room.RoomID, room.HostToken))
expectCode(t, err, CodeInvalidToken)
_, err = fourth.ConnectParticipant(dbViewerInput(room.RoomID, "stale-grant", room.ViewerGrant))
expectCode(t, err, CodeInvalidToken)
}
func TestPreservesTheExactHostDisconnectDeadline(t *testing.T) {
path := databasePath(t)
testClock := &clock{nowMs: 100}
first := stableStore(t, path, testClock, Options{})
first := stableStore(t, path, Options{})
room := createRoom(t, first, protocol.CodeEntryOpen, "", "5890")
host := mustConnect(t, first, hostInput(room.RoomID, room.HostToken))
testClock.nowMs = 250
if _, err := first.DisconnectParticipant(room.RoomID, host.PeerID, "host-session"); err != nil {
t.Fatalf("DisconnectParticipant: %v", err)
}
@@ -278,18 +218,25 @@ func TestPreservesTheExactHostDisconnectDeadline(t *testing.T) {
t.Fatalf("Close: %v", err)
}
testClock.nowMs = 500
second := stableStore(t, path, testClock, Options{})
dormant := mustConnect(t, second, dbViewerInput(room.RoomID, "dormant-viewer", room.ViewerGrant))
if dormant.ExpiresAt == nil || *dormant.ExpiresAt != "1970-01-01T00:00:01.250Z" {
t.Fatalf("expiresAt = %v", dormant.ExpiresAt)
for range 3 {
restored := stableStore(t, path, Options{})
if restored.Size() != 1 {
t.Fatalf("size = %d, want 1", restored.Size())
}
viewer := mustConnect(t, restored, dbViewerInput(room.RoomID, "dormant-viewer", room.ViewerGrant))
if viewer.HostOnline {
t.Fatal("restored room reported an online host")
}
mustConnect(t, restored, hostInput(room.RoomID, room.HostToken))
if err := restored.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
}
func TestPersistsExplicitRoomDeletionBeforeRecyclingItsCode(t *testing.T) {
path := databasePath(t)
testClock := &clock{nowMs: 10}
first := stableStore(t, path, testClock, Options{})
first := stableStore(t, path, Options{})
room := createRoom(t, first, protocol.CodeEntryOpen, "", "6789")
closed, err := first.AbandonRoom(room.RoomID)
if err != nil || closed == nil || closed.RoomID != room.RoomID {
@@ -299,7 +246,7 @@ func TestPersistsExplicitRoomDeletionBeforeRecyclingItsCode(t *testing.T) {
t.Fatalf("Close: %v", err)
}
second := stableStore(t, path, testClock, Options{})
second := stableStore(t, path, Options{})
if second.Size() != 0 {
t.Fatalf("size = %d, want 0", second.Size())
}
@@ -312,48 +259,9 @@ func TestPersistsExplicitRoomDeletionBeforeRecyclingItsCode(t *testing.T) {
}
}
func TestRollsBackABatchExpiryWhenAnyAuthorityIsStale(t *testing.T) {
path := databasePath(t)
testClock := &clock{nowMs: 10}
first := stableStore(t, path, testClock, Options{})
createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
createRoom(t, first, protocol.CodeEntryOpen, "", "5678")
if err := first.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
database, err := NewDatabase(path)
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
restored, err := database.Initialize(testClock.nowMs, 1_010)
if err != nil {
t.Fatalf("Initialize: %v", err)
}
if len(restored) != 2 {
t.Fatalf("restored %d rooms, want 2", len(restored))
}
err = database.DeleteRooms([]RoomIdentity{
{RoomID: restored[0].RoomID, HostTokenDigest: restored[0].HostTokenDigest},
{RoomID: restored[1].RoomID, HostTokenDigest: make([]byte, 32)},
})
if err == nil || err.Error() != "Room database delete did not match current authority" {
t.Fatalf("DeleteRooms: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
second := stableStore(t, path, testClock, Options{})
if second.Size() != 2 {
t.Fatalf("size = %d, want 2", second.Size())
}
}
func TestPersistsRoomReplacementAsOneAuthorityTransition(t *testing.T) {
path := databasePath(t)
testClock := &clock{nowMs: 10}
first := stableStore(t, path, testClock, Options{})
first := stableStore(t, path, Options{})
original := createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
replacement, err := tryReplaceRoom(
first, original.RoomID, original.HostToken, protocol.CodeEntryPrivate, "new-password")
@@ -364,7 +272,7 @@ func TestPersistsRoomReplacementAsOneAuthorityTransition(t *testing.T) {
t.Fatalf("Close: %v", err)
}
second := stableStore(t, path, testClock, Options{})
second := stableStore(t, path, Options{})
if second.Size() != 1 {
t.Fatalf("size = %d, want 1", second.Size())
}
@@ -386,14 +294,44 @@ func TestPersistsRoomReplacementAsOneAuthorityTransition(t *testing.T) {
}
}
func TestRollsBackReplacementWhenOldAuthorityIsStale(t *testing.T) {
path := databasePath(t)
first := stableStore(t, path, Options{})
createRoom(t, first, protocol.CodeEntryPrivate, "room-password", "4321")
if err := first.Close(); err != nil {
t.Fatal(err)
}
database, err := NewDatabase(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
before, err := database.Initialize()
if err != nil || len(before) != 1 {
t.Fatalf("Initialize = %v, %v", before, err)
}
replacement := before[0]
replacement.RoomID = "5678"
err = database.ReplaceRoom("4321", make([]byte, 32), replacement)
if err == nil || err.Error() != "Room database replacement delete did not match current authority" {
t.Fatalf("ReplaceRoom = %v", err)
}
if err := database.Close(); err != nil {
t.Fatal(err)
}
after, err := database.Initialize()
if err != nil || !reflect.DeepEqual(after, before) {
t.Fatalf("failed replacement changed durable authority: %v", err)
}
}
// TestOpensThePathWithURIReservedCharactersLiterally: `new DatabaseSync(path)`
// opened the literal path, while the driver's DSN is a URI in which `%41`
// decodes to `A` and `#` ends the file name; the file must still be the
// configured one, and the busy-timeout pragma must survive the escaping.
func TestOpensThePathWithURIReservedCharactersLiterally(t *testing.T) {
testClock := &clock{nowMs: 10}
path := filepath.Join(t.TempDir(), "rooms%41#1.sqlite")
first := stableStore(t, path, testClock, Options{})
first := stableStore(t, path, Options{})
createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
if err := first.Close(); err != nil {
t.Fatalf("Close: %v", err)
@@ -401,7 +339,7 @@ func TestOpensThePathWithURIReservedCharactersLiterally(t *testing.T) {
if _, err := os.Stat(path); err != nil {
t.Fatalf("the configured file was not the one opened: %v", err)
}
if second := stableStore(t, path, testClock, Options{}); second.Size() != 1 {
if second := stableStore(t, path, Options{}); second.Size() != 1 {
t.Fatalf("restored size = %d, want 1", second.Size())
}
}
@@ -412,14 +350,14 @@ func TestRejectsAnotherOwnerOfTheSameDatabase(t *testing.T) {
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
if _, err := first.Initialize(0, 1_000); err != nil {
if _, err := first.Initialize(); err != nil {
t.Fatalf("Initialize: %v", err)
}
contender, err := NewDatabase(path)
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
_, err = contender.Initialize(0, 1_000)
_, err = contender.Initialize()
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "locked") {
t.Fatalf("contender Initialize = %v, want a lock failure", err)
}
@@ -434,7 +372,7 @@ func TestRejectsAnotherOwnerOfTheSameDatabase(t *testing.T) {
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
restored, err := successor.Initialize(0, 1_000)
restored, err := successor.Initialize()
if err != nil || len(restored) != 0 {
t.Fatalf("successor Initialize = %v, %v", restored, err)
}
@@ -446,7 +384,7 @@ func TestRejectsAnotherOwnerOfTheSameDatabase(t *testing.T) {
func TestRejectsAnUnknownDatabaseIdentity(t *testing.T) {
for _, testCase := range []struct{ name, mutation string }{
{"application identity", "PRAGMA application_id = 1"},
{"schema version", "PRAGMA user_version = 2"},
{"schema version", "PRAGMA user_version = 99"},
} {
t.Run(testCase.name, func(t *testing.T) {
path := databasePath(t)
@@ -454,7 +392,7 @@ func TestRejectsAnUnknownDatabaseIdentity(t *testing.T) {
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
if _, err := initial.Initialize(0, 1_000); err != nil {
if _, err := initial.Initialize(); err != nil {
t.Fatalf("Initialize: %v", err)
}
if err := initial.Close(); err != nil {
@@ -466,7 +404,7 @@ func TestRejectsAnUnknownDatabaseIdentity(t *testing.T) {
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
_, err = reopened.Initialize(0, 1_000)
_, err = reopened.Initialize()
if err == nil || !strings.Contains(err.Error(), "does not match") {
t.Fatalf("Initialize = %v", err)
}
@@ -483,7 +421,7 @@ func TestRejectsAnAlteredCurrentVersionSchema(t *testing.T) {
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
if _, err := initial.Initialize(0, 1_000); err != nil {
if _, err := initial.Initialize(); err != nil {
t.Fatalf("Initialize: %v", err)
}
if err := initial.Close(); err != nil {
@@ -495,7 +433,7 @@ func TestRejectsAnAlteredCurrentVersionSchema(t *testing.T) {
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
_, err = reopened.Initialize(0, 1_000)
_, err = reopened.Initialize()
if err == nil || err.Error() != "Room database columns do not match the current schema" {
t.Fatalf("Initialize = %v", err)
}
@@ -506,8 +444,7 @@ func TestRejectsAnAlteredCurrentVersionSchema(t *testing.T) {
func TestRejectsCorruptAuthorityRows(t *testing.T) {
path := databasePath(t)
testClock := &clock{nowMs: 100}
initial := stableStore(t, path, testClock, Options{})
initial := stableStore(t, path, Options{})
createRoom(t, initial, protocol.CodeEntryOpen, "", "7890")
if err := initial.Close(); err != nil {
t.Fatalf("Close: %v", err)
@@ -529,10 +466,8 @@ func TestRejectsCorruptAuthorityRows(t *testing.T) {
t.Fatalf("NewDatabase: %v", err)
}
reopened, err := New(Options{
LeaseMs: 1_000,
MaxRooms: 8,
MaxViewersPerRoom: 8,
Now: func() int64 { return testClock.nowMs },
Database: database,
})
if err != nil {
@@ -550,9 +485,7 @@ func TestRejectsCorruptAuthorityRows(t *testing.T) {
}
}
// TestRejectsInvalidAuthorityArguments keeps the assertions the TypeScript ran
// before touching SQLite: STRICT would accept an integral REAL or a numeric
// TEXT timestamp, and a caller mistake must not reach the file at all.
// Invalid authority must be rejected before mutating SQLite.
func TestRejectsInvalidAuthorityArguments(t *testing.T) {
valid := StoredRoomAuthority{
RoomID: "1234",
@@ -561,7 +494,6 @@ func TestRejectsInvalidAuthorityArguments(t *testing.T) {
ViewerAuthorizationGeneration: "MzMzMzMzMzMzMzMzMzMzMw",
CodeEntryPolicy: protocol.CodeEntryOpen,
}
lease := int64(protocol.MaxSafeInteger + 1)
shortDigest := make([]byte, 31)
for _, testCase := range []struct {
name string
@@ -598,13 +530,8 @@ func TestRejectsInvalidAuthorityArguments(t *testing.T) {
room.ViewerPasswordMaterial = make([]byte, 47)
return d.InsertRoom(room)
}, "Viewer password material must contain 48 bytes"},
{"lease deadline", func(d *Database) error {
room := valid
room.LeaseExpiresAtMs = &lease
return d.InsertRoom(room)
}, "Room database lease deadline must be a safe positive integer"},
{"identity", func(d *Database) error {
return d.SetLeaseDeadline("123", make([]byte, 32), nil)
return d.DeleteRoom("123", make([]byte, 32))
}, "Room ID is invalid"},
{"update policy", func(d *Database) error {
return d.SetCodeEntryPolicy("1234", make([]byte, 32), "public")
@@ -618,7 +545,7 @@ func TestRejectsInvalidAuthorityArguments(t *testing.T) {
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
if _, err := database.Initialize(0, 1_000); err != nil {
if _, err := database.Initialize(); err != nil {
t.Fatalf("Initialize: %v", err)
}
defer func() { _ = database.Close() }()
@@ -644,10 +571,10 @@ func TestRejectsASecondInitialize(t *testing.T) {
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
if _, err := database.Initialize(0, 1_000); err != nil {
if _, err := database.Initialize(); err != nil {
t.Fatalf("Initialize: %v", err)
}
if _, err := database.Initialize(0, 1_000); err == nil ||
if _, err := database.Initialize(); err == nil ||
err.Error() != "Room database is already initialized" {
t.Fatalf("second Initialize = %v", err)
}
@@ -664,21 +591,3 @@ func TestRejectsASecondInitialize(t *testing.T) {
t.Fatalf("InsertRoom after Close = %v", err)
}
}
func TestRejectsAnActiveLeaseThatIsNotInTheFuture(t *testing.T) {
database, err := NewDatabase(databasePath(t))
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
if _, err := database.Initialize(1_000, 1_000); err == nil ||
err.Error() != "Room database active-room lease deadline must be in the future" {
t.Fatalf("Initialize = %v", err)
}
if _, err := database.Initialize(-1, 1_000); err == nil ||
err.Error() != "Room database startup time must be a safe positive integer" {
t.Fatalf("Initialize = %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
+6 -7
View File
@@ -1,8 +1,8 @@
// Package room ports the room authority: src/server/room-store.ts (rooms,
// participants, room codes, viewer credentials, the four-digit code pool) and
// src/server/room-database.ts (the SQLite stable authority). Every mutation
// writes SQLite before memory and returns the write error unchanged; there is
// no catch-and-continue anywhere, exactly as in the TypeScript.
// src/server/room-database.ts (the SQLite stable authority). Authority mutations
// write SQLite before memory and return the write error unchanged. Participants
// remain process-only.
//
// # Locking
//
@@ -21,8 +21,7 @@
// POST /api/rooms (createRoom):
//
// mu.Lock()
// lease, err := store.BeginCreateRoom() // ROOM_LIMIT + lease deadline, pre-KDF
// if err != nil { mu.Unlock(); return err }
// if err := store.BeginCreateRoom(); err != nil { mu.Unlock(); return err }
// var material []byte
// var derived error
// if password != "" {
@@ -30,7 +29,7 @@
// material, derived = store.DeriveViewerPasswordMaterial(password, nil)
// mu.Lock()
// }
// created, err := store.CreateRoom(policy, material, derived, preferredRoomID, lease)
// created, err := store.CreateRoom(policy, material, derived, preferredRoomID)
// mu.Unlock()
//
// replaceRoom and set-viewer-password (the mayStart closure is the TypeScript
@@ -38,7 +37,7 @@
// the derive goroutine, so it takes the lock itself):
//
// mu.Lock()
// current, err := store.HostManagedRoom(roomID, hostToken) // may fail INVALID_TOKEN/ROOM_EXPIRED
// current, err := store.HostManagedRoom(roomID, hostToken) // may fail INVALID_TOKEN
// if err != nil { mu.Unlock(); return err }
// var material []byte
// var derived error
+15 -126
View File
@@ -12,7 +12,6 @@ import (
"math"
"slices"
"strconv"
"time"
"github.com/TNTcraftHIM/Screener/internal/server/ordered"
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
@@ -32,7 +31,6 @@ const (
CodeRoomAccessDenied ErrorCode = "ROOM_ACCESS_DENIED"
CodeRoomBusy ErrorCode = "ROOM_BUSY"
CodeRoomNotFound ErrorCode = "ROOM_NOT_FOUND"
CodeRoomExpired ErrorCode = "ROOM_EXPIRED"
CodeRoomFull ErrorCode = "ROOM_FULL"
CodeHostAlreadyConnected ErrorCode = "HOST_ALREADY_CONNECTED"
CodeRoomLimit ErrorCode = "ROOM_LIMIT"
@@ -78,8 +76,6 @@ type Room struct {
viewerPasswordMaterial []byte
viewerAuthorizationGeneration string
codeEntryPolicy protocol.CodeEntryPolicy
sharingActive bool
leaseExpiresAtMs *int64
host *participant
// ordering: JS Map insertion order decides viewer presence order, the
// closed-session list and the router's participant order.
@@ -92,7 +88,6 @@ type CreatedRoom struct {
HostToken string
CodeEntryPolicy protocol.CodeEntryPolicy
ViewerGrant string
ExpiresAt *string
}
// ConnectParticipantInput is ConnectParticipantInput; Token is read for a host
@@ -121,7 +116,6 @@ type ConnectedParticipant struct {
RoomID string
Role protocol.Role
PeerID string
ExpiresAt *string
HostOnline bool
ReplacedSessionID string
CodeEntryPolicy protocol.CodeEntryPolicy
@@ -177,24 +171,20 @@ type CodeEntryUpdate struct {
// Options is RoomStoreOptions.
type Options struct {
LeaseMs int64
MaxRooms int
MaxViewersPerRoom int
Database *Database
Now func() int64
Random func(size int) []byte
}
// Store is RoomStore. See the package comment for the locking contract.
type Store struct {
// ordering: JS Map insertion order decides AbandonAllRooms/ExpireRooms.
// ordering: JS Map insertion order decides AbandonAllRooms.
rooms ordered.Map[string, *Room]
freeRoomCodes []string
now func() int64
random func(size int) []byte
database *Database
initialized bool
leaseMs int64
maxRooms int
maxViewersPerRoom int
// gate is per store; the TypeScript module-level gate was shared by every
@@ -205,9 +195,6 @@ type Store struct {
// New is the RoomStore constructor; the TypeScript threw where this returns an
// error.
func New(options Options) (*Store, error) {
if options.LeaseMs <= 0 || options.LeaseMs > protocol.MaxSafeInteger {
return nil, errors.New("Room lease must be a positive integer")
}
if options.MaxRooms <= 0 || options.MaxRooms > Capacity {
return nil, fmt.Errorf("Room limit must be an integer between 1 and %d", Capacity)
}
@@ -219,11 +206,9 @@ func New(options Options) (*Store, error) {
}
store := &Store{
freeRoomCodes: make([]string, Capacity),
now: options.Now,
random: options.Random,
database: options.Database,
initialized: options.Database == nil,
leaseMs: options.LeaseMs,
maxRooms: options.MaxRooms,
maxViewersPerRoom: options.MaxViewersPerRoom,
gate: gate{limit: gateActiveLimit, pendingLimit: gatePendingLimit},
@@ -231,9 +216,6 @@ func New(options Options) (*Store, error) {
for index := range store.freeRoomCodes {
store.freeRoomCodes[index] = strconv.Itoa(roomCodeFirst + index)
}
if store.now == nil {
store.now = func() int64 { return time.Now().UnixMilli() }
}
if store.random == nil {
store.random = cryptoRandom
}
@@ -270,7 +252,7 @@ func (s *Store) Initialize() error {
s.initialized = true
return nil
}
if err := s.restore(s.now()); err != nil {
if err := s.restore(); err != nil {
s.rooms.Clear()
_ = s.database.Close()
return err
@@ -279,12 +261,8 @@ func (s *Store) Initialize() error {
return nil
}
func (s *Store) restore(startupNowMs int64) error {
lease, err := s.leaseDeadline(startupNowMs)
if err != nil {
return err
}
storedRooms, err := s.database.Initialize(startupNowMs, lease)
func (s *Store) restore() error {
storedRooms, err := s.database.Initialize()
if err != nil {
return err
}
@@ -306,8 +284,6 @@ func (s *Store) restore(startupNowMs int64) error {
viewerPasswordMaterial: bytes.Clone(stored.ViewerPasswordMaterial),
viewerAuthorizationGeneration: stored.ViewerAuthorizationGeneration,
codeEntryPolicy: stored.CodeEntryPolicy,
sharingActive: false,
leaseExpiresAtMs: stored.LeaseExpiresAtMs,
})
}
available := s.freeRoomCodes[:0]
@@ -320,28 +296,25 @@ func (s *Store) restore(startupNowMs int64) error {
return nil
}
// BeginCreateRoom runs the part of createRoom that precedes the password
// derivation: the room limit and the lease deadline, which the TypeScript took
// from the clock before awaiting the KDF. Pass the deadline to CreateRoom.
func (s *Store) BeginCreateRoom() (leaseExpiresAtMs int64, err error) {
// BeginCreateRoom checks room capacity before password derivation.
func (s *Store) BeginCreateRoom() error {
if err := s.ensureInitialized(); err != nil {
return 0, err
return err
}
if s.rooms.Len() >= s.maxRooms {
return 0, codeError(CodeRoomLimit)
return codeError(CodeRoomLimit)
}
return s.leaseDeadline(s.now())
return nil
}
// CreateRoom is the commit half of createRoom. material and derived are the
// results of DeriveViewerPasswordMaterial (both zero when no password was
// given) and leaseExpiresAtMs comes from BeginCreateRoom.
// given).
func (s *Store) CreateRoom(
policy protocol.CodeEntryPolicy,
material []byte,
derived error,
preferredRoomID string,
leaseExpiresAtMs int64,
) (CreatedRoom, error) {
// createViewerPasswordMaterial threw before any of the checks below.
if derived != nil && !isGateOutcome(derived) {
@@ -363,7 +336,7 @@ func (s *Store) CreateRoom(
if err != nil {
return CreatedRoom{}, err
}
room, created, err := s.newRoom(roomID, policy, material, leaseExpiresAtMs)
room, created, err := s.newRoom(roomID, policy, material)
if err == nil {
err = s.writeDatabase(func() error { return s.database.InsertRoom(storedRoomAuthority(room)) })
}
@@ -437,11 +410,7 @@ func (s *Store) replaceRoom(
material []byte,
current *Room,
) (ReplacedRoom, error) {
leaseExpiresAtMs, err := s.leaseDeadline(s.now())
if err != nil {
return ReplacedRoom{}, err
}
room, created, err := s.newRoom(replacementRoomID, policy, material, leaseExpiresAtMs)
room, created, err := s.newRoom(replacementRoomID, policy, material)
if err != nil {
return ReplacedRoom{}, err
}
@@ -591,7 +560,7 @@ func (s *Store) ConnectParticipant(input ConnectParticipantInput) (ConnectedPart
room, err := s.getAvailableRoom(input.RoomID)
if err != nil {
if input.Role == protocol.RoleViewer && input.ViewerGrant == "" &&
(hasCode(err, CodeInvalidToken) || hasCode(err, CodeRoomExpired)) {
hasCode(err, CodeInvalidToken) {
return ConnectedParticipant{}, codeError(CodeRoomNotFound)
}
return ConnectedParticipant{}, err
@@ -633,8 +602,8 @@ func (s *Store) ViewerGrantMayEnter(roomID, grant string) (bool, error) {
// ViewerPasswordChallenge is the locked first half of connectViewerWithPassword:
// it returns the salt to derive against, a copy of the material to compare with
// and the room reference to re-check afterwards. A missing room, an expired
// room and a room without a private password all answer with the same 48 zero
// and the room reference to re-check afterwards. A missing room and a room
// without a private password both answer with the same 48 zero
// bytes so the derivation cost does not reveal which. The only error is the
// `ensureInitialized()` the TypeScript ran before the password check.
func (s *Store) ViewerPasswordChallenge(
@@ -711,19 +680,7 @@ func (s *Store) DisconnectParticipant(
if room.host.sessionID != sessionID {
return nil, nil
}
leaseExpiresAtMs, err := s.leaseDeadline(s.now())
if err != nil {
return nil, err
}
err = s.writeDatabase(func() error {
return s.database.SetLeaseDeadline(roomID, room.hostTokenDigest, &leaseExpiresAtMs)
})
if err != nil {
return nil, err
}
room.host.sessionID = ""
room.sharingActive = false
room.leaseExpiresAtMs = &leaseExpiresAtMs
return &DisconnectedParticipant{
RoomID: roomID, Role: protocol.RoleHost, PeerID: peerID}, nil
}
@@ -841,35 +798,6 @@ func (s *Store) AbandonAllRooms() ([]ClosedRoom, error) {
return closedRooms, nil
}
// ExpireRooms is expireRooms, in room insertion order. The TypeScript defaulted
// nowMs to the injected clock; every caller passed it explicitly.
func (s *Store) ExpireRooms(nowMs int64) ([]ClosedRoom, error) {
if err := s.ensureInitialized(); err != nil {
return nil, err
}
var expiredIDs []string
var identities []RoomIdentity
var expired []ClosedRoom
for roomID, room := range s.rooms.All() {
if !roomIsExpired(room, nowMs) {
continue
}
expiredIDs = append(expiredIDs, roomID)
identities = append(identities, RoomIdentity{
RoomID: roomID, HostTokenDigest: room.hostTokenDigest})
expired = append(expired, ClosedRoom{
RoomID: roomID, SessionIDs: connectedSessionIDs(room)})
}
if err := s.writeDatabase(func() error { return s.database.DeleteRooms(identities) }); err != nil {
return nil, err
}
for _, roomID := range expiredIDs {
s.rooms.Delete(roomID)
s.releaseRoomCode(roomID)
}
return expired, nil
}
func (s *Store) connectHost(
room *Room, input ConnectParticipantInput,
) (ConnectedParticipant, error) {
@@ -888,16 +816,8 @@ func (s *Store) connectHost(
clientID: input.ClientID, peerID: peerID, admittedBy: admittedByCode}
}
replacedSessionID := joining.sessionID
err := s.writeDatabase(func() error {
return s.database.SetLeaseDeadline(room.roomID, room.hostTokenDigest, nil)
})
if err != nil {
return ConnectedParticipant{}, err
}
joining.sessionID = input.SessionID
room.host = joining
room.sharingActive = true
room.leaseExpiresAtMs = nil
return connectedParticipant(
room, joining.peerID, protocol.RoleHost, replacedSessionID, input.SessionID), nil
@@ -936,7 +856,6 @@ func connectedParticipant(
RoomID: room.roomID,
Role: role,
PeerID: peerID,
ExpiresAt: formatExpiresAt(room.leaseExpiresAtMs),
HostOnline: room.host != nil && room.host.sessionID != "",
ReplacedSessionID: replacedSessionID,
CodeEntryPolicy: room.codeEntryPolicy,
@@ -950,9 +869,6 @@ func (s *Store) getAvailableRoom(roomID string) (*Room, error) {
if !ok {
return nil, codeError(CodeInvalidToken)
}
if roomIsExpired(room, s.now()) {
return nil, codeError(CodeRoomExpired)
}
return room, nil
}
@@ -1067,7 +983,6 @@ func (s *Store) newRoom(
roomID string,
policy protocol.CodeEntryPolicy,
material []byte,
leaseExpiresAtMs int64,
) (*Room, CreatedRoom, error) {
hostTokenBytes := s.random(32)
if len(hostTokenBytes) != 32 {
@@ -1089,15 +1004,12 @@ func (s *Store) newRoom(
viewerPasswordMaterial: material,
viewerAuthorizationGeneration: generation,
codeEntryPolicy: policy,
sharingActive: false,
leaseExpiresAtMs: &leaseExpiresAtMs,
}
return room, CreatedRoom{
RoomID: roomID,
HostToken: hostToken,
CodeEntryPolicy: policy,
ViewerGrant: viewerGrant,
ExpiresAt: formatExpiresAt(&leaseExpiresAtMs),
}, nil
}
@@ -1117,15 +1029,6 @@ func (s *Store) newAuthorizationGeneration() (string, error) {
return base64.RawURLEncoding.EncodeToString(value), nil
}
func (s *Store) leaseDeadline(nowMs int64) (int64, error) {
deadline := nowMs + s.leaseMs
if deadline <= nowMs || deadline > protocol.MaxSafeInteger ||
deadline < -protocol.MaxSafeInteger {
return 0, errors.New("Room lease deadline exceeds the supported time range")
}
return deadline, nil
}
func (s *Store) ensureInitialized() error {
if !s.initialized {
return errors.New("RoomStore stable authority is not initialized")
@@ -1150,23 +1053,9 @@ func storedRoomAuthority(room *Room) StoredRoomAuthority {
ViewerAuthorizationGeneration: room.viewerAuthorizationGeneration,
CodeEntryPolicy: room.codeEntryPolicy,
ViewerPasswordMaterial: room.viewerPasswordMaterial,
LeaseExpiresAtMs: room.leaseExpiresAtMs,
}
}
func roomIsExpired(room *Room, nowMs int64) bool {
return !room.sharingActive && room.leaseExpiresAtMs != nil &&
*room.leaseExpiresAtMs <= nowMs
}
func formatExpiresAt(expiresAtMs *int64) *string {
if expiresAtMs == nil {
return nil
}
value := time.UnixMilli(*expiresAtMs).UTC().Format("2006-01-02T15:04:05.000Z")
return &value
}
func findViewerByPeerID(room *Room, peerID string) *participant {
for _, viewer := range room.viewers.Values() {
if viewer.peerID == peerID {
+36 -93
View File
@@ -16,21 +16,14 @@ import (
// The scenarios below are ported from tests/room-store.test.ts.
type clock struct{ nowMs int64 }
func newStore(t *testing.T, options Options) (*Store, *clock) {
func newStore(t *testing.T, options Options) *Store {
t.Helper()
testClock := &clock{}
if options.LeaseMs == 0 {
options.LeaseMs = 1_000
}
if options.MaxRooms == 0 {
options.MaxRooms = 2
}
if options.MaxViewersPerRoom == 0 {
options.MaxViewersPerRoom = 2
}
options.Now = func() int64 { return testClock.nowMs }
store, err := New(options)
if err != nil {
t.Fatalf("New: %v", err)
@@ -39,7 +32,7 @@ func newStore(t *testing.T, options Options) (*Store, *clock) {
t.Fatalf("Initialize: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
return store, testClock
return store
}
func constantRandom(value func(size int) byte) func(size int) []byte {
@@ -51,8 +44,7 @@ func constantRandom(value func(size int) byte) func(size int) []byte {
func tryCreateRoom(
store *Store, policy protocol.CodeEntryPolicy, password, preferredRoomID string,
) (CreatedRoom, error) {
lease, err := store.BeginCreateRoom()
if err != nil {
if err := store.BeginCreateRoom(); err != nil {
return CreatedRoom{}, err
}
var material []byte
@@ -60,7 +52,7 @@ func tryCreateRoom(
if password != "" {
material, derived = store.DeriveViewerPasswordMaterial(password, nil)
}
return store.CreateRoom(policy, material, derived, preferredRoomID, lease)
return store.CreateRoom(policy, material, derived, preferredRoomID)
}
func createRoom(
@@ -167,7 +159,7 @@ var roomCodePattern = regexp.MustCompile(`^[1-9][0-9]{3}$`)
var viewerGrantPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{21}[AQgw]$`)
func TestAllocatesEveryFreeRoomCodeAndRecyclesReleases(t *testing.T) {
store, _ := newStore(t, Options{
store := newStore(t, Options{
MaxRooms: Capacity,
Random: constantRandom(func(int) byte { return 0 }),
})
@@ -200,7 +192,7 @@ func TestAllocatesEveryFreeRoomCodeAndRecyclesReleases(t *testing.T) {
}
func TestUsesAFreePreferredCodeAndFallsBack(t *testing.T) {
store, _ := newStore(t, Options{MaxRooms: 3})
store := newStore(t, Options{MaxRooms: 3})
preferred := createRoom(t, store, protocol.CodeEntryOpen, "", "4321")
fallback := createRoom(t, store, protocol.CodeEntryOpen, "", "4321")
@@ -220,7 +212,7 @@ func TestUsesAFreePreferredCodeAndFallsBack(t *testing.T) {
}
func TestAbandonsEveryCurrentRoomAsOneShutdown(t *testing.T) {
store, _ := newStore(t, Options{MaxRooms: 3})
store := newStore(t, Options{MaxRooms: 3})
first := createRoom(t, store, protocol.CodeEntryOpen, "", "")
second := createRoom(t, store, protocol.CodeEntryOpen, "", "")
mustConnect(t, store, hostInput(first.RoomID, first.HostToken))
@@ -248,7 +240,7 @@ func TestAbandonsEveryCurrentRoomAsOneShutdown(t *testing.T) {
}
func TestAtomicallyReplacesARoomWithDifferentAuthority(t *testing.T) {
store, _ := newStore(t, Options{})
store := newStore(t, Options{})
original := createRoom(t, store, protocol.CodeEntryPrivate, "old-password", "4321")
host := mustConnect(t, store, hostInput(original.RoomID, original.HostToken))
mustConnect(t, store, viewerInput(original.RoomID, "viewer-session", original.ViewerGrant))
@@ -293,56 +285,36 @@ func TestAtomicallyReplacesARoomWithDifferentAuthority(t *testing.T) {
}
func TestRejectsRoomLimitsBeyondTheCodeSpace(t *testing.T) {
_, err := New(Options{LeaseMs: 1_000, MaxRooms: Capacity + 1, MaxViewersPerRoom: 2})
_, err := New(Options{MaxRooms: Capacity + 1, MaxViewersPerRoom: 2})
if err == nil || err.Error() != "Room limit must be an integer between 1 and 9000" {
t.Fatalf("New: %v", err)
}
}
func TestKeepsActiveSharingAliveAndRenewsOnlyWithTheExactHostToken(t *testing.T) {
store, testClock := newStore(t, Options{LeaseMs: 1_000})
func TestResumesDisconnectedRoomOnlyWithTheExactHostToken(t *testing.T) {
store := newStore(t, Options{})
room := createRoom(t, store, protocol.CodeEntryOpen, "", "")
host := mustConnect(t, store, hostInput(room.RoomID, room.HostToken))
testClock.nowMs = 2_000
if expired, err := store.ExpireRooms(testClock.nowMs); err != nil || len(expired) != 0 {
t.Fatalf("ExpireRooms while sharing = %v, %v", expired, err)
}
if _, err := store.DisconnectParticipant(room.RoomID, host.PeerID, "host-session"); err != nil {
t.Fatalf("DisconnectParticipant: %v", err)
}
testClock.nowMs = 2_500
if expired, err := store.ExpireRooms(testClock.nowMs); err != nil || len(expired) != 0 {
t.Fatalf("ExpireRooms inside the lease = %v, %v", expired, err)
}
_, err := store.ConnectParticipant(hostInput(room.RoomID, "wrong-token"))
expectCode(t, err, CodeInvalidToken)
resumed := mustConnect(t, store, hostInput(room.RoomID, room.HostToken, "host-session-2"))
if resumed.ExpiresAt != nil {
t.Fatalf("resumed expiresAt = %v, want null", *resumed.ExpiresAt)
}
if _, err := store.DisconnectParticipant(
room.RoomID, resumed.PeerID, "host-session-2"); err != nil {
t.Fatalf("DisconnectParticipant: %v", err)
}
mustConnect(t, store, viewerInput(room.RoomID, "viewer-session", room.ViewerGrant))
testClock.nowMs = 3_501
expired, err := store.ExpireRooms(testClock.nowMs)
if err != nil || len(expired) != 1 {
t.Fatalf("ExpireRooms after the lease = %v, %v", expired, err)
}
_, err = store.ConnectParticipant(hostInput(room.RoomID, room.HostToken, "late-host"))
expectCode(t, err, CodeInvalidToken)
mustConnect(t, store, hostInput(room.RoomID, room.HostToken, "late-host"))
}
func TestManagesDormantAccessWithTheExactHostTokenWithoutRenewing(t *testing.T) {
store, testClock := newStore(t, Options{LeaseMs: 1_000})
func TestManagesDormantAccessWithTheExactHostToken(t *testing.T) {
store := newStore(t, Options{})
room := createRoom(t, store, protocol.CodeEntryOpen, "", "")
otherRoom := createRoom(t, store, protocol.CodeEntryOpen, "", "")
testClock.nowMs = 900
_, err := trySetViewerPassword(store, room.RoomID, "room-password", "wrong-token")
expectCode(t, err, CodeInvalidToken)
@@ -370,28 +342,10 @@ func TestManagesDormantAccessWithTheExactHostTokenWithoutRenewing(t *testing.T)
if _, ok := store.GetConnectedHost(room.RoomID); ok {
t.Fatal("a dormant room reported a connected host")
}
testClock.nowMs = 1_001
expired, err := store.ExpireRooms(testClock.nowMs)
if err != nil {
t.Fatalf("ExpireRooms: %v", err)
}
if !slicesContainsRoom(expired, room.RoomID) {
t.Fatalf("expired = %+v, want %q", expired, room.RoomID)
}
}
func slicesContainsRoom(rooms []ClosedRoom, roomID string) bool {
for _, room := range rooms {
if room.RoomID == roomID {
return true
}
}
return false
}
func TestKeepsViewerGrantsIndependentFromCodeEntryPolicy(t *testing.T) {
store, _ := newStore(t, Options{MaxRooms: 3})
store := newStore(t, Options{MaxRooms: 3})
room := createRoom(t, store, protocol.CodeEntryPrivate, "", "")
_, err := store.ConnectParticipant(viewerInput(room.RoomID, "code-only", ""))
@@ -410,14 +364,13 @@ func TestKeepsViewerGrantsIndependentFromCodeEntryPolicy(t *testing.T) {
}
func TestKeepsTheExactViewerGrantValidForTheRoomIncarnation(t *testing.T) {
store, testClock := newStore(t, Options{LeaseMs: 1_000})
store := newStore(t, Options{})
room := createRoom(t, store, protocol.CodeEntryOpen, "", "")
mustConnect(t, store, hostInput(room.RoomID, room.HostToken))
if !viewerGrantPattern.MatchString(room.ViewerGrant) {
t.Fatalf("viewer grant = %q", room.ViewerGrant)
}
testClock.nowMs = 8 * 24 * 60 * 60 * 1_000
connected := mustConnect(t, store, viewerInput(room.RoomID, "room-lived-grant", room.ViewerGrant))
if connected.Role != protocol.RoleViewer {
t.Fatalf("role = %q", connected.Role)
@@ -425,7 +378,7 @@ func TestKeepsTheExactViewerGrantValidForTheRoomIncarnation(t *testing.T) {
}
func TestSupportsOpenAndPasswordEnabledPrivateCodeEntry(t *testing.T) {
store, _ := newStore(t, Options{MaxRooms: 3})
store := newStore(t, Options{MaxRooms: 3})
open := createRoom(t, store, protocol.CodeEntryOpen, "", "")
if connected := mustConnect(t, store, viewerInput(open.RoomID, "open", "")); connected.Role !=
protocol.RoleViewer {
@@ -495,7 +448,7 @@ func waitForSaturatedGate(t *testing.T, store *Store) {
}
func TestDoesNotCreateAPasswordlessRoomWhenTheGateIsBusy(t *testing.T) {
store, _ := newStore(t, Options{MaxRooms: 1})
store := newStore(t, Options{MaxRooms: 1})
withSaturatedGate(t, store, func() {
_, err := tryCreateRoom(store, protocol.CodeEntryPrivate, "room-password", "4321")
@@ -510,7 +463,7 @@ func TestDoesNotCreateAPasswordlessRoomWhenTheGateIsBusy(t *testing.T) {
}
func TestKeepsGateSaturationDistinctFromInvalidCredentials(t *testing.T) {
store, _ := newStore(t, Options{})
store := newStore(t, Options{})
room := createRoom(t, store, protocol.CodeEntryPrivate, "room-password", "")
withSaturatedGate(t, store, func() {
@@ -525,7 +478,7 @@ func TestKeepsGateSaturationDistinctFromInvalidCredentials(t *testing.T) {
}
func TestKeepsRoomReplacementAndPasswordUpdatesUnchangedWhenBusy(t *testing.T) {
store, _ := newStore(t, Options{MaxRooms: 3})
store := newStore(t, Options{MaxRooms: 3})
room := createRoom(t, store, protocol.CodeEntryPrivate, "old-password", "4321")
withSaturatedGate(t, store, func() {
@@ -560,7 +513,7 @@ func TestKeepsRoomReplacementAndPasswordUpdatesUnchangedWhenBusy(t *testing.T) {
}
func TestBoundsPasswordDerivationsAndUsesTheSamePathForUnknownRooms(t *testing.T) {
store, _ := newStore(t, Options{})
store := newStore(t, Options{})
var mayStartCalls atomic.Int64
release := make(chan struct{})
@@ -611,7 +564,7 @@ func TestBoundsPasswordDerivationsAndUsesTheSamePathForUnknownRooms(t *testing.T
}
func TestRotatesAndRevokesViewerGrantsWithoutChangingCodeEntry(t *testing.T) {
store, _ := newStore(t, Options{})
store := newStore(t, Options{})
room := createRoom(t, store, protocol.CodeEntryOpen, "", "")
mustConnect(t, store, hostInput(room.RoomID, room.HostToken))
@@ -646,7 +599,7 @@ func TestKeepsTheOldGrantWhenRotationCannotCreateAGeneration(t *testing.T) {
failedRandomCall := 0
randomCalls := 0
randomValue := byte(0)
store, _ := newStore(t, Options{
store := newStore(t, Options{
Random: func(size int) []byte {
randomCalls++
randomValue++
@@ -680,9 +633,9 @@ func TestKeepsTheOldGrantWhenRotationCannotCreateAGeneration(t *testing.T) {
}
func TestClearsEveryCredentialOnProcessRestart(t *testing.T) {
firstStore, _ := newStore(t, Options{})
firstStore := newStore(t, Options{})
room := createRoom(t, firstStore, protocol.CodeEntryPrivate, "room-password", "")
secondStore, _ := newStore(t, Options{})
secondStore := newStore(t, Options{})
replacement := createRoom(t, secondStore, protocol.CodeEntryOpen, "", "")
if !roomCodePattern.MatchString(replacement.RoomID) {
@@ -702,7 +655,7 @@ func TestClearsEveryCredentialOnProcessRestart(t *testing.T) {
}
func TestRejectsAnOldGrantWhenANewRoomReusesTheSameCode(t *testing.T) {
firstStore, _ := newStore(t, Options{
firstStore := newStore(t, Options{
Random: constantRandom(func(size int) byte {
if size == 8 {
return 0
@@ -715,7 +668,7 @@ func TestRejectsAnOldGrantWhenANewRoomReusesTheSameCode(t *testing.T) {
t.Fatalf("AbandonRoom: %v", err)
}
secondStore, _ := newStore(t, Options{
secondStore := newStore(t, Options{
Random: constantRandom(func(size int) byte {
if size == 8 {
return 0
@@ -737,7 +690,7 @@ func TestRejectsAnOldGrantWhenANewRoomReusesTheSameCode(t *testing.T) {
}
func TestParticipantReplacementRules(t *testing.T) {
store, _ := newStore(t, Options{})
store := newStore(t, Options{})
room := createRoom(t, store, protocol.CodeEntryOpen, "", "")
host := mustConnect(t, store, hostInput(room.RoomID, room.HostToken))
@@ -823,7 +776,7 @@ func TestHostChangeChecksIdentityAfterTheDerivation(t *testing.T) {
// A constant random source gives a recreated room the same Host token, so
// the identity comparison is reachable instead of being shadowed by the
// token check.
store, testClock := newStore(t, Options{
store := newStore(t, Options{
MaxRooms: 3,
Random: constantRandom(func(int) byte { return 7 }),
})
@@ -856,15 +809,6 @@ func TestHostChangeChecksIdentityAfterTheDerivation(t *testing.T) {
if err != nil {
t.Fatalf("HostManagedRoom: %v", err)
}
testClock.nowMs = 5_000
_, err = store.SetViewerPassword(again.RoomID, again.HostToken, nil, nil, incarnation)
expectCode(t, err, CodeRoomExpired)
// An expired room outranks a saturated gate, as the TypeScript order did.
_, err = store.SetViewerPassword(
again.RoomID, again.HostToken, nil, codeError(CodeRoomBusy), incarnation)
expectCode(t, err, CodeRoomExpired)
testClock.nowMs = 0
_, err = store.SetViewerPassword(
again.RoomID, again.HostToken, nil, errDerivationCancelled, incarnation)
expectCode(t, err, CodeRoomAccessDenied)
@@ -883,7 +827,7 @@ func TestHostChangeChecksIdentityAfterTheDerivation(t *testing.T) {
func TestPasswordLoginRechecksTheRoomAfterTheDerivation(t *testing.T) {
// A constant random source repeats the salt, so a recreated room holds the
// same material and only the room identity separates the two incarnations.
store, _ := newStore(t, Options{
store := newStore(t, Options{
MaxRooms: 3,
Random: constantRandom(func(int) byte { return 7 }),
})
@@ -938,7 +882,7 @@ func TestPasswordLoginRequiresAnInitializedStore(t *testing.T) {
if err != nil {
t.Fatalf("NewDatabase: %v", err)
}
store, err := New(Options{LeaseMs: 1_000, MaxRooms: 2, MaxViewersPerRoom: 2, Database: database})
store, err := New(Options{MaxRooms: 2, MaxViewersPerRoom: 2, Database: database})
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -951,7 +895,7 @@ func TestPasswordLoginRequiresAnInitializedStore(t *testing.T) {
// TestPasswordLoginHidesAFullRoom keeps ROOM_FULL from leaking to a Viewer that
// authenticated with the room password.
func TestPasswordLoginHidesAFullRoom(t *testing.T) {
store, _ := newStore(t, Options{MaxViewersPerRoom: 2})
store := newStore(t, Options{MaxViewersPerRoom: 2})
room := createRoom(t, store, protocol.CodeEntryPrivate, "room-password", "")
mustConnect(t, store, viewerInput(room.RoomID, "first", room.ViewerGrant))
mustConnect(t, store, viewerInput(room.RoomID, "second", room.ViewerGrant))
@@ -966,9 +910,8 @@ func TestPasswordLoginHidesAFullRoom(t *testing.T) {
}
func TestCreateRoomReportsACancelledDerivation(t *testing.T) {
store, _ := newStore(t, Options{})
lease, err := store.BeginCreateRoom()
if err != nil {
store := newStore(t, Options{})
if err := store.BeginCreateRoom(); err != nil {
t.Fatalf("BeginCreateRoom: %v", err)
}
material, derived := store.DeriveViewerPasswordMaterial(
@@ -976,7 +919,7 @@ func TestCreateRoomReportsACancelledDerivation(t *testing.T) {
if material != nil || !errors.Is(derived, errDerivationCancelled) {
t.Fatalf("derive = %v, %v", material, derived)
}
_, err = store.CreateRoom(protocol.CodeEntryPrivate, material, derived, "", lease)
_, err := store.CreateRoom(protocol.CodeEntryPrivate, material, derived, "")
if err == nil || err.Error() != "Room creation password derivation was cancelled" {
t.Fatalf("CreateRoom = %v", err)
}
@@ -986,7 +929,7 @@ func TestCreateRoomReportsACancelledDerivation(t *testing.T) {
}
func TestRejectsAMalformedViewerPassword(t *testing.T) {
store, _ := newStore(t, Options{})
store := newStore(t, Options{})
for _, password := range []string{"", "with space", strings.Repeat("a", 65)} {
material, derived := store.DeriveViewerPasswordMaterial(password, nil)
if material != nil || !hasCode(derived, CodeInvalidToken) {
Binary file not shown.
+2 -2
View File
@@ -306,8 +306,8 @@ type ReconcileResult struct {
Released []*Resource
}
// SettleResult is the outcome of a candidate settlement call. Committed is
// true only when CandidateReady committed the attempt (TS: optional
// SettleResult is the outcome of candidate settlement or active replacement.
// Committed is true only when CandidateReady committed the attempt (TS: optional
// committed, read as truthy by the router). TS: SettleResult.
type SettleResult struct {
Accepted bool
+18 -9
View File
@@ -778,13 +778,13 @@ func (c *Controller) InvalidateHostPublication(guard PublicationGuard, nowMs *in
return true
}
// AdoptDirectConnection rewrites the connection ID of a usable, active
// direct edge the operation does not own; true when adopted (no-op when
// unchanged).
func (c *Controller) AdoptDirectConnection(input AdoptDirectConnectionInput) bool {
// AdoptDirectConnection accepts a replacement for the exact active edge.
// Recovery supersedes optional preparation for that child when the replacement
// arrives. Required availability work, including capacity reduction, keeps priority.
func (c *Controller) AdoptDirectConnection(input AdoptDirectConnectionInput, nowMs int64) SettleResult {
result := SettleResult{ActiveRevision: c.revision}
edge, _ := c.upstreamByViewer.Get(input.ChildPeerID)
if (c.operation != nil && c.operation.childPeerID == input.ChildPeerID) ||
c.revision != input.RouteRevision ||
if c.revision != input.RouteRevision ||
edge == nil ||
edge.Kind != UpstreamPeer ||
edge.Transport != TransportDirect ||
@@ -794,10 +794,17 @@ func (c *Controller) AdoptDirectConnection(input AdoptDirectConnectionInput) boo
edge.ParentSessionID != input.ParentSessionID ||
edge.ConnectionID != input.ConnectionID ||
input.NewConnectionID == "" {
return false
return result
}
if input.NewConnectionID == edge.ConnectionID {
return true
result.Accepted = true
return result
}
if op := c.operation; op != nil && op.childPeerID == input.ChildPeerID {
if isAvailabilityOperation(op.reason) {
return result
}
result.Released = c.abortOperation(&nowMs, RejectionAborted)
}
c.clearQualityForParticipant(input.ChildPeerID)
edge.ConnectionID = input.NewConnectionID
@@ -805,7 +812,9 @@ func (c *Controller) AdoptDirectConnection(input AdoptDirectConnectionInput) boo
child.availabilityExhausted = false
}
c.touchFacts()
return true
result.Accepted = true
result.ActiveRevision = c.revision
return result
}
// RetireHostPublication retires the physically active publication and its
+30 -1
View File
@@ -1127,7 +1127,7 @@ func TestClearsQualityShadowForRelaySubtreeWhenSourceChanges(t *testing.T) {
eq(t, routes.AdoptDirectConnection(AdoptDirectConnectionInput{
EdgeGuard: edgeGuard(A, "host_session", revision, "a_from_host"),
NewConnectionID: "a_from_host_recovered",
}), true)
}, 1_002).Accepted, true)
eq(t, withQuality(1_002), 0)
for _, step := range []struct {
@@ -1142,6 +1142,35 @@ func TestClearsQualityShadowForRelaySubtreeWhenSourceChanges(t *testing.T) {
eq(t, withQuality(3_101), 2)
}
func TestActiveRebuildRetiresBootstrapReservationWithoutLosingWaitingDemand(t *testing.T) {
routes := newController(2, Options{SfuEnabled: true})
for _, child := range []string{B, C} {
addViewer(routes, child, 0, nil)
routes.hydrateEdge(child, peerEdge(HOST, child+"_from_host"))
}
addViewer(routes, A, 0, ms(0))
bootstrap := must(t, routes.Reconcile(0).Operation)
eq(t, bootstrap.Reason, DemandSfuBootstrap)
retained := edgeOf(t, routes, bootstrap.ChildPeerID)
attempt := beginOperation(t, routes, BeginInput{
NowMs: 1, ConnectionID: "carrier_sfu", PublicationGeneration: "publication",
PublicationConnectionID: "publication_connection",
Reservation: sfuCreateOverlap("subscription", "publication", "overlap"),
})
replaced := routes.AdoptDirectConnection(AdoptDirectConnectionInput{
EdgeGuard: edgeGuard(bootstrap.ChildPeerID, "host_session", routes.Revision(), retained.ConnectionID),
NewConnectionID: "recovered_connection",
}, 2)
eq(t, replaced.Accepted, true)
eqLabels(t, replaced.Released, "subscription", "publication", "overlap")
noOperation(t, routes.Snapshot().Operation)
eq(t, edgeOf(t, routes, bootstrap.ChildPeerID).ConnectionID, "recovered_connection")
eq(t, routes.CandidateReady(guardFor(bootstrap.ChildPeerID, attempt.Current.Revision, "carrier_sfu"), 3, nil, CandidateProof{}).Accepted, false)
next := must(t, routes.Reconcile(4).Operation)
eq(t, next.Reason, DemandSfuBootstrap)
eq(t, next.DemandPeerID, A)
}
// TS 2569: retains one latest timing sample for a 20-Viewer burst
func TestRetainsOneLatestTimingSampleForTwentyViewerBurst(t *testing.T) {
routes := newController(2, Options{})
+7 -25
View File
@@ -2,7 +2,7 @@ package signal
// Viewer presence, host status, disconnect handling and room termination of
// src/server/signaling.ts (sendViewerPresence 1594, broadcastHostStatus
// 1683, handleDisconnect 1397, stopSharing 1466, terminateRoom 1500,
// 1683, handleDisconnect 1397, stopSharing 1466, closeRoom,
// revokeGrantViewers 1016, closeRevokedViewerSession 1697 and the
// viewer-keyed map helpers). Every function here runs with mu held.
@@ -123,8 +123,7 @@ func (s *Server) broadcastHostStatus(roomID string, online, paused bool) {
}
// handleDisconnect ports handleDisconnect. It runs once, from the reader
// goroutine's exit (D6). The host lease write is one of the two paths the TS
// left uncaught, so its failure panics (D7).
// goroutine's exit (D6).
func (s *Server) handleDisconnect(sess *session) {
if !s.sessions.Has(sess) {
return
@@ -149,7 +148,7 @@ func (s *Server) handleDisconnect(sess *session) {
disconnected, err := s.store.DisconnectParticipant(
sess.authenticated.roomID, sess.authenticated.peerID, sess.sessionID)
if err != nil {
panic(fmt.Errorf("room store write failed during host disconnect: %w", err))
panic(fmt.Errorf("room store failed during disconnect: %w", err))
}
if disconnected == nil {
return
@@ -215,26 +214,9 @@ func (s *Server) abandonRoom(roomID string) error {
return nil
}
// closeRoom ports closeRoom.
// closeRoom closes the host session first, then
// viewers in store order, each told room-closed then closed with 1000.
func (s *Server) closeRoom(closed room.ClosedRoom) {
s.terminateRoom(closed, "host-ended", "Room abandoned")
}
// expireRooms ports expireRooms, the cleanup timer body. Its store write is
// the second path the TS left uncaught (D7).
func (s *Server) expireRooms() {
expired, err := s.store.ExpireRooms(s.now())
if err != nil {
panic(fmt.Errorf("room store write failed while expiring rooms: %w", err))
}
for _, closed := range expired {
s.terminateRoom(closed, "expired", "Room expired")
}
}
// terminateRoom ports terminateRoom (O4: the host session first, then
// viewers in store order, each told room-closed then closed with 1000).
func (s *Server) terminateRoom(closed room.ClosedRoom, reason, closeText string) {
s.clearRoomGraceTimers(closed.RoomID)
s.clearRoomViewerEvidence(closed.RoomID)
s.router.deleteRoom(closed.RoomID)
@@ -244,8 +226,8 @@ func (s *Server) terminateRoom(closed room.ClosedRoom, reason, closeText string)
if sess == nil {
continue
}
s.send(sess, protocol.RoomClosedMessage{Type: "room-closed", Reason: reason})
sess.close(websocket.StatusNormalClosure, closeText)
s.send(sess, protocol.RoomClosedMessage{Type: "room-closed", Reason: "host-ended"})
sess.close(websocket.StatusNormalClosure, "Room abandoned")
}
}
+100
View File
@@ -0,0 +1,100 @@
package signal
import (
"strings"
"testing"
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
)
func TestSignalRebuildSupersedesOverlappingQualityPreparation(t *testing.T) {
for _, order := range []string{"prepare-before-restart", "restart-before-prepare"} {
t.Run(order, func(t *testing.T) {
h := startHarness(t, harnessOptions{endpointMediaCopyCapacity: 2})
host := openClient(t, h)
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "recovery-host", 1,
testShareGeneration, presenceOptions{routePolicy: qualityPolicy})
viewer := openClient(t, h)
viewerAuth := authenticate(t, viewer, h.room, protocol.RoleViewer, "recovery-viewer", 0, "", presenceOptions{})
commitPreparedRoute(t, viewer)
var active activeViewerMediaEdge
var hostSession, viewerSession string
h.locked(func() {
parent, _ := h.store.GetConnectedHost(h.room.RoomID)
child, _ := h.store.GetConnectedViewer(h.room.RoomID, viewerAuth.PeerID)
hostSession, viewerSession = parent.SessionID, child.SessionID
active, _ = h.server.router.resolveActiveViewerMediaEdge(h.room.RoomID, viewerAuth.PeerID)
})
restart := func() {
viewer.sendJSON(map[string]any{"type": "restart-request", "targetPeerId": hostAuth.PeerID,
"connectionId": active.connectionID, "rebuild": true})
expectMatch(t, host.next("restart-request").raw, `{"rebuild":true}`)
}
if order == "restart-before-prepare" {
restart()
}
h.locked(func() {
participant := authenticatedRouteParticipant{roomID: h.room.RoomID, role: protocol.RoleHost,
peerID: hostAuth.PeerID, sessionID: hostSession, routePolicy: qualityPolicy}
for index, state := range []string{"healthy", "degraded", "degraded", "degraded"} {
h.server.router.observeSenderQualityEvidence(participant, senderEvidenceMessage(
viewerAuth.PeerID, active.connectionID, "recovery-sender", "recovery-track",
float64(100+index), active.revision, state))
}
})
pending := nextPreparedRoute(t, viewer)
if !pending.Candidate.QualityProbe || pending.Assignment.Upstream != protocol.PeerUpstream(hostAuth.PeerID) {
t.Fatal("expected a same-parent quality preparation")
}
if !strings.HasPrefix(active.connectionID, candidateConnectionIDPrefix) ||
!strings.HasPrefix(pending.Candidate.ConnectionID, candidateConnectionIDPrefix) {
t.Fatal("prepared connections must remain distinguishable after retirement")
}
if order == "prepare-before-restart" {
restart()
}
// A same-parent probe must not intercept the retained connection's
// offer, answer or ICE. The probe's own signaling also stays valid.
host.sendJSON(signalDescription(viewerAuth.PeerID, active.connectionID, "offer"))
expectMatch(t, viewer.next("signal").raw, `{"payload":{"connectionId":"`+active.connectionID+`"}}`)
viewer.sendJSON(signalDescription(hostAuth.PeerID, active.connectionID, "answer"))
host.next("signal")
viewer.sendJSON(signalCandidate(hostAuth.PeerID, active.connectionID))
host.next("signal")
host.sendJSON(signalDescription(viewerAuth.PeerID, pending.Candidate.ConnectionID, "offer"))
expectMatch(t, viewer.next("signal").raw, `{"payload":{"connectionId":"`+pending.Candidate.ConnectionID+`"}}`)
const replacement = "manual_replacement_connection"
host.sendJSON(signalDescription(viewerAuth.PeerID, replacement, "offer"))
expectMatch(t, viewer.next("signal").raw, `{"payload":{"connectionId":"`+replacement+`"}}`)
settled := nextActiveRouteAfter(t, viewer, pending.Revision)
if settled.Assignment.Upstream != active.upstream {
t.Fatal("manual recovery must keep the committed parent")
}
viewer.sendJSON(signalDescription(hostAuth.PeerID, replacement, "answer"))
host.next("signal")
h.locked(func() {
rm, _ := h.server.router.rooms.Get(h.room.RoomID)
if rm.controller.Snapshot().Operation != nil {
t.Fatal("superseded preparation must release the serial operation")
}
for _, retired := range []string{pending.Candidate.ConnectionID, active.connectionID} {
authorization := h.server.router.peerSignalAuthorization(peerSignalInput{
roomID: h.room.RoomID, sourcePeerID: hostAuth.PeerID, sourceSessionID: hostSession,
targetPeerID: viewerAuth.PeerID, targetSessionID: viewerSession,
connectionID: retired, signalKind: "description", descriptionType: "offer",
})
if authorization != signalAuthorizationDenied {
t.Fatal("a late retired offer must not replace the recovered connection")
}
}
current, _ := h.server.router.resolveActiveViewerMediaEdge(h.room.RoomID, viewerAuth.PeerID)
if current.connectionID != replacement || current.revision != settled.Revision {
t.Fatalf("recovered edge = %+v", current)
}
})
})
}
}
+17 -5
View File
@@ -569,7 +569,12 @@ func (r *router) getViewerRouteUpstream(roomID, viewerPeerID string) protocol.Me
return assignmentFor(r.assignments(roomID, rm.controller.Snapshot(), nil, "", ""), viewerPeerID).Upstream
}
// peerSignalAuthorization ports peerSignalAuthorization.
// A retired server candidate must never be mistaken for a parent-initiated
// replacement. Its ID remains opaque to clients; no retired-ID history is needed.
const candidateConnectionIDPrefix = "route_"
// peerSignalAuthorization resolves candidate and retained media by connection,
// since both may belong to the same parent/child pair during overlap.
func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorization {
rm, _ := r.rooms.Get(input.roomID)
if rm == nil || rm.controller == nil {
@@ -586,7 +591,7 @@ func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorizat
input.targetPeerID == operation.ChildPeerID
childToParent := input.sourcePeerID == operation.ChildPeerID &&
input.targetPeerID == current.Tuple.ParentPeerID
if parentToChild || childToParent {
if (parentToChild || childToParent) && input.connectionID == current.ConnectionID {
parentSession, childSession := input.targetSessionID, input.sourceSessionID
if parentToChild {
parentSession, childSession = input.sourceSessionID, input.targetSessionID
@@ -594,7 +599,6 @@ func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorizat
if hasParent && hasChild &&
parent.SessionID == parentSession &&
child.SessionID == childSession &&
input.connectionID == current.ConnectionID &&
(input.signalKind == "candidate" ||
(parentToChild && input.descriptionType == "offer") ||
(childToParent && input.descriptionType == "answer")) {
@@ -627,6 +631,9 @@ func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorizat
input.signalKind == "description" &&
input.descriptionType == "offer" &&
input.connectionID != edge.ConnectionID {
if strings.HasPrefix(input.connectionID, candidateConnectionIDPrefix) {
return signalAuthorizationDenied
}
adopted := controller.AdoptDirectConnection(route.AdoptDirectConnectionInput{
EdgeGuard: route.EdgeGuard{
ChildPeerID: childPeerID,
@@ -636,8 +643,13 @@ func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorizat
ConnectionID: edge.ConnectionID,
},
NewConnectionID: input.connectionID,
})
if adopted {
}, r.now())
if adopted.Accepted {
r.releaseResources(adopted.Released)
if adopted.ActiveRevision != snapshot.Revision {
r.broadcastActive(input.roomID, rm)
r.requestPump(input.roomID)
}
return signalAuthorizationAllowed
}
return signalAuthorizationDenied
+1 -1
View File
@@ -308,7 +308,7 @@ func (r *router) prepareCandidate(
if !hasChild || child.SessionID != operation.ChildSessionID || shareGeneration == "" {
return denied(route.RejectionStale)
}
connectionID := opaqueID()
connectionID := candidateConnectionIDPrefix + opaqueID()
var overlap *route.Resource
if plan.EndpointTransition.Kind == route.TransitionOverlap {
producerPeerID := ""
+3 -5
View File
@@ -69,14 +69,13 @@ type routerHarness struct {
func newRouterHarness(t *testing.T, options routerHarnessOptions) *routerHarness {
t.Helper()
h := &routerHarness{t: t, sent: map[string][]protocol.ServerMessage{}}
storeOptions := room.Options{LeaseMs: 60_000, MaxRooms: 4, MaxViewersPerRoom: 20}
storeOptions := room.Options{MaxRooms: 4, MaxViewersPerRoom: 20}
var now func() int64
var afterFunc func(time.Duration, func()) func() bool
if options.fakeTimers {
h.clock = &manualClock{settle: h.settle}
now = h.clock.now
afterFunc = h.clock.afterFunc
storeOptions.Now = now
}
store, err := room.New(storeOptions)
if err != nil {
@@ -156,10 +155,9 @@ func (h *routerHarness) createRoom() room.CreatedRoom {
var created room.CreatedRoom
var err error
h.locked(func() {
var lease int64
lease, err = h.store.BeginCreateRoom()
err = h.store.BeginCreateRoom()
if err == nil {
created, err = h.store.CreateRoom(protocol.CodeEntryOpen, nil, nil, "", lease)
created, err = h.store.CreateRoom(protocol.CodeEntryOpen, nil, nil, "")
}
})
if err != nil {
+10 -37
View File
@@ -58,7 +58,6 @@ const (
defaultAuthenticationTimeoutMs = 5_000
defaultViewerDisconnectGraceMs = 5_000
defaultHeartbeatIntervalMs = 30_000
defaultCleanupIntervalMs = 30_000
)
// SfuFallback is the embedded media runtime the media router
@@ -91,7 +90,6 @@ type Options struct {
AuthenticationTimeoutMs int
ViewerDisconnectGraceMs int
HeartbeatIntervalMs int
CleanupIntervalMs int
MaxConnections int
MaxUnauthenticatedConnections int
// AfterFunc is the timer factory (D5); nil uses time.AfterFunc.
@@ -138,7 +136,7 @@ type viewerQualityEvidenceAttempt struct {
// (authenticate falls back to the session id), which is what made the TS
// `undefined === undefined` comparisons meaningful.
type roomShare struct {
// generation survives stopSharing; only terminateRoom drops the whole
// generation survives stopSharing; only closeRoom drops the whole
// record (hazard 2).
generation string
// qualitySettings and routePolicy are nil where the TS map had no entry;
@@ -182,7 +180,6 @@ type Server struct {
authenticationTimeoutMs int
viewerDisconnectGraceMs int
heartbeatIntervalMs int
cleanupIntervalMs int
maxConnections int
maxUnauthenticatedConnections int
@@ -208,12 +205,11 @@ type Server struct {
deferredViewerPresenceRooms map[string]struct{}
heartbeatStop func() bool
cleanupStop func() bool
closing bool
}
// New is the SignalingServer constructor. It performs no I/O; the heartbeat
// and cleanup tickers start immediately.
// ticker starts immediately.
func New(options Options) (*Server, error) {
if err := protocol.AssertEndpointMediaCopyCapacity(options.EndpointMediaCopyCapacity); err != nil {
return nil, err
@@ -239,7 +235,6 @@ func New(options Options) (*Server, error) {
authenticationTimeoutMs: orDefault(options.AuthenticationTimeoutMs, defaultAuthenticationTimeoutMs),
viewerDisconnectGraceMs: orDefault(options.ViewerDisconnectGraceMs, defaultViewerDisconnectGraceMs),
heartbeatIntervalMs: orDefault(options.HeartbeatIntervalMs, defaultHeartbeatIntervalMs),
cleanupIntervalMs: orDefault(options.CleanupIntervalMs, defaultCleanupIntervalMs),
maxConnections: orDefault(options.MaxConnections, defaultMaxSignalConnections),
maxUnauthenticatedConnections: orDefault(options.MaxUnauthenticatedConnections, defaultMaxUnauthenticatedConnections),
sessionsByID: map[string]*session{},
@@ -295,7 +290,6 @@ func New(options Options) (*Server, error) {
s.router = mediaRouter
s.mu.Lock()
s.armHeartbeat()
s.armCleanup()
s.mu.Unlock()
return s, nil
}
@@ -449,9 +443,6 @@ func (s *Server) Close(ctx context.Context) error {
if s.heartbeatStop != nil {
s.heartbeatStop()
}
if s.cleanupStop != nil {
s.cleanupStop()
}
for key, timer := range s.viewerGraceTimers {
timer.stop()
delete(s.viewerGraceTimers, key)
@@ -639,8 +630,7 @@ func (s *Server) CreateRoom(
) (room.CreatedRoom, error) {
s.mu.Lock()
defer s.mu.Unlock()
lease, err := s.store.BeginCreateRoom()
if err != nil {
if err := s.store.BeginCreateRoom(); err != nil {
return room.CreatedRoom{}, err
}
var material []byte
@@ -652,7 +642,7 @@ func (s *Server) CreateRoom(
material, derived = s.store.DeriveViewerPasswordMaterial(*roomPassword, nil)
}()
}
return s.store.CreateRoom(codeEntryPolicy, material, derived, preferredRoomID, lease)
return s.store.CreateRoom(codeEntryPolicy, material, derived, preferredRoomID)
}
// armHeartbeat is the heartbeat setInterval (T1): each tick re-arms the
@@ -674,19 +664,6 @@ func (s *Server) armHeartbeat() {
})
}
// armCleanup is the expireRooms setInterval (T2).
func (s *Server) armCleanup() {
s.cleanupStop = s.afterFunc(time.Duration(s.cleanupIntervalMs)*time.Millisecond, func() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closing {
return
}
s.expireRooms()
s.armCleanup()
})
}
// ---------------------------------------------------------------------------
// messages
// ---------------------------------------------------------------------------
@@ -911,7 +888,7 @@ func (s *Server) completeAuthentication(sess *session, request authRequest, part
if s.closing || !s.sessions.Has(sess) || sess.revoked || !sess.open() {
if _, err := s.store.DisconnectParticipant(participant.RoomID, participant.PeerID, sess.sessionID); err != nil {
// The TS threw out of the async function; there is no catcher (D7).
panic(fmt.Errorf("room store write failed while releasing an aborted authentication: %w", err))
panic(fmt.Errorf("room store failed while releasing an aborted authentication: %w", err))
}
return
}
@@ -985,7 +962,6 @@ func (s *Server) completeAuthentication(sess *session, request authRequest, part
Type: "authenticated",
Protocol: protocol.SignalingProtocol,
PeerID: participant.PeerID,
RoomExpiresAt: participant.ExpiresAt,
MaxViewers: protocol.Int(s.store.MaxViewersPerRoom()),
EndpointMediaCopyCapacity: protocol.Int(s.endpointMediaCopyCapacity),
HostOnline: participant.HostOnline,
@@ -1009,7 +985,6 @@ func (s *Server) completeAuthentication(sess *session, request authRequest, part
Type: "authenticated",
Protocol: protocol.SignalingProtocol,
PeerID: participant.PeerID,
RoomExpiresAt: participant.ExpiresAt,
MaxViewers: protocol.Int(s.store.MaxViewersPerRoom()),
EndpointMediaCopyCapacity: protocol.Int(s.endpointMediaCopyCapacity),
HostOnline: participant.HostOnline,
@@ -1306,7 +1281,7 @@ func (s *Server) handleAuthenticatedMessage(sess *session, authenticated *authen
if s.sessions.Has(sess) && sess.authenticated == authenticated {
if _, err := s.store.DisconnectParticipant(authenticated.roomID, authenticated.peerID, sess.sessionID); err != nil {
// The TS threw out of the message handler; nothing caught it (D7).
panic(fmt.Errorf("room store write failed while stopping sharing: %w", err))
panic(fmt.Errorf("room store failed while stopping sharing: %w", err))
}
}
s.stopSharing(authenticated.roomID)
@@ -1643,15 +1618,13 @@ func authenticationErrorMessage(code string) string {
case "ROOM_ACCESS_DENIED":
return "Room access denied"
case "ROOM_NOT_FOUND":
return "Room not found or expired"
case "ROOM_EXPIRED":
return "Room has expired"
return "Room not found"
case "ROOM_FULL":
return "Room is full"
case "HOST_ALREADY_CONNECTED":
return "A host is already connected"
case "INVALID_TOKEN":
return "Room is invalid or expired"
return "Room credentials are invalid"
}
return "Authentication failed"
}
@@ -1672,12 +1645,12 @@ func authenticationErrorCode(request authRequest, err error) string {
return string(roomError.Code)
}
if request.viewerGrant == "" {
if roomError.Code == room.CodeRoomNotFound || roomError.Code == room.CodeRoomExpired {
if roomError.Code == room.CodeRoomNotFound {
return "ROOM_NOT_FOUND"
}
return "ROOM_ACCESS_DENIED"
}
if roomError.Code == room.CodeRoomExpired || roomError.Code == room.CodeRoomNotFound {
if roomError.Code == room.CodeRoomNotFound {
return "INVALID_TOKEN"
}
return string(roomError.Code)
+56 -115
View File
@@ -48,7 +48,7 @@ func TestCreateRoomReleasesLockWhenDerivationOrCommitPanics(t *testing.T) {
for _, password := range []string{"", "room-password"} {
t.Run(password, func(t *testing.T) {
store, err := room.New(room.Options{
LeaseMs: 86_400_000, MaxRooms: 1, MaxViewersPerRoom: 1,
MaxRooms: 1, MaxViewersPerRoom: 1,
Random: func(int) []byte { panic("test random source") },
})
if err != nil {
@@ -71,46 +71,27 @@ func TestCreateRoomReleasesLockWhenDerivationOrCommitPanics(t *testing.T) {
}
}
func TestIncomingStorageFailureReleasesLockWithoutSuppressingPanic(t *testing.T) {
func TestStopSharingDoesNotWriteRoomAuthority(t *testing.T) {
database, err := room.NewDatabase(filepath.Join(t.TempDir(), "rooms.sqlite"))
if err != nil {
t.Fatal(err)
}
store := newStore(t, 1, nil, database)
defer store.Close()
created := createStoreRoom(t, store, protocol.CodeEntryOpen, "")
sess := newSession(nil, true)
defer sess.cancel()
participant, err := store.ConnectParticipant(room.ConnectParticipantInput{
RoomID: created.RoomID, Role: protocol.RoleHost, Token: created.HostToken,
ClientID: "failed-storage-host", SessionID: sess.sessionID,
store := newStore(t, 1, database)
h := startHarness(t, harnessOptions{store: store})
host := openClient(t, h)
authenticated := authenticate(t, host, h.room, protocol.RoleHost, "storage-free-host", 1, "", presenceOptions{})
h.locked(func() {
if err := database.Close(); err != nil {
t.Fatal(err)
}
})
if err != nil {
t.Fatal(err)
host.sendJSON(map[string]any{"type": "stop-sharing", "shareGeneration": authenticated.ShareGeneration})
expectClose(t, host, 1000, "Sharing stopped")
if h.storeSize() != 1 {
t.Fatal("stopping sharing removed room authority")
}
const generation = "failed_storage_share_generation"
sess.authenticated = &authenticatedSession{roomID: created.RoomID, role: protocol.RoleHost,
peerID: participant.PeerID, shareGeneration: generation}
server := &Server{store: store, shares: map[string]roomShare{created.RoomID: {generation: generation}}}
server.sessions.Set(sess, struct{}{})
if err = database.Close(); err != nil {
t.Fatal(err)
}
func() {
defer func() {
failure, ok := recover().(error)
if !ok || !strings.Contains(failure.Error(), "room store write failed while stopping sharing") {
t.Errorf("handler did not preserve its storage panic: %v", failure)
}
}()
server.handleIncoming(sess, websocket.MessageText,
[]byte(`{"type":"stop-sharing","shareGeneration":"`+generation+`"}`))
}()
if !server.mu.TryLock() {
server.mu.Unlock() // Allow test cleanup if the regression returns.
t.Fatal("storage panic left the message lock held before reader cleanup")
}
server.mu.Unlock()
resumed := openClient(t, h)
authenticate(t, resumed, h.room, protocol.RoleHost, "storage-free-host", 1, "", presenceOptions{})
}
// ---------------------------------------------------------------------------
@@ -128,7 +109,6 @@ type harnessOptions struct {
stunURLs []string
natPredictionEnabled bool
now func() int64
cleanupIntervalMs int
store *room.Store
room *room.CreatedRoom
afterFunc func(time.Duration, func()) func() bool
@@ -152,13 +132,11 @@ func orValue(value, fallback int) int {
return value
}
func newStore(t *testing.T, maxViewersPerRoom int, now func() int64, database *room.Database) *room.Store {
func newStore(t *testing.T, maxViewersPerRoom int, database *room.Database) *room.Store {
t.Helper()
store, err := room.New(room.Options{
LeaseMs: 86_400_000,
MaxRooms: room.Capacity,
MaxViewersPerRoom: maxViewersPerRoom,
Now: now,
Database: database,
})
if err != nil {
@@ -175,8 +153,7 @@ func newStore(t *testing.T, maxViewersPerRoom int, now func() int64, database *r
// createStoreRoom is roomStore.createRoom() on a store no server guards yet.
func createStoreRoom(t *testing.T, store *room.Store, policy protocol.CodeEntryPolicy, password string) room.CreatedRoom {
t.Helper()
lease, err := store.BeginCreateRoom()
if err != nil {
if err := store.BeginCreateRoom(); err != nil {
t.Fatalf("BeginCreateRoom: %v", err)
}
var material []byte
@@ -184,7 +161,7 @@ func createStoreRoom(t *testing.T, store *room.Store, policy protocol.CodeEntryP
if password != "" {
material, derived = store.DeriveViewerPasswordMaterial(password, nil)
}
created, err := store.CreateRoom(policy, material, derived, "", lease)
created, err := store.CreateRoom(policy, material, derived, "")
if err != nil {
t.Fatalf("CreateRoom: %v", err)
}
@@ -196,7 +173,7 @@ func startHarness(t *testing.T, options harnessOptions) *harness {
maxViewersPerRoom := orValue(options.maxViewersPerRoom, 8)
store := options.store
if store == nil {
store = newStore(t, maxViewersPerRoom, options.now, nil)
store = newStore(t, maxViewersPerRoom, nil)
}
var created room.CreatedRoom
if options.room != nil {
@@ -227,7 +204,6 @@ func startHarness(t *testing.T, options harnessOptions) *harness {
AuthenticationTimeoutMs: orValue(options.authenticationTimeoutMs, 500),
ViewerDisconnectGraceMs: orValue(options.viewerDisconnectGraceMs, 50),
HeartbeatIntervalMs: 60_000,
CleanupIntervalMs: orValue(options.cleanupIntervalMs, 60_000),
MaxConnections: options.maxSignalConnections,
MaxUnauthenticatedConnections: options.maxUnauthenticatedSignalConnections,
AfterFunc: options.afterFunc,
@@ -1163,7 +1139,7 @@ func TestSignalRebuildsRouteWhenViewerReconnectsBeforeHostAfterRestart(t *testin
if err != nil {
t.Fatal(err)
}
firstStore := newStore(t, 8, nil, firstDatabase)
firstStore := newStore(t, 8, firstDatabase)
rm := createStoreRoom(t, firstStore, protocol.CodeEntryOpen, "")
first := startHarness(t, harnessOptions{store: firstStore, room: &rm})
firstHost := openClient(t, first)
@@ -1178,7 +1154,7 @@ func TestSignalRebuildsRouteWhenViewerReconnectsBeforeHostAfterRestart(t *testin
if err != nil {
t.Fatal(err)
}
secondStore := newStore(t, 8, nil, secondDatabase)
secondStore := newStore(t, 8, secondDatabase)
second := startHarness(t, harnessOptions{store: secondStore, room: &rm})
secondViewer := openClient(t, second)
viewerAuth := authenticate(t, secondViewer, rm, protocol.RoleViewer, "restart-route-viewer", 1, "", presenceOptions{})
@@ -1913,11 +1889,11 @@ func TestSignalDistinguishesMissingRoomsFromExistingRoomCodeOnlyDenials(t *testi
})
protectedRoom := h.createRoom(protocol.CodeEntryPrivate, "correct-password")
expiring := h.createRoom(protocol.CodeEntryOpen, "")
abandoned := h.createRoom(protocol.CodeEntryOpen, "")
unusedRoomID := ""
for code := 1_000; code < 10_000; code++ {
candidate := fmt.Sprint(code)
if candidate != h.room.RoomID && candidate != protectedRoom.RoomID && candidate != expiring.RoomID {
if candidate != h.room.RoomID && candidate != protectedRoom.RoomID && candidate != abandoned.RoomID {
unusedRoomID = candidate
break
}
@@ -1936,7 +1912,7 @@ func TestSignalDistinguishesMissingRoomsFromExistingRoomCodeOnlyDenials(t *testi
sendCodeOnlyAuthenticate(client, roomID, clientID, extra)
message := "Room access denied"
if code == "ROOM_NOT_FOUND" {
message = "Room not found or expired"
message = "Room not found"
}
expectEqual(t, client.next("error").raw, fmt.Sprintf(`{"type":"error","code":%q,"message":%q}`, code, message))
expectClose(t, client, 4003, "Authentication failed")
@@ -1947,85 +1923,50 @@ func TestSignalDistinguishesMissingRoomsFromExistingRoomCodeOnlyDenials(t *testi
expectDenial(protectedRoom.RoomID, "wrong-password-viewer", "ROOM_ACCESS_DENIED", "wrong-password")
expectDenial(h.room.RoomID, "full-room-viewer", "ROOM_ACCESS_DENIED", "")
now.advance(86_400_001)
expectDenial(expiring.RoomID, "expired-room-viewer", "ROOM_NOT_FOUND", "")
expiredGrant := openClientWithCookie(t, h, siteAccessCookie)
sendCodeOnlyAuthenticate(expiredGrant, expiring.RoomID, "expired-grant-viewer",
map[string]any{"viewerGrant": expiring.ViewerGrant})
expectMatch(t, expiredGrant.next("error").raw, `{"code":"INVALID_TOKEN"}`)
}
// ---------------------------------------------------------------------------
// room lease and expiry
// ---------------------------------------------------------------------------
func TestSignalDoesNotExpireActiveRoomAndArmsItsLeaseAfterHostDisconnect(t *testing.T) {
now := newClock(utcMillis(2026, time.August, 20, 12))
h := startHarness(t, harnessOptions{now: now.now})
host := openClient(t, h)
authenticated := authenticate(t, host, h.room, protocol.RoleHost, "active-lease-host", 1, "", presenceOptions{})
viewer := openClient(t, h)
authenticate(t, viewer, h.room, protocol.RoleViewer, "provisional-viewer", 1, "", presenceOptions{})
commitPreparedRoute(t, viewer)
now.advance(86_401_000)
expireAt := func(nowMs int64) []room.ClosedRoom {
t.Helper()
var expired []room.ClosedRoom
var err error
h.locked(func() { expired, err = h.store.ExpireRooms(nowMs) })
if err != nil {
t.Fatalf("ExpireRooms: %v", err)
h.locked(func() {
if _, err := h.store.AbandonRoom(abandoned.RoomID); err != nil {
t.Fatal(err)
}
return expired
}
if expired := expireAt(now.now()); len(expired) != 0 {
t.Fatalf("active room expired: %v", expired)
}
h.closeClient(host)
viewer.next("host-status")
var hostConnected bool
h.locked(func() { _, hostConnected = h.store.GetConnectedHost(h.room.RoomID) })
expectTrue(t, "host must be disconnected", !hostConnected)
if expired := expireAt(now.now() + 86_399_999); len(expired) != 0 {
t.Fatalf("room expired before its lease: %v", expired)
}
expired := expireAt(now.now() + 86_401_000)
if len(expired) != 1 || expired[0].RoomID != h.room.RoomID || len(expired[0].SessionIDs) != 1 {
t.Fatalf("expired rooms: %+v", expired)
}
h.closeClient(viewer)
expectString(t, "role", authenticated.Role, protocol.RoleHost)
})
expectDenial(abandoned.RoomID, "abandoned-room-viewer", "ROOM_NOT_FOUND", "")
abandonedGrant := openClientWithCookie(t, h, siteAccessCookie)
sendCodeOnlyAuthenticate(abandonedGrant, abandoned.RoomID, "abandoned-grant-viewer",
map[string]any{"viewerGrant": abandoned.ViewerGrant})
expectMatch(t, abandonedGrant.next("error").raw, `{"code":"INVALID_TOKEN"}`)
}
func TestSignalTerminatesExpiredDormantRoomThroughCleanupTimer(t *testing.T) {
// ---------------------------------------------------------------------------
// room lifetime
// ---------------------------------------------------------------------------
func TestSignalRetainsDormantAuthorityAcrossLongHostAbsence(t *testing.T) {
now := newClock(utcMillis(2026, time.August, 20, 12))
h := startHarness(t, harnessOptions{now: now.now, cleanupIntervalMs: 20})
h := startHarness(t, harnessOptions{now: now.now})
now.advance(30 * 24 * 60 * 60 * 1_000)
host := openClient(t, h)
authenticate(t, host, h.room, protocol.RoleHost, "expiring-host", 1, "", presenceOptions{})
authenticate(t, host, h.room, protocol.RoleHost, "returning-host", 1, "", presenceOptions{})
viewer := openClient(t, h)
authenticate(t, viewer, h.room, protocol.RoleViewer, "expiring-viewer", 1, "", presenceOptions{})
commitPreparedRoute(t, viewer)
authenticate(t, viewer, h.room, protocol.RoleViewer, "waiting-viewer", 1, "", presenceOptions{})
h.closeClient(host)
viewer.next("host-status")
now.advance(86_401_000)
expectEqual(t, viewer.next("room-closed").raw, `{"type":"room-closed","reason":"expired"}`)
expectClose(t, viewer, 1000, "Room expired")
if size := h.storeSize(); size != 0 {
t.Fatalf("store size %d", size)
now.advance(30 * 24 * 60 * 60 * 1_000)
resumed := openClient(t, h)
authenticate(t, resumed, h.room, protocol.RoleHost, "returning-host", 1, "", presenceOptions{})
lateViewer := openClient(t, h)
authenticate(t, lateViewer, h.room, protocol.RoleViewer, "late-viewer", 1, "", presenceOptions{})
if h.storeSize() != 1 || viewer.isClosed() {
t.Fatal("host absence retired the room or its Viewer")
}
}
func TestSignalKeepsPreHostViewerSessionCurrentAfterHostClearsRoomLease(t *testing.T) {
func TestSignalKeepsPreHostViewerSessionCurrentAfterHostConnects(t *testing.T) {
now := newClock(utcMillis(2026, time.August, 20, 12))
h := startHarness(t, harnessOptions{now: now.now})
viewer := openClient(t, h)
authenticate(t, viewer, h.room, protocol.RoleViewer, "pre-host-lease-viewer", 1, "", presenceOptions{displayName: "Before"})
authenticate(t, viewer, h.room, protocol.RoleViewer, "pre-host-viewer", 1, "", presenceOptions{displayName: "Before"})
host := openClient(t, h)
authenticate(t, host, h.room, protocol.RoleHost, "pre-host-lease-host", 1, "", presenceOptions{viewerPresence: true})
authenticate(t, host, h.room, protocol.RoleHost, "pre-host-host", 1, "", presenceOptions{viewerPresence: true})
named := func(name string) func(presenceMessage) bool {
return func(message presenceMessage) bool {
return slices.ContainsFunc(viewerPresenceEntries(message), func(entry presenceEntry) bool {
@@ -2055,7 +1996,7 @@ func TestSignalLeavesEstablishedAuthorizationAndMediaUntouchedWhenGrantUpdateFai
if err != nil {
t.Fatal(err)
}
store := newStore(t, 8, nil, database)
store := newStore(t, 8, database)
h := startHarness(t, harnessOptions{store: store})
host := openClient(t, h)
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "persistence-failure-host", 1, "", presenceOptions{})
@@ -2676,7 +2617,7 @@ func (m *timerQueue) fireWhere(due func(time.Duration) bool) {
func TestSignalHeartbeatTerminatesSilentConnectionAfterMissedPong(t *testing.T) {
timers := &timerQueue{}
h := startHarness(t, harnessOptions{afterFunc: timers.afterFunc, cleanupIntervalMs: 30_000})
h := startHarness(t, harnessOptions{afterFunc: timers.afterFunc})
heartbeat := 60_000 * time.Millisecond
responsive := openClient(t, h)
header := http.Header{}
+1
View File
@@ -261,6 +261,7 @@ async function main(): Promise<void> {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
SCREENER_ENV: "production",
ROOM_DATABASE_PATH: ":memory:",
PORT: String(serverPort),
LISTEN_HOST: "127.0.0.1",
// Production requires an https public origin. The browser reaches the
+1
View File
@@ -127,6 +127,7 @@ async function startRoomServer(
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
SCREENER_ENV: "development",
ROOM_DATABASE_PATH: ":memory:",
PORT: String(port),
LISTEN_HOST: "127.0.0.1",
PUBLIC_BASE_URL: `http://${LAN_ADDRESS}:${port}`,
@@ -32,6 +32,7 @@ export function CaptureSourcePicker({
onRefresh,
onCancel,
browserAvailable = true,
initialTab = "window",
initialAudio = true,
audioLocked = false,
}: {
@@ -45,12 +46,13 @@ export function CaptureSourcePicker({
onRefresh: () => void;
onCancel: () => void;
browserAvailable?: boolean;
initialTab?: SourceTab;
initialAudio?: boolean;
audioLocked?: boolean;
}) {
const { vis, t } = useCopy();
const pickerId = useId();
const [tab, setTab] = useState<SourceTab>("window");
const [tab, setTab] = useState<SourceTab>(initialTab);
const [shareAudio, setShareAudio] = useState(initialAudio);
const activeTab = tab === "browser" && !browserAvailable ? "window" : tab;
+2 -12
View File
@@ -17,9 +17,7 @@ const hostRoomStorageSchema = createRoomResponseSchema.pick({
roomId: true,
hostToken: true,
inviteUrl: true,
expiresAt: true,
roomLeaseSeconds: true,
});
}).strip();
const hostRoomPreferenceSchema = z
.object({
roomId: roomCodeSchema,
@@ -28,7 +26,7 @@ const hostRoomPreferenceSchema = z
export type HostRoomIdentity = Pick<
CreateRoomResponse,
"roomId" | "hostToken" | "expiresAt" | "roomLeaseSeconds"
"roomId" | "hostToken"
> & { canonicalUrl: string };
export interface HostRoomState extends HostRoomIdentity {
@@ -197,8 +195,6 @@ function readStoredHostRoom(
const room: HostRoomIdentity = {
roomId: parsed.data.roomId,
hostToken: parsed.data.hostToken,
expiresAt: parsed.data.expiresAt,
roomLeaseSeconds: parsed.data.roomLeaseSeconds,
canonicalUrl: canonicalViewerUrl(parsed.data.inviteUrl),
};
return room;
@@ -352,8 +348,6 @@ export async function writeHostRoom(
const identity: HostRoomIdentity = {
roomId: room.roomId,
hostToken: room.hostToken,
expiresAt: room.expiresAt,
roomLeaseSeconds: room.roomLeaseSeconds,
canonicalUrl:
"canonicalUrl" in room
? room.canonicalUrl
@@ -383,8 +377,6 @@ function storeHostRoom(
JSON.stringify({
roomId: room.roomId,
hostToken: room.hostToken,
expiresAt: room.expiresAt,
roomLeaseSeconds: room.roomLeaseSeconds,
inviteUrl: room.canonicalUrl,
}),
);
@@ -495,7 +487,6 @@ export function readViewerGrant(roomId: string): string | null {
export function mergeAuthenticatedHostRoom(
current: HostRoomState | null,
activeRoomId: string,
roomExpiresAt: string | null,
codeEntryPolicy: CodeEntryPolicy,
): HostRoomState | null {
if (!current || current.roomId !== activeRoomId) {
@@ -514,7 +505,6 @@ export function mergeAuthenticatedHostRoom(
return {
...current,
expiresAt: roomExpiresAt,
codeEntryPolicy,
inviteUrl,
};
-1
View File
@@ -38,7 +38,6 @@ const FATAL_SIGNAL_ERRORS = new Set([
"INVALID_TOKEN",
"ROOM_NOT_FOUND",
"ROOM_ACCESS_DENIED",
"ROOM_EXPIRED",
"ROOM_FULL",
"HOST_ALREADY_CONNECTED",
]);
+4 -7
View File
@@ -101,7 +101,7 @@ export const en: Record<CopyKey, string> = {
"host.resumeNotice": "Audio and video sharing resumed",
"host.stopNotice": "Sharing stopped",
"host.roomReplaced": "Room code replaced",
"host.roomInvalid": "Room expired; press again to create a new one",
"host.roomInvalid": "Room no longer available; press again to create a new one",
"host.roomReplace": "Replace room code",
"host.roomReplaceConfirm": "Confirm replacing the room code",
"host.terminated.stale": "Page updated; refresh to continue",
@@ -279,10 +279,9 @@ export const en: Record<CopyKey, string> = {
"viewer.msg.playbackFailed": "The browser cannot play this stream",
"viewer.msg.hostOffline": "Host connection lost",
"viewer.msg.serverError": "Connection service unavailable",
"viewer.msg.notFound": "Room not found or expired",
"viewer.msg.notFound": "Room not found",
"viewer.msg.denied": "This room cannot be joined by code right now",
"viewer.msg.invalidInvite": "Invite link invalid or expired",
"viewer.msg.expired": "Room expired",
"viewer.msg.invalidInvite": "Invite link is no longer valid",
"viewer.msg.closed": "Room closed",
"viewer.msg.full": "Cannot join the room right now",
"viewer.msg.stale": "Page updated; refresh to continue",
@@ -327,7 +326,6 @@ export const en: Record<CopyKey, string> = {
"host.captureAria": "Actual capture parameters",
"host.quality.title": "{label} · {mbps} Mbps",
"host.audio.title": "{label} · {kbps} kbps ceiling",
"host.roomExpired": "Room expired",
"host.roomClosed": "Room closed",
"host.pause.noTracksResume": "No resumable media tracks in the current share",
"host.pause.signalRecovering": "Server reconnecting; sharing stays paused",
@@ -343,9 +341,8 @@ export const en: Record<CopyKey, string> = {
"host.fail.quality": "Failed to apply quality settings",
"host.fail.connection": "Viewer connection handling failed",
"host.fail.room": "Room operation failed",
"host.err.invalidToken": "Share credentials expired; create the room again",
"host.err.invalidToken": "Share credentials are no longer valid; create the room again",
"host.err.accessDenied": "Not permitted",
"host.err.roomExpired": "Room expired; create it again",
"host.err.alreadyConnected": "This room is already shared in another page",
"host.err.peerGone": "The viewer connection has left",
"host.err.forbidden": "Action unavailable",
+1 -4
View File
@@ -277,10 +277,9 @@ export const zh = {
"viewer.msg.playbackFailed": "浏览器无法播放当前画面",
"viewer.msg.hostOffline": "分享者连接已中断",
"viewer.msg.serverError": "连接服务暂时不可用",
"viewer.msg.notFound": "房间不存在或已过期",
"viewer.msg.notFound": "房间不存在",
"viewer.msg.denied": "当前无法通过房间号加入",
"viewer.msg.invalidInvite": "邀请链接无效或已失效",
"viewer.msg.expired": "房间已过期",
"viewer.msg.closed": "房间已关闭",
"viewer.msg.full": "当前无法加入房间",
"viewer.msg.stale": "页面版本已更新,请刷新后重试",
@@ -325,7 +324,6 @@ export const zh = {
"host.captureAria": "实际捕获参数",
"host.quality.title": "{label} · {mbps} Mbps",
"host.audio.title": "{label} · {kbps} kbps 上限",
"host.roomExpired": "房间已过期",
"host.roomClosed": "房间已关闭",
"host.pause.noTracksResume": "当前分享没有可恢复的媒体轨道",
"host.pause.signalRecovering": "服务器连接正在恢复,分享仍保持暂停",
@@ -343,7 +341,6 @@ export const zh = {
"host.fail.room": "房间操作失败",
"host.err.invalidToken": "分享凭证已失效,请重新创建房间",
"host.err.accessDenied": "当前操作没有权限",
"host.err.roomExpired": "房间已过期,请重新创建",
"host.err.alreadyConnected": "此房间已在另一个页面中分享",
"host.err.peerGone": "对应的观看连接已经离开",
"host.err.forbidden": "当前操作不可用",
-6
View File
@@ -16,7 +16,6 @@ export type ViewerFailureCode =
| "ROOM_ACCESS_DENIED"
| "INVALID_TOKEN"
| "ROOM_CLOSED"
| "ROOM_EXPIRED"
| "ROOM_FULL"
| "STALE_CLIENT"
| "SERVER_ERROR"
@@ -37,7 +36,6 @@ export type ViewerStage =
| "access-denied"
| "invalid-invite"
| "room-closed"
| "room-expired"
| "room-full"
| "stale-client"
| "server-error"
@@ -422,8 +420,6 @@ export function deriveViewerPresentation(
"blocking",
state,
);
case "ROOM_EXPIRED":
return presentation("room-expired", "viewer.msg.expired", "blocking", state);
case "ROOM_CLOSED":
return presentation("room-closed", "viewer.msg.closed", "blocking", state);
case "ROOM_FULL":
@@ -629,8 +625,6 @@ export function viewerFailureFromServerCode(
case "INVALID_TOKEN":
case "AUTH_REQUIRED":
return "INVALID_TOKEN";
case "ROOM_EXPIRED":
return "ROOM_EXPIRED";
case "ROOM_FULL":
return "ROOM_FULL";
case "SERVER_ERROR":
+72 -46
View File
@@ -78,7 +78,7 @@ import {
type HostCreationProfile,
} from "../lib/creation-profile";
import { createOpaqueId } from "../lib/opaque-id";
import { debugEvent, debugOperation } from "../lib/debug";
import { debugError, debugEvent, debugOperation } from "../lib/debug";
import {
defaultHostDisplayName,
readDisplayName,
@@ -384,8 +384,6 @@ function hostRoomFromCreated(room: CreateRoomResponse): HostRoomState {
return {
roomId: room.roomId,
hostToken: room.hostToken,
expiresAt: room.expiresAt,
roomLeaseSeconds: room.roomLeaseSeconds,
canonicalUrl: canonicalUrl.toString(),
codeEntryPolicy: room.codeEntryPolicy,
inviteUrl: room.inviteUrl,
@@ -964,6 +962,7 @@ export function HostPage({
}
roomMutationRef.current = null;
setRoomMutation(null);
releaseUnusedNativeClient();
}
function endSharing(
@@ -1036,8 +1035,8 @@ export function HostPage({
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
if (!wasSharing) {
// A lease can disappear while the host is idle (for example after a
// server restart). Creation is the canonical recovery path; do not
// A memory-only room can disappear after a server restart.
// Creation is the canonical recovery path; do not
// clear the visible room before it succeeds.
try {
const response = await createRoom(
@@ -1249,8 +1248,10 @@ export function HostPage({
let bridge: NativeMediaBridge | null = null;
let shareStarted = false;
try {
await nativeShareCleanupRef.current;
await nativePreviewTailRef.current;
if (!isCurrentShare(generation, shareGeneration)) return null;
if (nativeClientRef.current !== client) throw new Error("Screener Client is unavailable");
const started = await client.startShare({
shareId: shareGeneration,
source: target,
@@ -1262,6 +1263,11 @@ export function HostPage({
codec: videoCodecModeRef.current,
});
shareStarted = true;
if (!isCurrentShare(generation, shareGeneration)) {
await client.stopShare(shareGeneration).catch(() => discardNativeClient(client));
return null;
}
if (nativeClientRef.current !== client) throw new Error("Screener Client is unavailable");
videoCodecRef.current = manualVideoCodecPreference(started.codec);
bridge = new NativeMediaBridge(
shareGeneration,
@@ -1300,7 +1306,8 @@ export function HostPage({
nativeEventCleanupRef.current = nativeEventCleanup;
const stream = await bridge.start();
if (!isCurrentShare(generation, shareGeneration)) {
disposeNativeShare();
if (nativeMediaBridgeRef.current === bridge) disposeNativeShare();
else bridge.dispose();
return null;
}
return stream;
@@ -1331,7 +1338,12 @@ export function HostPage({
nativeClientCloseCleanupRef.current = client.onClose(() => {
if (nativeClientRef.current !== client) return;
nativeClientRef.current = null;
nativeClientConnectRef.current = null;
nativeClientCloseCleanupRef.current = null;
if (nativeSourceRequestRef.current) {
nativeSourcePathRef.current = null;
setNativeSources({ kind: "unavailable" });
}
if (nativeMediaIngressRef.current) {
recoverBrowserFanout(nativeMediaIngressRef.current);
} else if (nativeModeRef.current && activeGenerationRef.current !== null) {
@@ -1345,16 +1357,21 @@ export function HostPage({
nativeClientCloseCleanupRef.current?.();
nativeClientCloseCleanupRef.current = null;
nativeClientRef.current = null;
nativeClientConnectRef.current = null;
}
client.close();
}
async function acquireNativeClient(): Promise<NativeClient | null> {
await nativeShareCleanupRef.current;
const current = nativeClientRef.current;
if (current) return current;
function acquireNativeClient(): Promise<NativeClient | null> {
if (nativeClientConnectRef.current) return nativeClientConnectRef.current;
const connecting = NativeClient.connect().then((client) => {
const connecting: Promise<NativeClient | null> = nativeShareCleanupRef.current.then(() =>
nativeClientConnectRef.current === connecting
? nativeClientRef.current ?? NativeClient.connect()
: null,
).catch((error) => {
debugError("native", "discovery-failed", error);
return null;
}).then((client) => {
if (nativeClientConnectRef.current !== connecting) {
client?.close();
return null;
@@ -1368,10 +1385,21 @@ export function HostPage({
return connecting;
}
function releaseUnusedNativeClient(): void {
// The picker may hand its connection to a starting share; only Native media
// needs to keep it after startup. Merely visiting the page owns no session.
if (nativeSourceRequestRef.current || nativeShareGenerationRef.current ||
(activeGenerationRef.current !== null && roomMutationRef.current !== null)) return;
nativeClientConnectRef.current = null;
const client = nativeClientRef.current;
if (client) discardNativeClient(client);
}
function closeCaptureSourcePicker(): void {
nativeSourceRequestRef.current = null;
nativeSourcePathRef.current = null;
setNativeSources(null);
releaseUnusedNativeClient();
}
async function openCaptureSourcePicker(): Promise<void> {
@@ -1403,7 +1431,7 @@ export function HostPage({
nativeModeRef.current ? videoCodecRef.current.primary : videoCodecModeRef.current,
client.health.nativeMedia.softwareVP8,
);
if (nativeSourceRequestRef.current !== request) {
if (nativeSourceRequestRef.current !== request || nativeClientRef.current !== client) {
return;
}
if (!path) {
@@ -1418,12 +1446,11 @@ export function HostPage({
systemAudio: client.health.nativeMedia.systemAudio,
});
} catch {
if (!nativeModeRef.current) {
if (nativeSourceRequestRef.current !== request || nativeClientRef.current !== client) return;
if (!nativeShareGenerationRef.current) {
discardNativeClient(client);
}
if (nativeSourceRequestRef.current === request) {
setNativeSources({ kind: "unavailable" });
}
setNativeSources({ kind: "unavailable" });
}
}
@@ -1434,8 +1461,10 @@ export function HostPage({
): Promise<void> {
if (!launchedByClient || videoCodecRef.current.primary !== "h264" ||
!routePolicyRef.current.topologyOptimization) return;
const client = await acquireNativeClient();
const client = nativeClientRef.current;
if (!client || !isCurrentShare(generation, shareGeneration)) return;
await nativeShareCleanupRef.current;
if (nativeClientRef.current !== client || !isCurrentShare(generation, shareGeneration)) return;
const ingress = new NativeMediaIngress(shareGeneration, client, () => {
recoverBrowserFanout(ingress);
}, () => qualitySettingsRef.current);
@@ -1477,8 +1506,8 @@ export function HostPage({
}
function startBrowserShareFromPicker(): void {
closeCaptureSourcePicker();
void startSharing({ kind: "browser" });
closeCaptureSourcePicker();
}
async function loadNativeSourcePreview(
@@ -1524,7 +1553,8 @@ export function HostPage({
void startSharing({ kind: "native", client, target, audio, path });
}
function disposeNativeShare(): void {
function disposeNativeShare(expectedShare = nativeShareGenerationRef.current): void {
if (nativeShareGenerationRef.current !== expectedShare) return;
const client = nativeClientRef.current;
const shareGeneration = nativeShareGenerationRef.current;
const ingress = nativeMediaIngressRef.current;
@@ -1538,12 +1568,14 @@ export function HostPage({
nativeModeRef.current = false;
setNativeActive(false);
if (!client || !shareGeneration) {
releaseUnusedNativeClient();
return;
}
nativeShareCleanupRef.current = (ingress
? client.stopReceive(shareGeneration)
: client.stopShare(shareGeneration))
.catch(() => discardNativeClient(client));
releaseUnusedNativeClient();
}
function finishSourceSwitch(token: object): void {
@@ -2042,18 +2074,13 @@ export function HostPage({
peerId: string,
generation: number,
): Promise<void> {
if (!isCurrentGeneration(generation) || !hostChildIsAssigned(peerId)) {
if (
!isCurrentGeneration(generation) ||
!hostChildIsAssigned(peerId) ||
peersRef.current.has(peerId)
) {
return;
}
// Preserve healthy media across control reconnects, but replace a stalled
// negotiation whose offer or answer may have been lost with the WebSocket.
const existing = peersRef.current.get(peerId);
if (existing) {
if (existing.isConnected()) {
return;
}
removePeer(peerId);
}
const activeStream = streamRef.current;
const nativeClient = nativeClientRef.current;
const nativeShareGeneration = nativeShareGenerationRef.current;
@@ -2246,7 +2273,6 @@ export function HostPage({
mergeAuthenticatedHostRoom(
current,
activeRoom.roomId,
message.roomExpiresAt,
message.codeEntryPolicy,
),
);
@@ -2254,6 +2280,10 @@ export function HostPage({
pendingQualitySettings ?? message.qualitySettings;
activeRouteRevisionRef.current = message.routeRevision;
if (reauthenticated) {
// Offers or answers may have been lost with the previous WebSocket.
for (const [peerId, peer] of peersRef.current) {
if (!peer.isConnected()) removePeer(peerId);
}
const draft = qualityChangeRef.current ? advancedQualityRef.current : null;
commitQuality(currentQualitySettings);
if (draft) {
@@ -2390,15 +2420,11 @@ export function HostPage({
if (!forgetRoom(activeRoom)) {
return;
}
endSharing(
message.reason === "expired" ? say("host.roomExpired") : say("host.roomClosed"),
false,
"warning",
);
endSharing(say("host.roomClosed"), false, "warning");
return;
}
if (message.type === "error") {
if (["INVALID_TOKEN", "ROOM_EXPIRED"].includes(message.code)) {
if (message.code === "INVALID_TOKEN") {
if (!forgetRoom(activeRoom)) {
return;
}
@@ -2455,7 +2481,7 @@ export function HostPage({
}
} catch (error) {
if (!isCurrentShare(generation, shareGeneration)) {
disposeNativeShare();
disposeNativeShare(shareGeneration);
return;
}
activeGenerationRef.current = null;
@@ -2467,7 +2493,7 @@ export function HostPage({
if (!isCurrentShare(generation, shareGeneration)) {
captured?.getTracks().forEach((track) => track.stop());
if (nativeStarted) disposeNativeShare();
if (nativeStarted) disposeNativeShare(shareGeneration);
return;
}
if (captured) {
@@ -2490,7 +2516,7 @@ export function HostPage({
}
if (!isCurrentShare(generation, shareGeneration)) {
captured?.getTracks().forEach((track) => track.stop());
if (nativeStarted) disposeNativeShare();
if (nativeStarted) disposeNativeShare(shareGeneration);
return;
}
@@ -2509,7 +2535,7 @@ export function HostPage({
createdRoom = hostRoomFromCreated(response);
if (!isCurrentShare(generation, shareGeneration)) {
captured?.getTracks().forEach((track) => track.stop());
if (nativeStarted) disposeNativeShare();
if (nativeStarted) disposeNativeShare(shareGeneration);
closeAbandonedRoom(createdRoom);
return;
}
@@ -2588,8 +2614,7 @@ export function HostPage({
if (
!authenticated &&
message.type === "error" &&
(message.code === "INVALID_TOKEN" ||
message.code === "ROOM_EXPIRED") &&
message.code === "INVALID_TOKEN" &&
!replacementAttempted
) {
replacementAttempted = true;
@@ -2611,7 +2636,7 @@ export function HostPage({
peer.updateIceConfig(message.iceConfig),
);
void writeHostRoom(
{ ...activeRoom, expiresAt: message.roomExpiresAt },
activeRoom,
() => isCurrentShare(generation, shareGeneration) &&
signalRef.current === signal,
);
@@ -2642,7 +2667,7 @@ export function HostPage({
const replacement = hostRoomFromCreated(response);
if (!isCurrentShare(generation, shareGeneration)) {
captured?.getTracks().forEach((track) => track.stop());
disposeNativeShare();
disposeNativeShare(shareGeneration);
closeAbandonedRoom(replacement);
return;
}
@@ -2660,7 +2685,7 @@ export function HostPage({
replacementSignal.start();
} catch (error) {
if (!isCurrentShare(generation, shareGeneration)) {
if (nativeStarted) disposeNativeShare();
if (nativeStarted) disposeNativeShare(shareGeneration);
return;
}
activeGenerationRef.current = null;
@@ -2683,7 +2708,7 @@ export function HostPage({
} catch (error) {
if (!isCurrentShare(generation, shareGeneration)) {
captured?.getTracks().forEach((track) => track.stop());
if (nativeStarted) disposeNativeShare();
if (nativeStarted) disposeNativeShare(shareGeneration);
if (createdRoom && !claimedRoom) {
closeAbandonedRoom(createdRoom);
}
@@ -3429,6 +3454,7 @@ export function HostPage({
{nativeSources ? (
<CaptureSourcePicker
nativeSources={nativeSources}
initialTab={nativeClientRef.current ? "window" : "browser"}
onBrowser={startBrowserShareFromPicker}
onNative={startNativeShareFromPicker}
onPreview={loadNativeSourcePreview}
+21 -15
View File
@@ -635,10 +635,24 @@ export function ViewerPage({
const nativeViewerSessionId = createOpaqueId();
const acquireNativeClient = async (): Promise<NativeClient | null> => {
if (!launchedByClient || !nativeViewerAvailable) return null;
nativeClientPromise ??= NativeClient.connect();
const client = await nativeClientPromise;
return nativeViewerAvailable ? client : null;
if (!active || !launchedByClient || !nativeViewerAvailable) return null;
if (!nativeClientPromise) {
const connecting: Promise<NativeClient | null> = NativeClient.connect().catch(() => null).then((client) => {
if (!client || !active) {
if (nativeClientPromise === connecting) nativeClientPromise = null;
client?.close();
return null;
}
client.onClose(() => {
if (nativeClientPromise === connecting) nativeClientPromise = null;
});
return client;
});
nativeClientPromise = connecting;
}
const connecting = nativeClientPromise;
const client = await connecting;
return active && nativeViewerAvailable && nativeClientPromise === connecting ? client : null;
};
function createViewerMediaPeer(
@@ -1746,6 +1760,7 @@ export function ViewerPage({
void viewerRelay?.updateProfile(currentQualitySettings);
const route = ensureViewerSfuRoute();
route.setPaused(sharingPaused);
viewerRelay?.resyncSignaling();
await route.resyncAuthoritative(
{
revision: message.routeRevision,
@@ -2019,8 +2034,7 @@ export function ViewerPage({
dispatchPresentation({
type: "access",
access: "denied",
failure:
message.reason === "expired" ? "ROOM_EXPIRED" : "ROOM_CLOSED",
failure: "ROOM_CLOSED",
});
signal.stop();
return;
@@ -2032,7 +2046,6 @@ export function ViewerPage({
"INVALID_TOKEN",
"ROOM_NOT_FOUND",
"ROOM_ACCESS_DENIED",
"ROOM_EXPIRED",
"ROOM_FULL",
].includes(message.code)
) {
@@ -2100,11 +2113,7 @@ export function ViewerPage({
viewerRelay?.dispose();
viewerRelay = null;
viewerRelaySourceKey = null;
void nativeClientPromise?.then(async (client) => {
if (!client) return;
await client.stopReceive(nativeViewerSessionId).catch(() => undefined);
client.close();
});
void nativeClientPromise?.then((client) => client?.close());
};
}, [roomId, viewerGrant, viewerPasswordAttempt, launchedByClient]);
@@ -2260,13 +2269,11 @@ export function ViewerPage({
: failureCode === "INVALID_TOKEN"
? "invalid-invite"
: failureCode === "ROOM_NOT_FOUND" ||
failureCode === "ROOM_EXPIRED" ||
failureCode === "ROOM_CLOSED"
? "room-not-found"
: "warning";
const deniedIcon: GlyphName =
failureCode === "ROOM_NOT_FOUND" ||
failureCode === "ROOM_EXPIRED" ||
failureCode === "ROOM_CLOSED"
? "door"
: failureCode === "ROOM_ACCESS_DENIED"
@@ -2277,7 +2284,6 @@ export function ViewerPage({
const deniedHintKey: CopyKey = codeOnlyDenied
? "viewer.hint.denied"
: failureCode === "ROOM_NOT_FOUND" ||
failureCode === "ROOM_EXPIRED" ||
failureCode === "ROOM_CLOSED"
? "viewer.hint.notFound"
: failureCode === "INVALID_TOKEN" ||
-1
View File
@@ -28,7 +28,6 @@ const HOST_SERVER_ERROR_NOTICE: Record<ServerErrorCode, CopyKey> = {
INVALID_TOKEN: "host.err.invalidToken",
ROOM_NOT_FOUND: "viewer.msg.notFound",
ROOM_ACCESS_DENIED: "host.err.accessDenied",
ROOM_EXPIRED: "host.err.roomExpired",
ROOM_FULL: "join.full",
HOST_ALREADY_CONNECTED: "host.err.alreadyConnected",
PEER_NOT_FOUND: "host.err.peerGone",
+12 -16
View File
@@ -141,7 +141,6 @@ export class ViewerRelay {
: null;
const replacesActive =
replacedPeer?.connectionId === prepared?.replacesConnectionId;
let promotedPeerId: string | null = null;
if (
prepared?.revision === revision &&
!prepared.failed &&
@@ -156,11 +155,10 @@ export class ViewerRelay {
this.peers.set(prepared.childPeerId, prepared.peer);
replacedPeer?.dispose();
this.snapshots.set(prepared.childPeerId, prepared.peer.getSnapshot());
promotedPeerId = prepared.childPeerId;
} else {
this.discardPreparedChild();
}
this.reconcileChildren(childPeerIds, promotedPeerId);
this.reconcileChildren(childPeerIds);
}
discardPreparedChild(): void {
@@ -178,7 +176,14 @@ export class ViewerRelay {
}
setChildren(childPeerIds: readonly string[]): void {
this.reconcileChildren(childPeerIds, null);
this.reconcileChildren(childPeerIds);
}
resyncSignaling(): void {
// Authoritative reconciliation recreates negotiations whose signals may be lost.
for (const [childPeerId, peer] of this.peers) {
if (!peer.isConnected()) this.disposePeer(childPeerId);
}
}
updateCapacity(maxMediaEdges: number): void {
@@ -189,13 +194,10 @@ export class ViewerRelay {
if (this.plannedChildPeerIds.length > maxMediaEdges) {
this.discardPreparedChild();
}
this.reconcileChildren(this.childPeerIds, null);
this.reconcileChildren(this.childPeerIds);
}
private reconcileChildren(
childPeerIds: readonly string[],
promotedPeerId: string | null,
): void {
private reconcileChildren(childPeerIds: readonly string[]): void {
if (this.disposed) {
return;
}
@@ -213,13 +215,7 @@ export class ViewerRelay {
this.cancelCodecProbe();
}
for (const childPeerId of this.childPeerIds) {
const peer = this.peers.get(childPeerId);
if (
(this.stream || this.peerFactory?.requiresStream === false) &&
childPeerId !== promotedPeerId &&
(!peer || !peer.isConnected())
) {
this.disposePeer(childPeerId);
if (this.stream || this.peerFactory?.requiresStream === false) {
this.startPeer(childPeerId, this.stream);
}
}
+2 -6
View File
@@ -7,7 +7,7 @@ import { NAT_TRAVERSAL_PATHS } from "./nat-candidate.js";
export const MAX_VIEWERS_PER_ROOM_LIMIT = 20;
export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1;
export const MAX_SIGNAL_BYTES = 64 * 1024;
export const SIGNALING_PROTOCOL = "screener-v22";
export const SIGNALING_PROTOCOL = "screener-v23";
export const SIGNAL_CLOSE_CODES = {
serviceRestart: 1012,
sessionReplaced: 4001,
@@ -946,7 +946,6 @@ const errorCodeSchema = z.enum([
"INVALID_TOKEN",
"ROOM_ACCESS_DENIED",
"ROOM_NOT_FOUND",
"ROOM_EXPIRED",
"ROOM_FULL",
"HOST_ALREADY_CONNECTED",
"PEER_NOT_FOUND",
@@ -958,7 +957,6 @@ const authenticatedMessageShape = {
type: z.literal("authenticated"),
protocol: z.literal(SIGNALING_PROTOCOL),
peerId: opaqueIdSchema,
roomExpiresAt: z.string().datetime().nullable(),
maxViewers: z.number().int().min(1).max(MAX_VIEWERS_PER_ROOM_LIMIT),
endpointMediaCopyCapacity: z
.number()
@@ -1150,7 +1148,7 @@ export const serverMessageSchema = z.union([
z
.object({
type: z.literal("room-closed"),
reason: z.enum(["host-ended", "expired"]),
reason: z.literal("host-ended"),
})
.strict(),
z
@@ -1169,8 +1167,6 @@ export const createRoomResponseSchema = z
hostToken: tokenSchema,
inviteUrl: z.string().url().max(2048),
codeEntryPolicy: codeEntryPolicySchema,
expiresAt: z.string().datetime().nullable(),
roomLeaseSeconds: z.number().int().positive(),
})
.strict();
export type CreateRoomResponse = z.infer<typeof createRoomResponseSchema>;
+9 -37
View File
@@ -310,7 +310,6 @@ function hostRoomStorage(lockState: "available" | "held" | "absent" = "available
const storedHost = (roomId = "1234", hostToken = "a".repeat(32)) => ({
roomId, hostToken, inviteUrl: `https://share.test/r/${roomId}`,
expiresAt: null, roomLeaseSeconds: 3_600,
});
describe("client session identity", () => {
@@ -342,8 +341,6 @@ describe("client session identity", () => {
hostToken: "a".repeat(32),
inviteUrl: `https://share.test/r/1234#v=${"b".repeat(21)}A`,
codeEntryPolicy: "open" as const,
expiresAt: null,
roomLeaseSeconds: 3_600,
};
await writeHostRoom(room);
@@ -352,15 +349,11 @@ describe("client session identity", () => {
roomId: "1234",
hostToken: "a".repeat(32),
canonicalUrl: "https://share.test/r/1234",
expiresAt: null,
roomLeaseSeconds: 3_600,
});
expect(values.get("screener:host-room:v1")).toBe(
JSON.stringify({
roomId: "1234",
hostToken: "a".repeat(32),
expiresAt: null,
roomLeaseSeconds: 3_600,
inviteUrl: "https://share.test/r/1234",
}),
);
@@ -377,26 +370,19 @@ describe("client session identity", () => {
roomId: "1234",
hostToken: "h".repeat(32),
canonicalUrl: "https://share.test/r/1234",
expiresAt: null,
roomLeaseSeconds: 3_600,
});
expect(JSON.parse(session.get("screener:host-room:v1")!)).toEqual(JSON.parse(local.get("screener:host-room:v1")!));
expect(request).toHaveBeenCalledWith(expect.stringMatching(/^screener:host-room:1234:[a-f0-9]{64}$/), { ifAvailable: true }, expect.any(Function));
expect(request.mock.calls[0]![0]).not.toContain("h".repeat(32));
});
it("keeps expired host records for server judgment and discards malformed records", async () => {
it("keeps stored room authority despite unowned metadata and discards malformed records", async () => {
const { local: values, session } = hostRoomStorage();
const expired = {
roomId: "1234",
hostToken: "b".repeat(32),
canonicalUrl: "https://share.test/r/1234",
expiresAt: "2026-08-18T00:00:00.000Z",
roomLeaseSeconds: 3_600,
};
await writeHostRoom(expired);
expect(await readHostRoom()).toMatchObject({ roomId: "1234" });
values.set("screener:host-room:v1", JSON.stringify({ ...storedHost(), unusedMetadata: 123 }));
expect(await readHostRoom()).toEqual({
roomId: "1234", hostToken: "a".repeat(32), canonicalUrl: "https://share.test/r/1234",
});
expect(JSON.parse(session.get("screener:host-room:v1")!)).toEqual(storedHost());
releaseHostRoom();
session.delete("screener:host-room:v1");
values.set("screener:host-room:v1", "not-json");
@@ -505,12 +491,12 @@ describe("client session identity", () => {
await writeHostRoom(room);
const saved = local.get("screener:host-room:v1");
let owns = true;
const writing = writeHostRoom({ ...room, expiresAt: "2026-09-09T00:00:00.000Z" }, () => owns);
const writing = writeHostRoom({ ...room, inviteUrl: "https://new.test/r/1234" }, () => owns);
owns = false;
expect(await writing).toBe(false);
expect(held.size).toBe(1);
expect(local.get("screener:host-room:v1")).toBe(saved);
expect(await readHostRoom()).toMatchObject({ roomId: "1234", expiresAt: null });
expect(await readHostRoom()).toMatchObject({ roomId: "1234", canonicalUrl: "https://share.test/r/1234" });
expect(request).toHaveBeenCalledOnce();
});
@@ -619,8 +605,6 @@ describe("client session identity", () => {
roomId: "1234",
hostToken: "h".repeat(32),
canonicalUrl: "https://share.test/r/1234",
expiresAt: null,
roomLeaseSeconds: 3_600,
codeEntryPolicy: "open" as const,
inviteUrl: oldInvite,
};
@@ -629,7 +613,6 @@ describe("client session identity", () => {
mergeAuthenticatedHostRoom(
{ ...activeRoom, inviteUrl: rotatedInvite },
activeRoom.roomId,
null,
"open",
)?.inviteUrl,
).toBe(rotatedInvite);
@@ -637,7 +620,6 @@ describe("client session identity", () => {
mergeAuthenticatedHostRoom(
{ ...activeRoom, inviteUrl: null },
activeRoom.roomId,
null,
"open",
)?.inviteUrl,
).toBe(oldInvite);
@@ -646,7 +628,6 @@ describe("client session identity", () => {
mergeAuthenticatedHostRoom(
{ ...activeRoom, inviteUrl: null },
activeRoom.roomId,
null,
"open",
)?.inviteUrl,
).toBeNull();
@@ -822,14 +803,12 @@ describe("site access API", () => {
);
});
it("accepts an active room with a lease deadline", async () => {
it("accepts room creation with permanent authority", async () => {
const room = {
roomId: "1234",
hostToken: "c".repeat(32),
inviteUrl: `https://share.test/r/1234#v=${"f".repeat(21)}A`,
codeEntryPolicy: "open",
expiresAt: "2026-08-24T00:00:00.000Z",
roomLeaseSeconds: 86_400,
};
vi.stubGlobal(
"fetch",
@@ -1091,7 +1070,6 @@ describe("client signaling recovery policy", () => {
protocol: SIGNALING_PROTOCOL,
role: "viewer",
peerId: "viewer_12345678",
roomExpiresAt: null,
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
@@ -1234,7 +1212,6 @@ describe("client signaling recovery policy", () => {
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
roomExpiresAt: null,
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
@@ -1270,7 +1247,6 @@ describe("client signaling recovery policy", () => {
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
roomExpiresAt: null,
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
@@ -1383,7 +1359,6 @@ describe("client signaling recovery policy", () => {
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
roomExpiresAt: null,
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
@@ -1511,7 +1486,6 @@ describe("client signaling recovery policy", () => {
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
roomExpiresAt: null,
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
@@ -1628,7 +1602,6 @@ describe("client signaling recovery policy", () => {
protocol: SIGNALING_PROTOCOL,
role: "viewer",
peerId: "viewer_12345678",
roomExpiresAt: null,
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
@@ -1709,7 +1682,6 @@ describe("client signaling recovery policy", () => {
type: "authenticated",
role: "viewer",
peerId: "viewer_12345678",
roomExpiresAt: null,
maxViewers: 8,
hostOnline: true,
connectionId: null,
+28 -32
View File
@@ -1,6 +1,6 @@
{
"constants": {
"signalingProtocol": "screener-v22",
"signalingProtocol": "screener-v23",
"maxViewersPerRoomLimit": 20,
"maxParticipantsPerRoomLimit": 21,
"maxSignalBytes": 65536,
@@ -41,79 +41,79 @@
{
"name": "authenticate-host-minimal",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"clientId\":\"client_12345678\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"clientId\":\"client_12345678\"}",
"valid": true
},
{
"name": "authenticate-host-full",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"clientId\":\"client_12345678\",\"shareGeneration\":\"share_generation_12345678\",\"sharingPaused\":false,\"qualitySettings\":{\"resolution\":\"1080p\",\"maxFramerate\":60,\"maxBitrate\":8000000,\"degradationPreference\":\"maintain-resolution\",\"screenAudioQuality\":\"music\"},\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false},\"viewerPresence\":true,\"displayName\":\"小明\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"clientId\":\"client_12345678\",\"shareGeneration\":\"share_generation_12345678\",\"sharingPaused\":false,\"qualitySettings\":{\"resolution\":\"1080p\",\"maxFramerate\":60,\"maxBitrate\":8000000,\"degradationPreference\":\"maintain-resolution\",\"screenAudioQuality\":\"music\"},\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false},\"viewerPresence\":true,\"displayName\":\"小明\"}",
"valid": true
},
{
"name": "authenticate-host-carries-viewer-grant",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"clientId\":\"client_12345678\",\"viewerGrant\":\"bbbbbbbbbbbbbbbbbbbbbg\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"clientId\":\"client_12345678\",\"viewerGrant\":\"bbbbbbbbbbbbbbbbbbbbbg\"}",
"valid": false
},
{
"name": "authenticate-host-carries-viewer-password",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"clientId\":\"client_12345678\",\"viewerPassword\":\"easy-password\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"clientId\":\"client_12345678\",\"viewerPassword\":\"easy-password\"}",
"valid": false
},
{
"name": "authenticate-host-short-token",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"short\",\"clientId\":\"client_12345678\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"short\",\"clientId\":\"client_12345678\"}",
"valid": false
},
{
"name": "authenticate-host-unknown-key",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"clientId\":\"client_12345678\",\"admin\":true}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"host\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"clientId\":\"client_12345678\",\"admin\":true}",
"valid": false
},
{
"name": "authenticate-viewer-minimal",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\"}",
"valid": true
},
{
"name": "authenticate-viewer-full",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"viewerGrant\":\"bbbbbbbbbbbbbbbbbbbbbg\",\"viewerPassword\":\"easy-password\",\"displayName\":\"Café 朋友\",\"viewerPresence\":true}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"viewerGrant\":\"bbbbbbbbbbbbbbbbbbbbbg\",\"viewerPassword\":\"easy-password\",\"displayName\":\"Café 朋友\",\"viewerPresence\":true}",
"valid": true
},
{
"name": "authenticate-viewer-carries-host-token",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}",
"valid": false
},
{
"name": "authenticate-viewer-carries-route-policy",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false}}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false}}",
"valid": false
},
{
"name": "authenticate-viewer-malformed-grant",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"viewerGrant\":\"bbbbbbbbbbbbbbbbbbbbb\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"viewerGrant\":\"bbbbbbbbbbbbbbbbbbbbb\"}",
"valid": false
},
{
"name": "authenticate-viewer-grant-bad-final-character",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"viewerGrant\":\"bbbbbbbbbbbbbbbbbbbbbb\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"viewerGrant\":\"bbbbbbbbbbbbbbbbbbbbbb\"}",
"valid": false
},
{
"name": "authenticate-viewer-unknown-capabilities",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"capabilities\":{\"peerIceTurn\":true}}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"capabilities\":{\"peerIceTurn\":true}}",
"valid": false
},
{
@@ -131,55 +131,55 @@
{
"name": "authenticate-viewer-leading-zero-room",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"0123\",\"role\":\"viewer\",\"clientId\":\"client_12345678\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"0123\",\"role\":\"viewer\",\"clientId\":\"client_12345678\"}",
"valid": false
},
{
"name": "authenticate-viewer-long-room",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"11111\",\"role\":\"viewer\",\"clientId\":\"client_12345678\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"11111\",\"role\":\"viewer\",\"clientId\":\"client_12345678\"}",
"valid": false
},
{
"name": "authenticate-viewer-noncanonical-display-name",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":\" Café \"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":\" Café \"}",
"valid": false
},
{
"name": "authenticate-viewer-display-name-too-long",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":\"名名名名名名名名名名名名名名名名名名名名名名名名名\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":\"名名名名名名名名名名名名名名名名名名名名名名名名名\"}",
"valid": false
},
{
"name": "authenticate-viewer-display-name-at-limit",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":\"名名名名名名名名名名名名名名名名名名名名名名名名\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":\"名名名名名名名名名名名名名名名名名名名名名名名名\"}",
"valid": true
},
{
"name": "authenticate-viewer-display-name-bidi-override",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":\"a‮b\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":\"a‮b\"}",
"valid": false
},
{
"name": "authenticate-viewer-display-name-newline",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":\"a\\nb\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":\"a\\nb\"}",
"valid": false
},
{
"name": "authenticate-viewer-null-display-name",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":null}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":null}",
"valid": false
},
{
"name": "authenticate-unknown-role",
"schema": "client",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v22\",\"roomId\":\"1234\",\"role\":\"relay\",\"clientId\":\"client_12345678\"}",
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"relay\",\"clientId\":\"client_12345678\"}",
"valid": false
},
{
@@ -611,7 +611,7 @@
{
"name": "abandon-room-extra-key",
"schema": "client",
"json": "{\"type\":\"abandon-room\",\"reason\":\"expired\"}",
"json": "{\"type\":\"abandon-room\",\"unexpected\":true}",
"valid": false
},
{
@@ -810,19 +810,19 @@
"serverMessages": [
{
"name": "authenticated-host-minimal",
"json": "{\"type\":\"authenticated\",\"protocol\":\"screener-v22\",\"peerId\":\"host_12345678\",\"roomExpiresAt\":\"2026-08-18T18:00:00.000Z\",\"maxViewers\":8,\"endpointMediaCopyCapacity\":2,\"hostOnline\":true,\"connectionId\":null,\"iceConfig\":{\"iceServers\":[],\"natPredictionStunUrls\":[]},\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false},\"codeEntryPolicy\":\"open\",\"viewerAuthorizationGeneration\":\"viewer_generation_12345678\",\"mediaMode\":\"peer-assisted\",\"shareGeneration\":\"share_generation_12345678\",\"routeRevision\":0,\"routeAssignment\":{\"upstream\":{\"kind\":\"none\"},\"childPeerIds\":[],\"sfuPublicationGeneration\":null},\"qualitySettings\":{\"resolution\":\"1080p\",\"maxFramerate\":60,\"maxBitrate\":8000000,\"degradationPreference\":\"maintain-resolution\",\"screenAudioQuality\":\"music\"},\"role\":\"host\",\"viewerPasswordEnabled\":false}"
"json": "{\"type\":\"authenticated\",\"protocol\":\"screener-v23\",\"peerId\":\"host_12345678\",\"maxViewers\":8,\"endpointMediaCopyCapacity\":2,\"hostOnline\":true,\"connectionId\":null,\"iceConfig\":{\"iceServers\":[],\"natPredictionStunUrls\":[]},\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false},\"codeEntryPolicy\":\"open\",\"viewerAuthorizationGeneration\":\"viewer_generation_12345678\",\"mediaMode\":\"peer-assisted\",\"shareGeneration\":\"share_generation_12345678\",\"routeRevision\":0,\"routeAssignment\":{\"upstream\":{\"kind\":\"none\"},\"childPeerIds\":[],\"sfuPublicationGeneration\":null},\"qualitySettings\":{\"resolution\":\"1080p\",\"maxFramerate\":60,\"maxBitrate\":8000000,\"degradationPreference\":\"maintain-resolution\",\"screenAudioQuality\":\"music\"},\"role\":\"host\",\"viewerPasswordEnabled\":false}"
},
{
"name": "authenticated-host-full",
"json": "{\"type\":\"authenticated\",\"protocol\":\"screener-v22\",\"peerId\":\"host_12345678\",\"roomExpiresAt\":null,\"maxViewers\":8,\"endpointMediaCopyCapacity\":2,\"hostOnline\":true,\"connectionId\":\"connection_12345678\",\"iceConfig\":{\"iceServers\":[{\"urls\":\"stun:stun.example.test:3478\"},{\"urls\":[\"stun:stun-1.example.test:3478\",\"stun:[2001:db8::1]:3478\"]}],\"natPredictionStunUrls\":[\"stun:nat.example.test:3478\"]},\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false},\"codeEntryPolicy\":\"open\",\"viewerAuthorizationGeneration\":\"viewer_generation_12345678\",\"mediaMode\":\"peer-assisted\",\"shareGeneration\":\"share_generation_12345678\",\"routeRevision\":0,\"routeAssignment\":{\"upstream\":{\"kind\":\"sfu\"},\"childPeerIds\":[],\"sfuPublicationGeneration\":\"generation_12345678\"},\"qualitySettings\":{\"resolution\":\"1080p\",\"maxFramerate\":60,\"maxBitrate\":8000000,\"degradationPreference\":\"maintain-resolution\",\"screenAudioQuality\":\"music\"},\"role\":\"host\",\"viewerPasswordEnabled\":false}"
"json": "{\"type\":\"authenticated\",\"protocol\":\"screener-v23\",\"peerId\":\"host_12345678\",\"maxViewers\":8,\"endpointMediaCopyCapacity\":2,\"hostOnline\":true,\"connectionId\":\"connection_12345678\",\"iceConfig\":{\"iceServers\":[{\"urls\":\"stun:stun.example.test:3478\"},{\"urls\":[\"stun:stun-1.example.test:3478\",\"stun:[2001:db8::1]:3478\"]}],\"natPredictionStunUrls\":[\"stun:nat.example.test:3478\"]},\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false},\"codeEntryPolicy\":\"open\",\"viewerAuthorizationGeneration\":\"viewer_generation_12345678\",\"mediaMode\":\"peer-assisted\",\"shareGeneration\":\"share_generation_12345678\",\"routeRevision\":0,\"routeAssignment\":{\"upstream\":{\"kind\":\"sfu\"},\"childPeerIds\":[],\"sfuPublicationGeneration\":\"generation_12345678\"},\"qualitySettings\":{\"resolution\":\"1080p\",\"maxFramerate\":60,\"maxBitrate\":8000000,\"degradationPreference\":\"maintain-resolution\",\"screenAudioQuality\":\"music\"},\"role\":\"host\",\"viewerPasswordEnabled\":false}"
},
{
"name": "authenticated-viewer-minimal",
"json": "{\"type\":\"authenticated\",\"protocol\":\"screener-v22\",\"peerId\":\"viewer_12345678\",\"roomExpiresAt\":\"2026-08-18T18:00:00.000Z\",\"maxViewers\":8,\"endpointMediaCopyCapacity\":2,\"hostOnline\":true,\"hostPaused\":false,\"connectionId\":null,\"iceConfig\":{\"iceServers\":[],\"natPredictionStunUrls\":[]},\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false},\"codeEntryPolicy\":\"open\",\"viewerAuthorizationGeneration\":\"viewer_generation_12345678\",\"mediaMode\":\"peer-assisted\",\"shareGeneration\":\"share_generation_12345678\",\"routeRevision\":0,\"routeAssignment\":{\"upstream\":{\"kind\":\"none\"},\"childPeerIds\":[],\"sfuPublicationGeneration\":null},\"qualitySettings\":{\"resolution\":\"1080p\",\"maxFramerate\":60,\"maxBitrate\":8000000,\"degradationPreference\":\"maintain-resolution\",\"screenAudioQuality\":\"music\"},\"role\":\"viewer\"}"
"json": "{\"type\":\"authenticated\",\"protocol\":\"screener-v23\",\"peerId\":\"viewer_12345678\",\"maxViewers\":8,\"endpointMediaCopyCapacity\":2,\"hostOnline\":true,\"hostPaused\":false,\"connectionId\":null,\"iceConfig\":{\"iceServers\":[],\"natPredictionStunUrls\":[]},\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false},\"codeEntryPolicy\":\"open\",\"viewerAuthorizationGeneration\":\"viewer_generation_12345678\",\"mediaMode\":\"peer-assisted\",\"shareGeneration\":\"share_generation_12345678\",\"routeRevision\":0,\"routeAssignment\":{\"upstream\":{\"kind\":\"none\"},\"childPeerIds\":[],\"sfuPublicationGeneration\":null},\"qualitySettings\":{\"resolution\":\"1080p\",\"maxFramerate\":60,\"maxBitrate\":8000000,\"degradationPreference\":\"maintain-resolution\",\"screenAudioQuality\":\"music\"},\"role\":\"viewer\"}"
},
{
"name": "authenticated-viewer-full",
"json": "{\"type\":\"authenticated\",\"protocol\":\"screener-v22\",\"peerId\":\"viewer_12345678\",\"roomExpiresAt\":\"2026-08-18T18:00:00.000Z\",\"maxViewers\":8,\"endpointMediaCopyCapacity\":2,\"hostOnline\":true,\"hostPaused\":true,\"connectionId\":\"connection_12345678\",\"iceConfig\":{\"iceServers\":[],\"natPredictionStunUrls\":[]},\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false},\"codeEntryPolicy\":\"open\",\"viewerAuthorizationGeneration\":\"viewer_generation_12345678\",\"mediaMode\":\"peer-assisted\",\"shareGeneration\":\"share_generation_12345678\",\"routeRevision\":0,\"routeAssignment\":{\"upstream\":{\"kind\":\"peer\",\"peerId\":\"parent_12345678\"},\"childPeerIds\":[\"child_12345678\",\"child_87654321\"],\"sfuPublicationGeneration\":null},\"qualitySettings\":{\"resolution\":\"1080p\",\"maxFramerate\":60,\"maxBitrate\":8000000,\"degradationPreference\":\"maintain-resolution\",\"screenAudioQuality\":\"music\"},\"role\":\"viewer\"}"
"json": "{\"type\":\"authenticated\",\"protocol\":\"screener-v23\",\"peerId\":\"viewer_12345678\",\"maxViewers\":8,\"endpointMediaCopyCapacity\":2,\"hostOnline\":true,\"hostPaused\":true,\"connectionId\":\"connection_12345678\",\"iceConfig\":{\"iceServers\":[],\"natPredictionStunUrls\":[]},\"routePolicy\":{\"peerOnly\":false,\"topologyOptimization\":true,\"natPrediction\":false},\"codeEntryPolicy\":\"open\",\"viewerAuthorizationGeneration\":\"viewer_generation_12345678\",\"mediaMode\":\"peer-assisted\",\"shareGeneration\":\"share_generation_12345678\",\"routeRevision\":0,\"routeAssignment\":{\"upstream\":{\"kind\":\"peer\",\"peerId\":\"parent_12345678\"},\"childPeerIds\":[\"child_12345678\",\"child_87654321\"],\"sfuPublicationGeneration\":null},\"qualitySettings\":{\"resolution\":\"1080p\",\"maxFramerate\":60,\"maxBitrate\":8000000,\"degradationPreference\":\"maintain-resolution\",\"screenAudioQuality\":\"music\"},\"role\":\"viewer\"}"
},
{
"name": "signaling-challenge-response",
@@ -928,10 +928,6 @@
"name": "room-closed-host-ended",
"json": "{\"type\":\"room-closed\",\"reason\":\"host-ended\"}"
},
{
"name": "room-closed-expired",
"json": "{\"type\":\"room-closed\",\"reason\":\"expired\"}"
},
{
"name": "error",
"json": "{\"type\":\"error\",\"code\":\"ROOM_FULL\",\"message\":\"Room is full\"}"
+1 -2
View File
@@ -22,9 +22,8 @@ describe("host error notices", () => {
AUTH_REQUIRED: "站点访问已失效,请重新验证",
INVALID_MESSAGE: "页面版本已更新,请刷新后重试",
INVALID_TOKEN: "分享凭证已失效,请重新创建房间",
ROOM_NOT_FOUND: "房间不存在或已过期",
ROOM_NOT_FOUND: "房间不存在",
ROOM_ACCESS_DENIED: "当前操作没有权限",
ROOM_EXPIRED: "房间已过期,请重新创建",
ROOM_FULL: "房间已满",
HOST_ALREADY_CONNECTED: "此房间已在另一个页面中分享",
PEER_NOT_FOUND: "对应的观看连接已经离开",
+76 -15
View File
@@ -2530,14 +2530,14 @@ describe("ViewerRelay downstream ownership", () => {
}
});
it("rebuilds a stalled peer but preserves a connected peer on reconciliation", async () => {
const targets: string[] = [];
it("preserves a connecting replacement when topology reaffirms the same child", async () => {
const connectionIds: string[] = [];
const relay = new ViewerRelay(
{ iceServers: [] },
QUALITY_PROFILES["720p30"],
{
sendSignal: (peerId) => {
targets.push(peerId);
sendSignal: (_peerId, payload) => {
connectionIds.push(payload.connectionId);
return true;
},
},
@@ -2546,20 +2546,81 @@ describe("ViewerRelay downstream ownership", () => {
relay.setStream(
createStream(createTrack("video", "reconciled-video"), null),
);
await vi.waitFor(() => expect(targets).toEqual(["reconciled-child"]));
const stalledConnection = FakePeerConnection.latest!;
try {
await vi.waitFor(() => expect(connectionIds).toHaveLength(1));
const original = FakePeerConnection.latest!;
original.connectionState = "connected";
relay.setChildren(["reconciled-child"]);
await vi.waitFor(() =>
expect(FakePeerConnection.latest).not.toBe(stalledConnection),
await relay.recover("reconciled-child", connectionIds[0]!, true);
await vi.waitFor(() => expect(connectionIds).toHaveLength(2));
const replacement = FakePeerConnection.latest!;
expect(replacement).not.toBe(original);
expect(original.connectionState).toBe("closed");
replacement.connectionState = "connecting";
relay.activateChildren(2, ["reconciled-child"]);
relay.setChildren(["reconciled-child"]);
relay.updateCapacity(1);
await expect(relay.acceptSignal("reconciled-child", {
kind: "description",
connectionId: connectionIds[1]!,
description: { type: "answer", sdp: "replacement-answer" },
}, 2)).resolves.toBe(true);
expect(FakePeerConnection.latest).toBe(replacement);
expect(replacement.connectionState).toBe("connecting");
expect(replacement.remoteDescription?.sdp).toBe("replacement-answer");
expect(connectionIds).toHaveLength(2);
} finally {
relay.dispose();
}
});
it("rebuilds only unfinished negotiations after signaling resynchronizes", async () => {
const signals: Array<{ peerId: string; connectionId: string }> = [];
const relay = new ViewerRelay(
{ iceServers: [] },
QUALITY_PROFILES["720p30"],
{
sendSignal: (peerId, payload) => {
signals.push({ peerId, connectionId: payload.connectionId });
return true;
},
},
);
const connectedConnection = FakePeerConnection.latest!;
await vi.waitFor(() => expect(targets).toHaveLength(2));
connectedConnection.connectionState = "connected";
const children = ["healthy-child", "unfinished-child"];
relay.setChildren(children);
relay.setStream(createStream(createTrack("video", "resync-video"), null));
try {
await vi.waitFor(() => expect(signals).toHaveLength(2));
const [healthy, unfinished] = FakePeerConnection.instances;
healthy!.connectionState = "connected";
unfinished!.connectionState = "connecting";
const oldId = signals.find(({ peerId }) => peerId === "unfinished-child")!.connectionId;
relay.setChildren(["reconciled-child"]);
expect(FakePeerConnection.latest).toBe(connectedConnection);
expect(targets).toHaveLength(2);
relay.resyncSignaling();
relay.activateChildren(2, children);
await vi.waitFor(() => expect(signals).toHaveLength(3));
expect(healthy!.connectionState).toBe("connected");
expect(unfinished!.connectionState).toBe("closed");
expect(signals.filter(({ peerId }) => peerId === "healthy-child")).toHaveLength(1);
const replacement = FakePeerConnection.latest!;
const newId = signals[2]!.connectionId;
expect(newId).not.toBe(oldId);
await expect(relay.acceptSignal("unfinished-child", {
kind: "description",
connectionId: oldId,
description: { type: "answer", sdp: "stale-answer" },
}, 2)).resolves.toBe(false);
await expect(relay.acceptSignal("unfinished-child", {
kind: "description",
connectionId: newId,
description: { type: "answer", sdp: "resynced-answer" },
}, 2)).resolves.toBe(true);
expect(replacement.remoteDescription?.sdp).toBe("resynced-answer");
} finally {
relay.dispose();
}
});
it("keeps one downstream connection across upstream stream replacement", async () => {
+207 -8
View File
@@ -8,14 +8,19 @@ import {
type QualitySettings,
} from "../src/client/media/quality";
import { NativeSenderPeer } from "../src/client/native/native-sender-peer";
import { debugEvent, debugOperation } from "../src/client/lib/debug";
import { reconcileBoundedMediaChildren } from "../src/client/webrtc/media-assignment";
import { debugError, debugEvent, debugOperation } from "../src/client/lib/debug";
// Exercise the actual page owners without mounting capture hardware or a Browser.
const source = ts.createSourceFile("HostPage.tsx", readFileSync(
new URL("../src/client/pages/HostPage.tsx", import.meta.url), "utf8",
), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const owners = new Set(["changeQuality", "commitQuality", "handleSignalMessage",
"switchNativeSource", "finishSourceSwitch", "recoverBrowserFanout", "disposeNativeShare"]);
"switchNativeSource", "finishSourceSwitch", "recoverBrowserFanout", "disposeNativeShare",
"acquireNativeClient", "requestSharing", "startNativeShare", "startBrowserNativeIngress",
"ownNativeClient", "discardNativeClient", "releaseUnusedNativeClient", "closeCaptureSourcePicker",
"openCaptureSourcePicker", "startSharing", "beginRoomMutation", "finishRoomMutation",
"startPeer", "reconcileHostChildren"]);
const functions: string[] = [];
function collect(node: ts.Node): void {
if (ts.isFunctionDeclaration(node) && node.name && owners.has(node.name.text)) {
@@ -42,20 +47,39 @@ function ingress() {
return { updateProfile: vi.fn(async (_profile: QualitySettings) => true), dispose: vi.fn() };
}
function fixture() {
function fixture(launchedByClient = true) {
let physical: QualitySettings = original;
const track = { stop: vi.fn() };
const stream = { getTracks: () => [track] };
const peer = { updateCaptureProfile: vi.fn(async () => true), updateProfile: vi.fn(async () => true) };
const peer = { updateCaptureProfile: vi.fn(async () => true), updateProfile: vi.fn(async () => true),
isConnected: vi.fn(() => true) };
const client = { updateShare: vi.fn(async () => undefined), replaceShareSource: vi.fn(async () => undefined),
startShare: vi.fn(async (): Promise<{ audio: boolean; codec: "h264" }> => ({ audio: false, codec: "h264" })),
close: vi.fn(), onClose: vi.fn(() => () => undefined),
health: { nativeMedia: { video: true, hardwareH264: true, softwareVP8: true } },
captureOptions: vi.fn(async () => []), sources: vi.fn(async () => []),
stopReceive: vi.fn(async () => undefined), stopShare: vi.fn(async () => undefined) };
const route = { updateProfile: vi.fn(async () => true), resyncAuthoritative: vi.fn(async (): Promise<void> => undefined) };
const state = {
debugEvent, debugOperation,
debugError, debugEvent, debugOperation,
launchedByClient, NativeClient: { connect: vi.fn(async (): Promise<typeof client | null> => null) },
nativeClientConnectRef: ref<Promise<typeof client | null> | null>(null),
ownNativeClient: vi.fn(), setJoiningRoom: vi.fn(), startSharing: vi.fn(), openCaptureSourcePicker: vi.fn(),
videoCodecRef: ref({ primary: "h264" }),
videoCodecModeRef: ref("h264"), MAX_ENDPOINT_MEDIA_CHILDREN: 2,
nativeClientCloseCleanupRef: ref<(() => void) | null>(null),
nativeSourceRequestRef: ref<object | null>(null), nativeSourcePathRef: ref<unknown>(null),
setNativeSources: vi.fn(), defaultNativeCapturePath: () => ({ adapterIndex: 0, encoderIndex: 0 }),
roomMutationRef: ref<object | null>(null), setRoomMutation: vi.fn(),
generationRef: ref(0), shareGenerationRef: ref<string | null>("share"), createOpaqueId: () => "share",
setCopied: vi.fn(), setPhase: vi.fn(), roomInitializationRef: ref(Promise.resolve()), roomRef: ref(null),
createRoom: vi.fn(async () => { throw new Error("must not create an empty room"); }),
readPreferredRoomId: () => null, disposeResources: vi.fn(), ApiError: class extends Error {},
NativeMediaBridge: vi.fn(), manualVideoCodecPreference: vi.fn(),
phase: "live", qualitySettingsRef: ref<QualitySettings>(original), advancedQualityRef: ref<QualitySettings>(original),
qualityChangeRef: ref<object | null>(null), pendingQualityChangeRef: ref<QualitySettings | null>(null),
activeGenerationRef: ref<number | null>(1), streamRef: ref<typeof stream | null>(stream), sourceSwitchRef: ref<object | null>(null),
nativeModeRef: ref(false), nativeClientRef: ref(client), nativeShareGenerationRef: ref<string | null>("share"),
nativeModeRef: ref(false), nativeClientRef: ref<typeof client | null>(client), nativeShareGenerationRef: ref<string | null>("share"),
nativeMediaIngressRef: ref<ReturnType<typeof ingress> | null>(null), nativeMediaBridgeRef: ref(null),
nativeEventCleanupRef: ref(null), nativeShareCleanupRef: ref(Promise.resolve()),
peersRef: ref(new Map([["viewer", peer]])), hostProvisionalChildRef: ref(null), hostSfuRouteRef: ref(route),
@@ -74,18 +98,193 @@ function fixture() {
setViewerPasswordEnabled: vi.fn(), setViewerPasswordDraft: vi.fn(), setViewerPasswordVisible: vi.fn(),
setMaxViewers: vi.fn(), setRoutePolicy: vi.fn(), setRoom: vi.fn(), activeRouteRevisionRef: ref(1),
ensureHostSfuRoute: () => route,
reconcileBoundedMediaChildren, activeHostChildPeerIdsRef: ref<string[]>([]),
};
const context = createContext({ ...state, isCurrentGeneration: (generation: number) => state.activeGenerationRef.current === generation });
const context = createContext({ ...state,
isCurrentGeneration: (generation: number) => state.activeGenerationRef.current === generation,
isCurrentShare: (generation: number, share: string) =>
state.activeGenerationRef.current === generation && state.shareGenerationRef.current === share,
});
runInContext(executable, context);
const start = context.startSharing;
const startChild = context.startPeer;
const openPicker = context.openCaptureSourcePicker;
context.startSharing = state.startSharing;
context.startPeer = state.startPeer;
context.openCaptureSourcePicker = state.openCaptureSourcePicker;
return { ...state, peer, client, route, track, physical: () => physical,
context, startChild, openPicker: openPicker as () => Promise<void>,
closePicker: context.closeCaptureSourcePicker as () => void,
releaseUnused: context.releaseUnusedNativeClient as () => void,
disposeNative: context.disposeNativeShare as (expectedShare?: string) => void,
start: () => start( { kind: "native", client, target: {}, audio: false, path: {} }) as Promise<void>,
discover: context.acquireNativeClient as () => Promise<typeof client | null>,
requestShare: context.requestSharing as () => void,
startNative: () => context.startNativeShare(1, "share", { client, target: {}, audio: false, path: {} }) as Promise<unknown>,
startOptionalIngress: () => context.startBrowserNativeIngress(1, "share", stream) as Promise<void>,
change: context.changeQuality as (profile: QualitySettings) => Promise<void>,
reauthenticate: () => context.handleSignalMessage({ type: "authenticated", role: "host", qualitySettings: original,
routePolicy: {}, routeRevision: 1 }, 1, { roomId: "room" }, true, null),
routePolicy: {}, routeRevision: 1, endpointMediaCopyCapacity: 2 }, 1, { roomId: "room" }, true, null),
switchSource: () => context.switchNativeSource(client, {}, false, {}),
};
}
describe("Host quality ownership", () => {
it("retains connecting children during reconciliation and rebuilds them after reauthentication", async () => {
const current = fixture(false);
current.nativeClientRef.current = null;
current.peer.isConnected.mockReturnValue(false);
const healthy = { ...current.peer, isConnected: vi.fn(() => true) };
const replacement = { ...current.peer, start: vi.fn(async () => true) };
current.peersRef.current.set("healthy", healthy);
current.removePeer.mockImplementation((peerId: string) => current.peersRef.current.delete(peerId));
const createPeer = vi.fn(function () { return replacement; });
Object.assign(current.context, {
startPeer: current.startChild, HostPeer: createPeer, browserVideoPool: () => null,
iceConfigRef: ref({ iceServers: [] }),
hostChildIsAssigned: (peerId: string) => current.activeHostChildPeerIdsRef.current.includes(peerId),
});
const reconcile = () => current.context.reconcileHostChildren(["viewer", "healthy"], 1);
reconcile();
expect(current.removePeer).not.toHaveBeenCalled();
expect(current.peersRef.current.get("viewer")).toBe(current.peer);
expect(createPeer).not.toHaveBeenCalled();
current.route.resyncAuthoritative.mockImplementation(async () => { reconcile(); });
current.reauthenticate();
await vi.waitFor(() => expect(replacement.start).toHaveBeenCalledOnce());
expect(current.removePeer).toHaveBeenCalledExactlyOnceWith("viewer");
expect(current.peersRef.current.get("viewer")).toBe(replacement);
expect(current.peersRef.current.get("healthy")).toBe(healthy);
expect(createPeer).toHaveBeenCalledOnce();
});
it("keeps Browser capture immediate and Client selection reachable after absent discovery", async () => {
const browser = fixture(false);
browser.nativeClientRef.current = null;
browser.requestShare();
expect(browser.startSharing).toHaveBeenCalledWith({ kind: "browser" });
expect(browser.openCaptureSourcePicker).not.toHaveBeenCalled();
expect(browser.NativeClient.connect).not.toHaveBeenCalled();
const current = fixture();
current.nativeClientRef.current = null;
current.routePolicyRef.current.topologyOptimization = true;
const discovery = deferred<typeof current.client | null>();
current.NativeClient.connect.mockReturnValue(discovery.promise);
const attempt = current.discover();
current.requestShare();
expect(current.openCaptureSourcePicker).toHaveBeenCalledTimes(1);
expect(current.discover()).toBe(attempt);
await current.startOptionalIngress();
discovery.resolve(null);
await attempt;
expect(current.NativeClient.connect).toHaveBeenCalledTimes(1);
current.NativeClient.connect.mockResolvedValue(current.client);
await expect(current.discover()).resolves.toBe(current.client);
expect(current.NativeClient.connect).toHaveBeenCalledTimes(2);
current.nativeClientRef.current = current.client;
current.requestShare();
expect(current.openCaptureSourcePicker).toHaveBeenCalledTimes(2);
});
it("fails the full startup before room creation when Client retires during cleanup", async () => {
const current = fixture();
current.context.phase = "idle";
current.activeGenerationRef.current = null;
current.nativeShareGenerationRef.current = null;
current.routePolicyRef.current.topologyOptimization = true;
const cleanup = deferred<void>();
current.nativeShareCleanupRef.current = cleanup.promise;
const starting = current.start();
await Promise.resolve();
expect(current.client.startShare).not.toHaveBeenCalled();
current.nativeClientRef.current = null;
cleanup.resolve();
await starting;
expect(current.client.startShare).not.toHaveBeenCalled();
expect(current.createRoom).not.toHaveBeenCalled();
expect(current.setPhase).toHaveBeenLastCalledWith("error");
expect(current.setNoticeError).toHaveBeenCalledOnce();
expect(current.activeGenerationRef.current).toBeNull();
expect(current.roomMutationRef.current).toBeNull();
});
it("retires discovery after picker cancellation and allows a later attempt", async () => {
const current = fixture();
current.nativeClientRef.current = null;
current.nativeShareGenerationRef.current = null;
const discovery = deferred<typeof current.client>();
current.NativeClient.connect.mockReturnValueOnce(discovery.promise);
const opening = current.openPicker();
await vi.waitFor(() => expect(current.NativeClient.connect).toHaveBeenCalledOnce());
current.closePicker();
discovery.resolve(current.client);
await opening;
expect(current.client.close).toHaveBeenCalledOnce();
expect(current.client.sources).not.toHaveBeenCalled();
expect(current.nativeClientRef.current).toBeNull();
const replacement = { ...current.client, close: vi.fn() };
current.NativeClient.connect.mockResolvedValue(replacement);
await current.openPicker();
expect(current.setNativeSources).toHaveBeenLastCalledWith(expect.objectContaining({ kind: "ready" }));
current.closePicker();
expect(replacement.close).toHaveBeenCalledOnce();
});
it("retires a cancelled start ACK before publishing codec or bridge state", async () => {
const current = fixture();
current.nativeShareGenerationRef.current = null;
const started = deferred<{ audio: boolean; codec: "h264" }>();
current.client.startShare.mockReturnValue(started.promise);
const starting = current.startNative();
await vi.waitFor(() => expect(current.client.startShare).toHaveBeenCalledOnce());
current.activeGenerationRef.current = null;
started.resolve({ audio: false, codec: "h264" });
await expect(starting).resolves.toBeNull();
expect(current.client.stopShare).toHaveBeenCalledWith("share");
expect(current.manualVideoCodecPreference).not.toHaveBeenCalled();
expect(current.NativeMediaBridge).not.toHaveBeenCalled();
expect(current.setNativeActive).not.toHaveBeenCalled();
});
it("ignores a stale listing failure after a newer picker owns the Client", async () => {
const current = fixture();
current.nativeShareGenerationRef.current = null;
const listing = deferred<never>();
current.client.sources.mockReturnValueOnce(listing.promise.then(() => { throw new Error("old listing failed"); }));
const old = current.openPicker();
await vi.waitFor(() => expect(current.client.sources).toHaveBeenCalledOnce());
await current.openPicker();
current.setNativeSources.mockClear();
listing.resolve(undefined as never);
await old;
expect(current.client.close).not.toHaveBeenCalled();
expect(current.nativeClientRef.current).toBe(current.client);
expect(current.setNativeSources).not.toHaveBeenCalled();
current.closePicker();
});
it("retains control for Native media but releases unused Browser and ended-share control", () => {
const current = fixture();
current.releaseUnused();
expect(current.client.close).not.toHaveBeenCalled();
current.disposeNative("older-share");
expect(current.client.stopShare).not.toHaveBeenCalled();
current.disposeNative("share");
expect(current.client.stopShare).toHaveBeenCalledWith("share");
expect(current.client.close).toHaveBeenCalledOnce();
const browser = fixture();
browser.nativeShareGenerationRef.current = null;
browser.roomMutationRef.current = {};
browser.releaseUnused();
expect(browser.client.close).not.toHaveBeenCalled();
browser.context.finishRoomMutation(browser.roomMutationRef.current);
expect(browser.client.close).toHaveBeenCalledOnce();
});
it.each([false, "reject"])("keeps applied Browser capture after optional ingress fails: %s", async (failure) => {
const current = fixture();
const optional = ingress();
+1 -14
View File
@@ -87,7 +87,7 @@ const qualityEvidence = {
describe("client signaling protocol", () => {
it("uses the current strict signaling generation", () => {
expect(SIGNALING_PROTOCOL).toBe("screener-v22");
expect(SIGNALING_PROTOCOL).toBe("screener-v23");
});
it("keeps signaling challenges strict and sequence-only", () => {
@@ -1249,7 +1249,6 @@ describe("server signaling protocol", () => {
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
roomExpiresAt: "2026-08-18T18:00:00.000Z",
maxViewers,
endpointMediaCopyCapacity: 2,
hostOnline: true,
@@ -1397,18 +1396,6 @@ describe("server signaling protocol", () => {
).toBe(true);
});
it("represents persistent rooms without a room expiry", () => {
expect(
serverMessageSchema.safeParse({
...authenticatedMessage(8),
roomExpiresAt: null,
}).success,
).toBe(true);
expect(
serverMessageSchema.safeParse({ type: "sharing-stopped" }).success,
).toBe(true);
});
it("requires a complete bounded peer-assisted assignment", () => {
const peerAssisted = {
...authenticatedMessage(8),
@@ -0,0 +1,129 @@
import { readFileSync } from "node:fs";
import { createContext, runInContext } from "node:vm";
import ts from "typescript";
import { expect, it, vi } from "vitest";
// Exercise the page's connection owner without mounting media or room signaling.
const source = ts.createSourceFile("ViewerPage.tsx", readFileSync(
new URL("../src/client/pages/ViewerPage.tsx", import.meta.url), "utf8",
), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const owners = new Set(["nativeClientPromise", "nativeViewerAvailable", "acquireNativeClient"]);
const declarations: string[] = [];
let cleanup = "";
function collect(node: ts.Node): void {
if (ts.isVariableDeclaration(node) && owners.has(node.name.getText(source))) {
declarations.push(`let ${node.getText(source)};`);
}
if (ts.isExpressionStatement(node) && node.getText(source).startsWith("void nativeClientPromise?.then")) {
cleanup = node.getText(source);
}
ts.forEachChild(node, collect);
}
collect(source);
if (declarations.length !== owners.size || !cleanup) throw new Error("Viewer Native owner was not found");
const executable = ts.transpile(`
let active = true;
${declarations.join("\n")}
globalThis.acquire = acquireNativeClient;
globalThis.disable = () => { nativeViewerAvailable = false; };
globalThis.dispose = () => { active = false; ${cleanup} };
`, { target: ts.ScriptTarget.ES2022 });
function control() {
let onClose: () => void = () => undefined;
return {
close: vi.fn(),
onClose: (listener: () => void) => { onClose = listener; },
disconnect: () => onClose(),
};
}
function fixture(launchedByClient = true) {
const connect = vi.fn(async (): Promise<ReturnType<typeof control> | null> => null);
const context = createContext({ launchedByClient, NativeClient: { connect } });
runInContext(executable, context);
return {
connect,
acquire: context.acquire as () => Promise<ReturnType<typeof control> | null>,
disable: context.disable as () => void,
dispose: context.dispose as () => void,
};
}
it.each([false, true])("retries an absent or rejected Client on the next offer: reject=%s", async (reject) => {
const current = fixture();
if (reject) current.connect.mockRejectedValueOnce(new Error("Client unavailable"));
await expect(current.acquire()).resolves.toBeNull();
expect(current.connect).toHaveBeenCalledOnce();
const client = control();
current.connect.mockResolvedValue(client);
await expect(current.acquire()).resolves.toBe(client);
await expect(current.acquire()).resolves.toBe(client);
expect(current.connect).toHaveBeenCalledTimes(2);
current.dispose();
await Promise.resolve();
expect(client.close).toHaveBeenCalledOnce();
});
it("shares pending discovery and rediscovers after the idle Client disconnects", async () => {
const current = fixture();
const client = control();
let resolve!: (value: typeof client) => void;
current.connect.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const first = current.acquire();
const second = current.acquire();
expect(current.connect).toHaveBeenCalledOnce();
resolve(client);
await expect(Promise.all([first, second])).resolves.toEqual([client, client]);
client.disconnect();
const replacement = control();
current.connect.mockResolvedValue(replacement);
await expect(current.acquire()).resolves.toBe(replacement);
expect(current.connect).toHaveBeenCalledTimes(2);
current.dispose();
});
it("closes discovery completed after unmount and refuses further acquisition", async () => {
const current = fixture();
const client = control();
let resolve!: (value: typeof client) => void;
current.connect.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const acquiring = current.acquire();
current.dispose();
resolve(client);
await expect(acquiring).resolves.toBeNull();
expect(client.close).toHaveBeenCalledOnce();
await expect(current.acquire()).resolves.toBeNull();
expect(current.connect).toHaveBeenCalledOnce();
});
it("does not hand an already closed discovery result to a media peer", async () => {
const current = fixture();
const closed = control();
closed.onClose = (listener) => listener();
current.connect.mockResolvedValueOnce(closed);
await expect(current.acquire()).resolves.toBeNull();
const replacement = control();
current.connect.mockResolvedValue(replacement);
await expect(current.acquire()).resolves.toBe(replacement);
expect(current.connect).toHaveBeenCalledTimes(2);
current.dispose();
});
it("keeps proven bridge failure disabled without closing another peer's shared control", async () => {
const current = fixture();
const client = control();
current.connect.mockResolvedValue(client);
await current.acquire();
current.disable();
await expect(current.acquire()).resolves.toBeNull();
expect(current.connect).toHaveBeenCalledOnce();
expect(client.close).not.toHaveBeenCalled();
current.dispose();
await Promise.resolve();
expect(client.close).toHaveBeenCalledOnce();
const browser = fixture(false);
await expect(browser.acquire()).resolves.toBeNull();
expect(browser.connect).not.toHaveBeenCalled();
});