open-standards/joule-code/joule-code-rs/crates/joule-moq-relay/tests/room_authority.rs
Transaction Science 6250fa5a4e
Some checks failed
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 33s
WAI operation catalogue / does this change touch it (push) Successful in 28s
wai-app and Create / wai-app · does this change touch it (push) Successful in 29s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 30s
WAI Studio / does this change touch it (push) Successful in 16s
wai-sink-swift native sink package / does this change touch it (push) Successful in 1m32s
wai-quantum studio and desktop / does this change touch it (push) Successful in 1m34s
wai-fleet service / does this change touch it (push) Successful in 1m40s
wai-audit collector and player integration / does this change touch it (push) Successful in 1m39s
wai-ledger service / does this change touch it (push) Successful in 1m37s
wai-quantum for JavaScript / does this change touch it (push) Successful in 1m34s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 1m41s
wai-sink-android native sink library / does this change touch it (push) Successful in 1m35s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 2m11s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Failing after 4m29s
joule-code conformance gate / joule-moq-relay, every target (push) Successful in 7m32s
joule-code conformance gate / joule-moq-ingest, every target (push) Successful in 9m9s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 6m1s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 8m1s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 11s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 45s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m22s
joule-code conformance gate / Build + round-trip C1-C4 vectors (push) Successful in 2m54s
WAI operation catalogue / the catalogue and every door · linux (push) Failing after 2m27s
wai-audit collector and player integration / collector · linux (push) Successful in 2m23s
wai-ledger service / service · linux (push) Successful in 3m55s
wai-fleet service / service · linux (push) Successful in 4m31s
wai-quantum for JavaScript / javascript · linux (push) Successful in 6m17s
wai-quantum studio and desktop / studio · browser (push) Successful in 7m43s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 10m31s
WAI Studio / Studio in WebKit · linux (push) Successful in 22m31s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Failing after 1m31s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 3m35s
wai release binaries build on Windows / the seven release binaries · windows (push) Successful in 18m5s
wai-quantum studio and desktop / desktop app · windows (push) Successful in 10m46s
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (push) Successful in 1m3s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 56s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 7m44s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 9m17s
wai-fleet service / service · macos (push) Successful in 1m4s
wai-quantum studio and desktop / desktop app · macos (push) Successful in 4m41s
wai-ledger service / service · macos (push) Successful in 1m7s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Successful in 2m35s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 7m17s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 15m0s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 15m50s
The relay reads the client's SETUP first (Privacy Pass auth -04), and what the draft-23 package left
SETUP order. draft-ietf-moq-privacy-pass-auth-04 §3.5.1 has a relay that
authenticates SETUP process the client's SETUP before it sends its own; WAI
age-assurance §5.1 and live-rooms §10.5.2 had the relay send first. The decision
was to follow -04, and the texts, the relay, the clients and the corpora do.

