diff --git a/.env.example b/.env.example
index a5bdc19..37c4330 100644
--- a/.env.example
+++ b/.env.example
@@ -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.
diff --git a/cmd/screener-server/main.go b/cmd/screener-server/main.go
index e9d3464..395774e 100644
--- a/cmd/screener-server/main.go
+++ b/cmd/screener-server/main.go
@@ -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{
diff --git a/deploy/container/Dockerfile b/deploy/container/Dockerfile
index b794cc7..b1889b3 100644
--- a/deploy/container/Dockerfile
+++ b/deploy/container/Dockerfile
@@ -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"]
diff --git a/deploy/systemd/screener.service.example b/deploy/systemd/screener.service.example
index 736b41f..4d9af13 100644
--- a/deploy/systemd/screener.service.example
+++ b/deploy/systemd/screener.service.example
@@ -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
diff --git a/docs/adr/0002-memory-resident-protected-rooms.md b/docs/adr/0002-memory-resident-protected-rooms.md
index 4a837ce..349e2ee 100644
--- a/docs/adr/0002-memory-resident-protected-rooms.md
+++ b/docs/adr/0002-memory-resident-protected-rooms.md
@@ -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,
diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md
index 64cbe90..59fcd96 100644
--- a/docs/adr/0005-automatic-hybrid-media-routing.md
+++ b/docs/adr/0005-automatic-hybrid-media-routing.md
@@ -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
diff --git a/docs/deployment.md b/docs/deployment.md
index 192439f..aa2d40c 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -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
diff --git a/docs/design/piik-brand.html b/docs/design/piik-brand.html
new file mode 100644
index 0000000..c1b8876
--- /dev/null
+++ b/docs/design/piik-brand.html
@@ -0,0 +1,239 @@
+
+
+
+
+
+
Piik · Brand study / 品牌预览
+
+
+
+ Piik / 品牌预览
+ Dark room
+
+
+
+
01 / A name taking shape
+
A little peek. A familiar face.
+
From “Peek” in games to a little TV for watching together. Keep the wink. Give it a name.
+
探个头,看看朋友在玩什么。名字换了,小电视还是那个小电视。
+
+
+
+ Piik → TV
+
+ Piik becomes the winking TV
+ 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.
+
+
+
Same strokes. Familiar wink.P 撑开屏幕,ii 变成眼睛和脚,k 的两笔转成电视天线。
+
+ 重播 / Replay
+ 暂停 / Pause
+ 字标 / Wordmark
+ 电视 / TV
+
+
减少动态 / Reduced motion
+
+
+ Motion is optional. Piik is the recommended spelling; PiiK and piik are the two alternatives below. The original TV remains the small icon.
+ Three ways to write it三个名字写法,同一套笔画 One drawing style. Only the casing changes.
+
+
+ 01 Recommended
+ Piik
+ Piik A clear name, with two curious eyes.主方案。首字母清楚,两个 i 留一点好奇心。
+
+
+ 02 More contrast
+ PiiK
+ PiiK A stronger K gives the name a sharper silhouette.两侧更高、更硬朗,也更抢眼。
+
+
+ 03 Softer
+ piik
+ piik Relaxed and compact, with a lower centre of gravity.更轻松。小写 p 会伸到基线下方。
+
+
+
+
+
02 / The two little details
+
Two dots. Two curious eyes.
+
Round dots keep the ii easy to read, then become the familiar TV eyes.
+
The wordmark below is shown at everyday sizes.
+
两个圆点保留 ii 的辨识度,再变成小电视的眼睛。下面按实际尺寸查看。
+
Keep the existing TV for the tiny icon. No four-letter favicon puzzle.
+
+
+ Height Piik
+
+ 64 px
+ 32 px
+ 24 px
+
+
+
+ At home on a page放到页面上:官网首屏的方向草图 A static website. The sharing app stays a tool.
+
+
+
+ Download Guide GitHub
+
+
+
Your game. Their front row. Share your screen. Bring your friends. They watch right in their browsers.
开一局,朋友坐前排。 发个邀请,打开浏览器就能看。
+
+
+
+
01 / Pick A screen, a window, a game.选好想分享的画面。
+
02 / Invite Send a link to your friends.把邀请链接发给朋友。
+
03 / Hang out A little room for your people.人到齐,就一起看。
+
+
+ Display / 显示名称 Piik Technical / 技术名称 piik-client Website / 官网 piik.tv Page title / 页签 Piik | 6020 · 📺
+
+
+
+
diff --git a/docs/operations/self-hosting.md b/docs/operations/self-hosting.md
index 801ad43..0643267 100644
--- a/docs/operations/self-hosting.md
+++ b/docs/operations/self-hosting.md
@@ -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.
diff --git a/docs/product/presentation-lifecycle.md b/docs/product/presentation-lifecycle.md
index b17d108..7b278da 100644
--- a/docs/product/presentation-lifecycle.md
+++ b/docs/product/presentation-lifecycle.md
@@ -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
diff --git a/docs/product/rooms-access.md b/docs/product/rooms-access.md
index 4200578..fecac34 100644
--- a/docs/product/rooms-access.md
+++ b/docs/product/rooms-access.md
@@ -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
diff --git a/docs/product/routing-transport.md b/docs/product/routing-transport.md
index 7f66dfd..79fc14c 100644
--- a/docs/product/routing-transport.md
+++ b/docs/product/routing-transport.md
@@ -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
diff --git a/docs/project-memory.md b/docs/project-memory.md
index adadf5b..9127b9f 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -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.
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 9842e68..3bf1d70 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -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
diff --git a/docs/research/cross-restart-room-recovery.md b/docs/research/cross-restart-room-recovery.md
index f5752b2..280d867 100644
--- a/docs/research/cross-restart-room-recovery.md
+++ b/docs/research/cross-restart-room-recovery.md
@@ -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.
diff --git a/docs/status.md b/docs/status.md
index 2fa2a64..726e41a 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -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
diff --git a/docs/todo.md b/docs/todo.md
index 5bd4fb4..298245f 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -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.
diff --git a/internal/server/app/app.go b/internal/server/app/app.go
index 1bd2424..7d3f680 100644
--- a/internal/server/app/app.go
+++ b/internal/server/app/app.go
@@ -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,
})
}
diff --git a/internal/server/app/app_test.go b/internal/server/app/app_test.go
index d39a9eb..fbff959 100644
--- a/internal/server/app/app_test.go
+++ b/internal/server/app/app_test.go
@@ -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)
diff --git a/internal/server/app/routes.go b/internal/server/app/routes.go
index 4ed72c1..e87cbed 100644
--- a/internal/server/app/routes.go
+++ b/internal/server/app/routes.go
@@ -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
diff --git a/internal/server/config/config.go b/internal/server/config/config.go
index f6a5ba8..6bb2533 100644
--- a/internal/server/config/config.go
+++ b/internal/server/config/config.go
@@ -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
diff --git a/internal/server/config/config_test.go b/internal/server/config/config_test.go
index 8adfeff..cbc8ae8 100644
--- a/internal/server/config/config_test.go
+++ b/internal/server/config/config_test.go
@@ -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": ""},
diff --git a/internal/server/config/local.go b/internal/server/config/local.go
index 3576152..5c0bf0a 100644
--- a/internal/server/config/local.go
+++ b/internal/server/config/local.go
@@ -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,
diff --git a/internal/server/config/local_test.go b/internal/server/config/local_test.go
index e6d9b9e..57817e7 100644
--- a/internal/server/config/local_test.go
+++ b/internal/server/config/local_test.go
@@ -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)
}
diff --git a/internal/server/protocol/constants.go b/internal/server/protocol/constants.go
index 6c75d64..ed692a4 100644
--- a/internal/server/protocol/constants.go
+++ b/internal/server/protocol/constants.go
@@ -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
diff --git a/internal/server/protocol/contract_test.go b/internal/server/protocol/contract_test.go
index daf834d..1df085a 100644
--- a/internal/server/protocol/contract_test.go
+++ b/internal/server/protocol/contract_test.go
@@ -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)
diff --git a/internal/server/protocol/http.go b/internal/server/protocol/http.go
index 37b1b6b..f52ba21 100644
--- a/internal/server/protocol/http.go
+++ b/internal/server/protocol/http.go
@@ -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.
diff --git a/internal/server/protocol/scalars.go b/internal/server/protocol/scalars.go
index 18d9740..7fc195c 100644
--- a/internal/server/protocol/scalars.go
+++ b/internal/server/protocol/scalars.go
@@ -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(
diff --git a/internal/server/protocol/server.go b/internal/server/protocol/server.go
index c0eea1f..9804e52 100644
--- a/internal/server/protocol/server.go
+++ b/internal/server/protocol/server.go
@@ -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
diff --git a/internal/server/protocol/types.go b/internal/server/protocol/types.go
index 25a5f07..30e00f1 100644
--- a/internal/server/protocol/types.go
+++ b/internal/server/protocol/types.go
@@ -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 {
diff --git a/internal/server/room/compat_test.go b/internal/server/room/compat_test.go
deleted file mode 100644
index 6275cd7..0000000
--- a/internal/server/room/compat_test.go
+++ /dev/null
@@ -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
-}
diff --git a/internal/server/room/database.go b/internal/server/room/database.go
index c411410..028d948 100644
--- a/internal/server/room/database.go
+++ b/internal/server/room/database.go
@@ -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)
diff --git a/internal/server/room/database_test.go b/internal/server/room/database_test.go
index 1f864ba..0f68fe2 100644
--- a/internal/server/room/database_test.go
+++ b/internal/server/room/database_test.go
@@ -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)
- }
-}
diff --git a/internal/server/room/doc.go b/internal/server/room/doc.go
index 84d73cb..b5a6214 100644
--- a/internal/server/room/doc.go
+++ b/internal/server/room/doc.go
@@ -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
diff --git a/internal/server/room/store.go b/internal/server/room/store.go
index 607c86d..7418475 100644
--- a/internal/server/room/store.go
+++ b/internal/server/room/store.go
@@ -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 {
diff --git a/internal/server/room/store_test.go b/internal/server/room/store_test.go
index 78caa46..0498007 100644
--- a/internal/server/room/store_test.go
+++ b/internal/server/room/store_test.go
@@ -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) {
diff --git a/internal/server/room/testdata/node-rooms.db b/internal/server/room/testdata/node-rooms.db
deleted file mode 100644
index 3303a6f..0000000
Binary files a/internal/server/room/testdata/node-rooms.db and /dev/null differ
diff --git a/internal/server/route/api.go b/internal/server/route/api.go
index bd1e755..3303436 100644
--- a/internal/server/route/api.go
+++ b/internal/server/route/api.go
@@ -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
diff --git a/internal/server/route/controller.go b/internal/server/route/controller.go
index a297587..946d94d 100644
--- a/internal/server/route/controller.go
+++ b/internal/server/route/controller.go
@@ -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
diff --git a/internal/server/route/controller_test.go b/internal/server/route/controller_test.go
index 8b5465f..ffb9ff8 100644
--- a/internal/server/route/controller_test.go
+++ b/internal/server/route/controller_test.go
@@ -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{})
diff --git a/internal/server/signal/presence.go b/internal/server/signal/presence.go
index 60a96cd..847297b 100644
--- a/internal/server/signal/presence.go
+++ b/internal/server/signal/presence.go
@@ -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")
}
}
diff --git a/internal/server/signal/recovery_test.go b/internal/server/signal/recovery_test.go
new file mode 100644
index 0000000..252b4e3
--- /dev/null
+++ b/internal/server/signal/recovery_test.go
@@ -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)
+ }
+ })
+ })
+ }
+}
diff --git a/internal/server/signal/router.go b/internal/server/signal/router.go
index 209bf7b..2e1fb66 100644
--- a/internal/server/signal/router.go
+++ b/internal/server/signal/router.go
@@ -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
diff --git a/internal/server/signal/router_pump.go b/internal/server/signal/router_pump.go
index baef594..8159d0d 100644
--- a/internal/server/signal/router_pump.go
+++ b/internal/server/signal/router_pump.go
@@ -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 := ""
diff --git a/internal/server/signal/router_test.go b/internal/server/signal/router_test.go
index 7540c86..64f5de0 100644
--- a/internal/server/signal/router_test.go
+++ b/internal/server/signal/router_test.go
@@ -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 {
diff --git a/internal/server/signal/server.go b/internal/server/signal/server.go
index 4a51740..c6aadc6 100644
--- a/internal/server/signal/server.go
+++ b/internal/server/signal/server.go
@@ -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)
diff --git a/internal/server/signal/server_test.go b/internal/server/signal/server_test.go
index f9ae1a6..cdc165e 100644
--- a/internal/server/signal/server_test.go
+++ b/internal/server/signal/server_test.go
@@ -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{}
diff --git a/scripts/access-privacy-gate.ts b/scripts/access-privacy-gate.ts
index 3669a49..2e459b5 100644
--- a/scripts/access-privacy-gate.ts
+++ b/scripts/access-privacy-gate.ts
@@ -261,6 +261,7 @@ async function main(): Promise {
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
diff --git a/scripts/client-browser-fanout-gate.ts b/scripts/client-browser-fanout-gate.ts
index d2be3c9..ebae63e 100644
--- a/scripts/client-browser-fanout-gate.ts
+++ b/scripts/client-browser-fanout-gate.ts
@@ -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}`,
diff --git a/src/client/components/living/CaptureSourcePicker.tsx b/src/client/components/living/CaptureSourcePicker.tsx
index 8aed582..55e9d10 100644
--- a/src/client/components/living/CaptureSourcePicker.tsx
+++ b/src/client/components/living/CaptureSourcePicker.tsx
@@ -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("window");
+ const [tab, setTab] = useState(initialTab);
const [shareAudio, setShareAudio] = useState(initialAudio);
const activeTab = tab === "browser" && !browserAvailable ? "window" : tab;
diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts
index e980c19..77b2f23 100644
--- a/src/client/lib/session.ts
+++ b/src/client/lib/session.ts
@@ -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,
};
diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts
index 0ae4cf9..158e66d 100644
--- a/src/client/lib/signaling.ts
+++ b/src/client/lib/signaling.ts
@@ -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",
]);
diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts
index fc306df..f57bb0a 100644
--- a/src/client/locales/en.ts
+++ b/src/client/locales/en.ts
@@ -101,7 +101,7 @@ export const en: Record = {
"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 = {
"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 = {
"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 = {
"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",
diff --git a/src/client/locales/zh.ts b/src/client/locales/zh.ts
index 4daf572..3a648c5 100644
--- a/src/client/locales/zh.ts
+++ b/src/client/locales/zh.ts
@@ -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": "当前操作不可用",
diff --git a/src/client/media/viewer-presentation.ts b/src/client/media/viewer-presentation.ts
index 35b64a8..f9ae6e7 100644
--- a/src/client/media/viewer-presentation.ts
+++ b/src/client/media/viewer-presentation.ts
@@ -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":
diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx
index 614382a..84eef3a 100644
--- a/src/client/pages/HostPage.tsx
+++ b/src/client/pages/HostPage.tsx
@@ -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 {
- await nativeShareCleanupRef.current;
- const current = nativeClientRef.current;
- if (current) return current;
+ function acquireNativeClient(): Promise {
if (nativeClientConnectRef.current) return nativeClientConnectRef.current;
- const connecting = NativeClient.connect().then((client) => {
+ const connecting: Promise = 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 {
@@ -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 {
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 {
- 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 ? (
=> {
- 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.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" ||
diff --git a/src/client/pages/host-page-notices.ts b/src/client/pages/host-page-notices.ts
index 72afd1b..2a7ab51 100644
--- a/src/client/pages/host-page-notices.ts
+++ b/src/client/pages/host-page-notices.ts
@@ -28,7 +28,6 @@ const HOST_SERVER_ERROR_NOTICE: Record = {
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",
diff --git a/src/client/webrtc/viewer-relay.ts b/src/client/webrtc/viewer-relay.ts
index b1598be..a190e7c 100644
--- a/src/client/webrtc/viewer-relay.ts
+++ b/src/client/webrtc/viewer-relay.ts
@@ -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);
}
}
diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts
index 4c27273..78dbed9 100644
--- a/src/shared/protocol.ts
+++ b/src/shared/protocol.ts
@@ -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;
diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts
index f5be867..3efdefe 100644
--- a/tests/client-state.test.ts
+++ b/tests/client-state.test.ts
@@ -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,
diff --git a/tests/fixtures/wire-samples.json b/tests/fixtures/wire-samples.json
index 27baee3..ddacb88 100644
--- a/tests/fixtures/wire-samples.json
+++ b/tests/fixtures/wire-samples.json
@@ -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\"}"
diff --git a/tests/host-page-notice.test.ts b/tests/host-page-notice.test.ts
index 59ca21d..4215526 100644
--- a/tests/host-page-notice.test.ts
+++ b/tests/host-page-notice.test.ts
@@ -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: "对应的观看连接已经离开",
diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts
index 4489c95..d578796 100644
--- a/tests/host-peer-tracks.test.ts
+++ b/tests/host-peer-tracks.test.ts
@@ -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 () => {
diff --git a/tests/host-quality-ownership.test.ts b/tests/host-quality-ownership.test.ts
index e905e8d..9d5e9ca 100644
--- a/tests/host-quality-ownership.test.ts
+++ b/tests/host-quality-ownership.test.ts
@@ -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 => undefined) };
const state = {
- debugEvent, debugOperation,
+ debugError, debugEvent, debugOperation,
+ launchedByClient, NativeClient: { connect: vi.fn(async (): Promise => null) },
+ nativeClientConnectRef: ref | 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(null), nativeSourcePathRef: ref(null),
+ setNativeSources: vi.fn(), defaultNativeCapturePath: () => ({ adapterIndex: 0, encoderIndex: 0 }),
+ roomMutationRef: ref(null), setRoomMutation: vi.fn(),
+ generationRef: ref(0), shareGenerationRef: ref("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(original), advancedQualityRef: ref(original),
qualityChangeRef: ref(null), pendingQualityChangeRef: ref(null),
activeGenerationRef: ref(1), streamRef: ref(stream), sourceSwitchRef: ref(null),
- nativeModeRef: ref(false), nativeClientRef: ref(client), nativeShareGenerationRef: ref("share"),
+ nativeModeRef: ref(false), nativeClientRef: ref(client), nativeShareGenerationRef: ref("share"),
nativeMediaIngressRef: ref | 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([]),
};
- 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,
+ 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,
+ discover: context.acquireNativeClient as () => Promise,
+ requestShare: context.requestSharing as () => void,
+ startNative: () => context.startNativeShare(1, "share", { client, target: {}, audio: false, path: {} }) as Promise,
+ startOptionalIngress: () => context.startBrowserNativeIngress(1, "share", stream) as Promise,
change: context.changeQuality as (profile: QualitySettings) => Promise,
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();
+ 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();
+ 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();
+ 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();
+ 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();
diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts
index 76eb7ab..8d03004 100644
--- a/tests/protocol.test.ts
+++ b/tests/protocol.test.ts
@@ -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),
diff --git a/tests/viewer-native-client-ownership.test.ts b/tests/viewer-native-client-ownership.test.ts
new file mode 100644
index 0000000..ca6ecdc
--- /dev/null
+++ b/tests/viewer-native-client-ownership.test.ts
@@ -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 | null> => null);
+ const context = createContext({ launchedByClient, NativeClient: { connect } });
+ runInContext(executable, context);
+ return {
+ connect,
+ acquire: context.acquire as () => Promise | 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();
+});