Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@ LOAD_METRICS_PORT=9464 cargo test --release --test load -- --ignored --nocapture
- TURN이 없습니다. UDP `:40000`에 직접 닿지 못하는 클라이언트는 연결할 수 없습니다.
- 방 하나는 미디어 루프 하나에서만 돌기 때문에 코어 하나를 넘지 못합니다. [#73](https://github.com/kimsun721/stream/issues/73)에서 방이 여러 루프에 걸치도록 바꿉니다.
- 방과 스트림 키는 메모리에만 있어서 재시작하면 사라집니다.
- HTTP 핸들러는 미디어 루프의 응답을 기다리는 동안 tokio 워커를 붙잡습니다. [ADR 0007](docs/decisions/0007-defer-sync-mpsc-recv-blocking.md) 참고.
- 프로세스 하나로 동작하며, 여러 서버로 부하를 나누지 않습니다.

## 문서
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@ LOAD_METRICS_PORT=9464 cargo test --release --test load -- --ignored --nocapture
- No TURN. A client that cannot reach UDP `:40000` directly cannot connect.
- A room lives on one media loop, so one room is bounded by one core. [#73](https://github.com/kimsun721/stream/issues/73) lets a room span loops.
- Rooms and stream keys live in memory. A restart drops them.
- An HTTP handler holds a tokio worker while it waits for a media loop to answer. See [ADR 0007](docs/decisions/0007-defer-sync-mpsc-recv-blocking.md).
- One process. Nothing spreads load across servers.

## Docs
Expand Down
3 changes: 1 addition & 2 deletions docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The external backend is treated as a black box that speaks HTTP. This server mak
- mux: one std thread, blocked on the socket.
- Media loops: `server.total_shards` std threads, the core count by default. Each is the sole owner of its `Rooms`, as [ADR 0001](../decisions/0001-actor-pattern.md) describes for the single loop it started with.

Every loop has one `sync_channel<SfuMessage>` that both the web threads and the mux write to, so a loop waits on one thing: its channel, until the next str0m deadline. The mux copies each datagram into an owned buffer to cross the thread boundary, and drops it rather than wait when a loop's channel is full.
Every loop has one `sync_channel<SfuMessage>` that both the web threads and the mux write to, so a loop waits on one thing: its channel, until the next str0m deadline. The mux copies each datagram into an owned buffer to cross the thread boundary, and drops it rather than wait when a loop's channel is full. The web threads do not wait either: a request to a full loop gets `503`.

All loops send on the same socket, so clients see one address.

Expand Down Expand Up @@ -80,7 +80,6 @@ The client opens a DataChannel **before** `createOffer`, so the DC is part of th

## Known limits

- HTTP handlers block on `recv()` while a loop works through its rooms. Deferred, see [ADR 0007](../decisions/0007-defer-sync-mpsc-recv-blocking.md).
- A room lives on one loop, so one room is bounded by one core. [#73](https://github.com/kimsun721/stream/issues/73) places clients instead.
- One process.
- A keyframe requested by one viewer goes to every viewer on that layer. See [ADR 0004](../decisions/0004-keyframe-broadcast-with-debounce.md).
2 changes: 1 addition & 1 deletion docs/decisions/0007-defer-sync-mpsc-recv-blocking.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Status

Accepted (2026-05-11)
Superseded by [ADR 0012](0012-reply-over-oneshot.md) (2026-10-08). Accepted (2026-05-11).

## Context

Expand Down
34 changes: 34 additions & 0 deletions docs/decisions/0012-reply-over-oneshot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 0012. Reply to HTTP handlers over `tokio::sync::oneshot`

## Status

Accepted (2026-10-08). Supersedes [ADR 0007](0007-defer-sync-mpsc-recv-blocking.md).

## Context

[ADR 0007](0007-defer-sync-mpsc-recv-blocking.md) left HTTP handlers blocking on `std::sync::mpsc::recv()` for a media loop's reply. It rejected `tokio::sync` channels because they would "couple the std-thread SFU to a tokio runtime or require bridging".

That premise was wrong when it was written. The types in `tokio::sync` are runtime agnostic, and `oneshot::Sender::send` is a plain function that a std thread can call. Nothing changed since to make it possible. The alternative was misjudged.

The deferral has a cost that is listed among the README's limits: a handler holds a tokio worker for as long as a loop takes to answer.

## Decision

- A reply travels over `tokio::sync::oneshot`. The media loop sends with a plain call and the handler awaits.
- Stream key resolution asks every loop, so its reply uses `tokio::sync::mpsc` with one sender per loop. A loop sends with `try_send`, which needs no runtime either, and the channel holds one reply per loop, so it never fills.
- The request channel stays `std::sync::mpsc::sync_channel`. A loop waits on it until str0m's next deadline, and tokio's `mpsc` has no blocking receive with a timeout outside a runtime. The mux feeds the same channel.
- A handler sends a request with `try_send` and answers `503` when the loop's channel is full. The channel also carries media from the mux, and media that waits reaches viewers late or not at all, while a refused request costs the client a retry. A refusal is counted in `stream_requests_refused` rather than logged, since refusals come in floods.
- Stream key resolution answers `503` if any loop's channel is full, not `401`, since the key may belong to that loop.

## Alternatives considered

- **`spawn_blocking` around the wait.** Frees the worker but parks a thread per waiting request.
- **Move the request channel to tokio as well.** No blocking receive with a timeout.
- **Keep deferring.** The reason given for deferring does not hold.
- **Blocking send, or an unbounded request channel.** Either keeps every request at the cost of media. A handler blocked on a full channel takes the next free slot ahead of the mux, and an unbounded channel queues datagrams behind requests without limit.

## Consequences

**Gain**: a handler no longer holds a worker while it waits or while a loop is full; a handler can put a timeout on a reply; one limit leaves the README.

**Lose**: `SfuMessage` now names tokio types, so the loop's message type depends on the tokio crate, though not on a runtime. A request is refused while its loop's channel is full, and the client has to retry.
41 changes: 41 additions & 0 deletions observability/grafana/dashboards/stream.json
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,47 @@
"mode": "multi"
}
}
},
{
"id": 10,
"type": "timeseries",
"title": "Refused requests",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 32
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"refId": "A",
"expr": "rate(stream_requests_refused_total[$__rate_interval])",
"legendFormat": "503 on a full channel"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
}
],
"templating": {
Expand Down
10 changes: 10 additions & 0 deletions src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ atomics!(
CHANNEL_MESSAGES,
CHANNEL_DRAIN_FULL,
CHANNEL_DROPS,
REQUESTS_REFUSED,
SOCKET_READS,
SOCKET_READ_BYTES,
ROOMS_VISITED,
Expand Down Expand Up @@ -210,6 +211,13 @@ pub fn channel_dropped() {
CHANNEL_DROPS.fetch_add(1, Relaxed);
}

/// A web request answered `503` because its media loop's channel was full.
/// Counted rather than logged, since it arrives in floods. Stream key
/// resolution asks every loop but counts once.
pub fn request_refused() {
REQUESTS_REFUSED.fetch_add(1, Relaxed);
}

pub fn socket_read(bytes: usize) {
SOCKET_READS.fetch_add(1, Relaxed);
SOCKET_READ_BYTES.fetch_add(bytes as u64, Relaxed);
Expand Down Expand Up @@ -352,6 +360,7 @@ pub struct Snapshot {
pub channel_messages: u64,
pub channel_drain_full: u64,
pub channel_drops: u64,
pub requests_refused: u64,
pub socket_reads: u64,
pub socket_read_bytes: u64,

Expand Down Expand Up @@ -394,6 +403,7 @@ pub fn snapshot() -> Snapshot {
channel_messages: CHANNEL_MESSAGES.load(Relaxed),
channel_drain_full: CHANNEL_DRAIN_FULL.load(Relaxed),
channel_drops: CHANNEL_DROPS.load(Relaxed),
requests_refused: REQUESTS_REFUSED.load(Relaxed),
socket_reads: SOCKET_READS.load(Relaxed),
socket_read_bytes: SOCKET_READ_BYTES.load(Relaxed),

Expand Down
11 changes: 8 additions & 3 deletions src/metrics/exposition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ use super::{
BUCKET_COUNT, CHANNEL_DRAIN_FULL, CHANNEL_DROPS, CHANNEL_MESSAGES, CHANNEL_WAIT,
CLIENT_TICK_ERRORS, CLIENTS_DISCONNECTED, DEMOTIONS, Durations, FORWARD_DELAY, Histogram,
LAP_LATENESS, LAPS, MEDIA_DATAS, MEDIA_WRITE_BYTES, MEDIA_WRITES, PHASES, PROMOTIONS, Phase,
RELAY_SAVED_BYTES, RELAY_SAVED_FRAMES, ROOMS_SKIPPED, ROOMS_VISITED, SENT_BYTES, SENT_PACKETS,
SOCKET_READ_BYTES, SOCKET_READS, ShardGauges, shards,
RELAY_SAVED_BYTES, RELAY_SAVED_FRAMES, REQUESTS_REFUSED, ROOMS_SKIPPED, ROOMS_VISITED,
SENT_BYTES, SENT_PACKETS, SOCKET_READ_BYTES, SOCKET_READS, ShardGauges, shards,
};

pub const CONTENT_TYPE: &str = "application/openmetrics-text; version=1.0.0; charset=utf-8";
Expand Down Expand Up @@ -82,7 +82,7 @@ impl Collector for Exposition {
}
}

fn counters() -> [(&'static str, &'static str, &'static AtomicU64); 19] {
fn counters() -> [(&'static str, &'static str, &'static AtomicU64); 20] {
[
("stream_laps", "Media loop iterations", &LAPS),
(
Expand All @@ -100,6 +100,11 @@ fn counters() -> [(&'static str, &'static str, &'static AtomicU64); 19] {
"Datagrams the mux read but dropped because a loop's channel was full",
&CHANNEL_DROPS,
),
(
"stream_requests_refused",
"Web requests answered 503 because a loop's channel was full",
&REQUESTS_REFUSED,
),
(
"stream_socket_reads",
"Datagrams read from the media socket",
Expand Down
Loading
Loading