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:
1 parent
c9a6642ac1
commit
35f9251088
67 files changed
+1553
-1441
No files matched your search
+4
-6
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": ""},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "当前操作不可用",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+28
-32
@@ -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\":\"ab\"}",
|
||||
"json": "{\"type\":\"authenticate\",\"protocol\":\"screener-v23\",\"roomId\":\"1234\",\"role\":\"viewer\",\"clientId\":\"client_12345678\",\"displayName\":\"ab\"}",
|
||||
"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\"}"
|
||||
|
||||
@@ -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: "对应的观看连接已经离开",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user