The relay's SETUP used to carry the challenge the client's SETUP then proved its
grant over, which the new order cannot do in one session. It is now -04's own
flow (§3.5.6.1, Figure 4), and live-rooms §10.5.3's over HTTP: every SETUP a
relay sends carries a fresh GRANT_CHALLENGE, good for the proofs of one later
SETUP at that relay for limits.grant_challenge_ms (60 s), kept by the relay
(src/challenge.rs) and consumed by the SETUP it admits. A client that holds none
presents its grant unproved, is refused pop_missing with a challenge, and proves
on a second session; one that holds the challenge of the relay's last SETUP to
it proves on its first. A refused SETUP is answered with the relay's SETUP
carrying a new Setup Option, GRANT_REFUSED (0x3A53, the reason's code), and the
session is closed UNAUTHORIZED once the client has left or
limits.setup_refusal_wait_ms (2 s) has passed: a close carries nothing sent
before it, which -04 notes of its own challenge, so the refusal is said in the
SETUP and the client that reads it leaves at once. A consumed challenge is
pop_replayed; one never issued, another relay's, or too old, pop_wrong_challenge.

The clients: joule_moq_relay::client (Client::open, connect_proving, challenge;
its SETUP always first), the room service's clock and participants, the remote
host agent, relay-to-relay links (a link's first SETUP is refused
relay_id_unproved and it dials again at once with its proof), and the browser
(MoqSession.connect's redial, SetupRefused). The interop client and the ingest
gateway already sent first and present no grant. The order is the same at every
draft; grants are proved at -23, -22, -18 and -16 in the relay's test. Not
interoperable with the order before where a grant is presented: a client that
waits for the relay's SETUP is sent none, and relays that peer change together.
Sessions that present no grant are unaffected.

What the draft-23 package left:
- A -23 session's subgroup streams and datagrams are read by -23's object rules
  (a supported property outside its scope or repeated makes the track malformed;
  a grease value in the Mandatory Track Property range is no Mandatory Track
  Property); a -22 session keeps -22's reading.
- Relays peer at -23: RELAY_PATH in the -23 codec and its bridge, a relay id
  taken at -23, links offering -23 and -22. An unnegotiated path closes a -23
  session INVALID_PARAMETER. The -23 corpus gains 6 vectors (125, 70 negative)
  and moqt23_check.py reads the extension.
- A grant that expires ends its subscriptions, each with PUBLISH_DONE
  EXPIRED_AUTH_TOKEN, and not the session, at -23; a -23 session that publishes
  by the grant, and every session at the older drafts, is closed as before.
- LARGEST_OBJECT against -23 §7.5: confirmed but for one source, a publisher's
  PUBLISH_STATE_NOTIFY, which is taken now; held by a vector of six steps. Kept
  and stated: an object counts once whole, not from its first byte.

Tests, each against planted faults: grant_proof (the relay sends nothing before
the client's SETUP; the second session; seven refusals, each with its reason),
room_authority (expiry at -23 beside -22), draft23 (the object rules against the
same objects at -22; the LARGEST_OBJECT vector), cascade and control_sessions
(a link at -23, opened on its second dial; the path at -23), the browser
session's unit tests. Falsified: twelve of the changes reverted in turn, each
failing a test.

live-rooms §10.5.2, §10.5.4, §10.7 and §13, and age-assurance §5.1 and §5.5,
say the order and what a challenge is bound to now. The rooms corpus renames one
proof vector (acknowledged in the change note); the registry lists
GRANT_REFUSED.

Co-Authored-By: Claude <[email protected]>
2026-11-10 17:22:02 -05:00

275 lines
16 KiB
Rust

//! A relay that serves a room whose authority issues grants as visitors arrive (WAI
//! live-rooms §10.2, §10.5): it holds the authority's key, not a rule for each grant.
//!
//! - A grant the authority signed, presented in SETUP with its proof, gives the rights
//! §10.2 maps it onto: a publisher its own media namespaces, a viewer the room to
//! subscribe to; the same grant with no proof closes the session, and a grant signed by
//! another key gives nothing.
//! - The door, with a rule over `["*", "*", "video"]` and `trust_field = 1`, holds each
//! publisher's video to the key its namespace names: a group signed by another key is
//! dropped there.
//! - The relay's limits end a session: at its session limit (`NO_ERROR`), when its grant
//! expires (`EXPIRED_AUTH_TOKEN`), and past its ingress limit (`INTERNAL_ERROR`).
//! - At -23 a grant that expires ends what it gave and not the session: each subscription
//! held by it ends with PUBLISH_DONE `EXPIRED_AUTH_TOKEN`, the session stays open, and a new
//! request by the expired grant is refused; a -22 session beside it, under a grant of the
//! same expiry, is closed `EXPIRED_AUTH_TOKEN` as before, and so is a -23 session that
//! publishes by the expired grant.
//!
//! Over the WebSocket binding, the transport a deployment behind an HTTP proxy uses.
mod common;
use common::*;
use ed25519_dalek::{Signer, SigningKey};
use joule_moq::draft22::{Parameters, RequestErrorCode, SessionError, Token};
use joule_moq_relay::client::{Client, ClientOptions};
use joule_moq_relay::config::{Authority, DoorRule};
use joule_moq_relay::transport::TransportError;
use joule_moq_relay::RelayServer;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::time::timeout;
fn now_s() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
}
/// A grant laid out as live-rooms §4.1 lays it out: `publish` the media bits, a policy that
/// permits the chat kind when `events`, signed by `authority`.
fn grant(authority: &SigningKey, room: [u8; 32], principal: &SigningKey, nbf: u64, exp: u64, publish: u8, events: bool) -> Vec<u8> {
let mut g = b"WRG1\x01".to_vec();
g.extend_from_slice(&room);
g.extend_from_slice(&principal.verifying_key().to_bytes());
g.extend_from_slice(&[0; 40]);
g.extend_from_slice(&nbf.to_le_bytes());
g.extend_from_slice(&exp.to_le_bytes());
g.extend_from_slice(&[0, publish, 1, 0, 0, 0]);
g.extend_from_slice(&[0; 32]);
g.extend_from_slice(b"WEP1\x01");
let mut kinds = [0u8; 32];
if events {
kinds[0] = 1 << 3;
}
g.extend_from_slice(&kinds);
g.extend_from_slice(&[0; 28]);
g.extend_from_slice(&[0xff; 16]);
g.extend_from_slice(&[0; 3]);
g.extend_from_slice(&[0, 0, 0, 0]);
let mut m = joule_moq::grant_proof::GRANT_DOMAIN.to_vec();
m.extend_from_slice(&g);
g.extend_from_slice(&authority.sign(&m).to_bytes());
g
}
fn target(server: &RelayServer) -> String {
format!("//{}{}", server.websocket_addr.unwrap(), server.relay().config().path)
}
fn with(server: &RelayServer, record: &[u8], key: &SigningKey) -> ClientOptions {
ClientOptions { grants: vec![(record.to_vec(), key.clone())], target: target(server), ..ClientOptions::default() }
}
async fn closed_with(c: &Client, code: SessionError, within: Duration, what: &str) -> String {
match timeout(within, c.conn.closed()).await.unwrap_or_else(|_| panic!("{what}: not closed in time")) {
TransportError::Closed { code: got, reason, .. } => {
assert_eq!(got, code.0, "{what}: {reason}");
reason
}
other => panic!("{what}: {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_authoritys_grants_their_proofs_the_door_and_the_limits() {
timeout(Duration::from_secs(180), run()).await.expect("the test finishes in time");
}
async fn run() {
let authority = SigningKey::from_bytes(&[0x61; 32]);
let stranger = SigningKey::from_bytes(&[0x62; 32]);
let (alice, bob, viewer) = (SigningKey::from_bytes(&[0x63; 32]), SigningKey::from_bytes(&[0x64; 32]), SigningKey::from_bytes(&[0x65; 32]));
let room = [0x7e; 32];
let r = hex::encode(room);
let server = relay(|c| {
c.auth.required = true;
c.auth.authorities = vec![Authority { key: hex::encode(authority.verifying_key().to_bytes()), rooms: vec![] }];
c.receipts.door = vec![DoorRule { prefix: vec!["*".into(), "*".into(), "video".into()], trust: vec![], trust_field: Some(1), hold_ms: 2_000 }];
})
.await;
let now = now_s();
let ga = grant(&authority, room, &alice, now - 60, now + 600, 0b011, true);
let gb = grant(&authority, room, &bob, now - 60, now + 600, 0b011, true);
let gv = grant(&authority, room, &viewer, now - 60, now + 600, 0, false);
let ta = Tracks::new(ns(&[&r, &hex::encode(alice.verifying_key().to_bytes()), "video"]), "0", 10, 5, 128);
let tb = Tracks::new(ns(&[&r, &hex::encode(bob.verifying_key().to_bytes()), "video"]), "0", 10, 5, 128);
// Alice publishes her video; the viewer subscribes to it and every object verifies.
let (a, _) = connect(&server, Via::WebSocket, with(&server, &ga, &alice)).await;
let _pa = publish_tracks(&a, &ta, None).await;
let (v, _) = connect(&server, Via::WebSocket, with(&server, &gv, &viewer)).await;
let sub = subscribe_both(&v, &ta, Parameters::new()).await;
for g in 0..5 {
publish_group(&a, &alice, &ta, g, &payload("a", g, 300)).await;
}
let got = collect_concurrent(&v, STEP, Duration::from_millis(500), |g| g.iter().filter(|x| x.header.track_alias == sub.media_alias).count() >= 5).await;
let (objects, verified, _, _) = verify(&got, &sub, &alice);
assert!(objects >= 5 && verified == objects, "{verified} of {objects} verified");
// Alice may not publish under Bob's namespace, the viewer may publish nothing of media,
// and neither may publish in another room.
let refused = a.publish(tb.ns.clone(), tb.media.clone(), 30, Parameters::new(), track_props(128, None)).await.expect_err("another principal's namespace");
assert!(refused.contains(&RequestErrorCode::UNAUTHORIZED.to_string()), "{refused}");
let refused = v.publish(ns(&[&r, &hex::encode(viewer.verifying_key().to_bytes()), "video"]), name("0"), 30, Parameters::new(), track_props(128, None)).await.expect_err("a viewer's media");
assert!(refused.contains(&RequestErrorCode::UNAUTHORIZED.to_string()), "{refused}");
// The door: Bob publishes in his own namespace with Alice's key; his group is dropped.
let (b, _) = connect(&server, Via::WebSocket, with(&server, &gb, &bob)).await;
let _pb = publish_tracks(&b, &tb, None).await;
let sb = subscribe_both(&v, &tb, Parameters::new()).await;
publish_group(&b, &alice, &tb, 0, &payload("b", 0, 300)).await;
let got = collect_concurrent(&v, Duration::from_secs(4), Duration::from_millis(500), |_| false).await;
assert!(got.iter().all(|x| x.header.track_alias != sb.media_alias || x.reset.is_some() || x.objects.is_empty()), "a group signed by another key went past the door");
assert!(server.relay().stats().refused_at_door >= 1 || server.relay().stats().groups_dropped_at_door >= 1, "the door refused it");
// The grant with no proof: the session is refused. A grant another key signed is no
// grant here: its requests are refused, its session stands.
let bare = ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: ga.clone() }], ..ClientOptions::default() };
let (c, _) = connect(&server, Via::WebSocket, bare).await;
closed_with(&c, SessionError::UNAUTHORIZED, STEP, "a grant with no proof").await;
let forged = grant(&stranger, room, &alice, now - 60, now + 600, 0b011, true);
let (c, _) = connect(&server, Via::WebSocket, with(&server, &forged, &alice)).await;
let refused = c.subscribe(ta.ns.clone(), ta.media.clone(), Parameters::new()).await.expect_err("a grant another key signed");
assert!(refused.contains(&RequestErrorCode::UNAUTHORIZED.to_string()), "{refused}");
for c in [&a, &b, &v, &c] {
c.close(0, "done");
}
server.shutdown().await;
// The limits, each on a relay of its own.
let limited = |f: fn(&mut joule_moq_relay::RelayConfig)| {
let key = hex::encode(authority.verifying_key().to_bytes());
relay(move |c| {
c.auth.required = true;
c.auth.authorities = vec![Authority { key, rooms: vec![] }];
f(c);
})
};
// At its session limit; a session whose token's rule is `unlimited` is not.
let s = limited(|c| {
c.limits.max_session_ms = 1_500;
c.auth.grants = vec![joule_moq_relay::config::Grant { prefix: vec![], token_sha256: hex::encode(joule_moq_relay::auth::sha256(b"service")), publish: false, subscribe: true, proof: false, unlimited: true, seek: false }];
})
.await;
let (c, _) = connect(&s, Via::WebSocket, with(&s, &gv, &viewer)).await;
let (svc, _) = connect(&s, Via::WebSocket, ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: b"service".to_vec() }], ..ClientOptions::default() }).await;
let why = closed_with(&c, SessionError::NO_ERROR, Duration::from_secs(10), "the session limit").await;
assert!(why.contains("session limit"), "{why}");
assert!(timeout(Duration::from_secs(2), svc.conn.closed()).await.is_err(), "an unlimited session outlives the limit");
svc.close(0, "done");
s.shutdown().await;
// When its grant expires.
let s = limited(|_| {}).await;
let short = grant(&authority, room, &viewer, now - 60, now_s() + 2, 0, false);
let (c, _) = connect(&s, Via::WebSocket, with(&s, &short, &viewer)).await;
closed_with(&c, SessionError::EXPIRED_AUTH_TOKEN, Duration::from_secs(10), "the grant's expiry").await;
s.shutdown().await;
// Past its ingress limit: 64 kbit/s over a second is 8 kB; ten 4 kB groups pass it.
let s = limited(|c| {
c.limits.max_ingress_kbps = 64;
c.limits.ingress_burst_ms = 1_000;
})
.await;
let (c, _) = connect(&s, Via::WebSocket, with(&s, &ga, &alice)).await;
let _p = publish_tracks(&c, &ta, None).await;
for g in 0..10 {
if c.send_subgroup(
joule_moq::draft22::data::SubgroupHeader { track_alias: ta.media_alias, group_id: g, subgroup_id: joule_moq::draft22::data::SubgroupId::Zero, publisher_priority: Some(128), properties: false, end_of_group: true, first_object: true },
&[joule_moq::draft22::data::SubgroupObject::normal(0, payload("x", g, 4_000))],
)
.await
.is_err()
{
break;
}
}
// The relay closes the session while the client may still be writing; then the client
// sees its pipe break before the close arrives, and the relay's own record says why.
match timeout(Duration::from_secs(10), c.conn.closed()).await.expect("closed in time") {
TransportError::Closed { code, reason, .. } => {
assert_eq!(code, SessionError::INTERNAL_ERROR.0, "{reason}");
assert!(reason.contains("ingress limit"), "{reason}");
}
other => {
let closed = until(Duration::from_secs(10), || s.relay().summaries().iter().any(|x| x.summary.close_code == Some(SessionError::INTERNAL_ERROR.0))).await;
assert!(closed, "the relay closed the session past its ingress limit (the client saw {other:?})");
}
}
s.shutdown().await;
}
/// A grant's expiry at -23 and at -22, side by side: three sessions under grants that expire
/// together. Planted: the older drafts' behaviour at -23 (the session closed, no PUBLISH_DONE)
/// fails the first viewer's checks, and an expiry that ended nothing fails its refused request.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_grant_that_expires_ends_its_subscription_at_23_and_the_session_at_22() {
timeout(Duration::from_secs(120), expiry()).await.expect("the test finishes in time");
}
async fn expiry() {
use joule_moq::draft::Draft;
use joule_moq::draft22::ControlMessage;
let authority = SigningKey::from_bytes(&[0x71; 32]);
let (alice, bob, v23, v22) = (SigningKey::from_bytes(&[0x73; 32]), SigningKey::from_bytes(&[0x74; 32]), SigningKey::from_bytes(&[0x75; 32]), SigningKey::from_bytes(&[0x76; 32]));
let room = [0x7d; 32];
let r = hex::encode(room);
let server = relay(|c| {
c.auth.required = true;
c.auth.authorities = vec![Authority { key: hex::encode(authority.verifying_key().to_bytes()), rooms: vec![] }];
})
.await;
let now = now_s();
let soon = now + 4;
let ga = grant(&authority, room, &alice, now - 60, now + 600, 0b011, false);
let ta = Tracks::new(ns(&[&r, &hex::encode(alice.verifying_key().to_bytes()), "video"]), "0", 10, 5, 128);
let tb = Tracks::new(ns(&[&r, &hex::encode(bob.verifying_key().to_bytes()), "video"]), "0", 20, 6, 128);
let (a, _) = connect(&server, Via::WebSocket, with(&server, &ga, &alice)).await;
let _pa = publish_tracks(&a, &ta, None).await;
// Three sessions under grants that expire at one second: a viewer at -23, a viewer at -22,
// and a publisher at -23.
let (c23, _) = connect(&server, Via::WsDraft(Draft::D23), with(&server, &grant(&authority, room, &v23, now - 60, soon, 0, false), &v23)).await;
let (c22, _) = connect(&server, Via::WebSocket, with(&server, &grant(&authority, room, &v22, now - 60, soon, 0, false), &v22)).await;
let (b, _) = connect(&server, Via::WsDraft(Draft::D23), with(&server, &grant(&authority, room, &bob, now - 60, soon, 0b011, false), &bob)).await;
let _pb = publish_tracks(&b, &tb, None).await;
let mut s23 = subscribe_both(&c23, &ta, Parameters::new()).await;
let _s22 = subscribe_both(&c22, &ta, Parameters::new()).await;
publish_group(&a, &alice, &ta, 0, &payload("a", 0, 300)).await;
let got = collect(&c23, STEP, |x| x.len() >= 2).await;
assert_eq!(got.len(), 2, "the -23 viewer is served while its grant is good");
assert_eq!(server.relay().stats().sessions_open, 4);
// The grants expire. At -23 each subscription the grant gave ends with PUBLISH_DONE
// EXPIRED_AUTH_TOKEN (0x7), the media track's and the receipts track's.
for (_, rx) in s23.held.iter_mut() {
match timeout(Duration::from_secs(15), rx.next()).await.expect("a PUBLISH_DONE at the grant's expiry") {
Ok(Some(ControlMessage::PublishDone(d))) => assert_eq!(d.status.0, 0x7, "EXPIRED_AUTH_TOKEN: {:?}", d.status),
other => panic!("the subscription ended otherwise: {other:?}"),
}
}
// At -22 the session ends, as before; and so does the -23 session that publishes by its
// grant, which no PUBLISH_DONE of the relay's can end.
closed_with(&c22, SessionError::EXPIRED_AUTH_TOKEN, Duration::from_secs(10), "the -22 session at its grant's expiry").await;
closed_with(&b, SessionError::EXPIRED_AUTH_TOKEN, Duration::from_secs(10), "the -23 session that publishes by the expired grant").await;
// The -23 viewer's session goes on: open, sent no GOAWAY, forwarded nothing more, and its
// expired grant gives it no new subscription.
assert!(timeout(Duration::from_millis(1500), c23.conn.closed()).await.is_err(), "the -23 session was closed at its grant's expiry");
assert!(c23.goaway().borrow().is_none());
publish_group(&a, &alice, &ta, 1, &payload("a", 1, 300)).await;
let more = collect(&c23, Duration::from_secs(2), |x| !x.is_empty()).await;
assert!(more.is_empty(), "an object forwarded on a subscription the expired grant gave: {more:?}");
let refused = c23.subscribe(ta.ns.clone(), ta.media.clone(), Parameters::new()).await.expect_err("a subscription by an expired grant");
assert!(refused.contains(&RequestErrorCode::UNAUTHORIZED.to_string()), "{refused}");
assert!(until(STEP, || server.relay().stats().sessions_open == 2).await, "the publisher and the -23 viewer, open");
a.close(0, "done");
c23.close(0, "done");
server.shutdown().await;
}