diff --git a/.github/workflows/antstream-visual.yml b/.github/workflows/antstream-visual.yml index cda1060..898d919 100644 --- a/.github/workflows/antstream-visual.yml +++ b/.github/workflows/antstream-visual.yml @@ -159,6 +159,30 @@ jobs: -antstream-shot-storage -antstream-shot-deposit sleep 22 xcrun simctl io booted screenshot shots/10-storage-deposit-topup.png + # --- Going live (#67 stage 2 / #65) --- + # 11/12 — the broadcast screen with a broadcast actually + # running. A simulator has no camera, so `-antstream-shot-live` + # runs the capture pipeline from the generated test pattern + # (labelled as such on screen) through the real encoder, + # segmenter and publisher: AVAssetWriter in .mpeg4AppleHLS mode + # -> ant_publisher_push_segment -> POST /bzz. It also installs + # a sample connected plan, because a fresh runner account has + # none and the screen refuses to go live without one. + # + # The uploads themselves cannot succeed here: the sample batch + # is not a real postage batch, so the gateway rejects it — + # exactly the "no usable batch" wall the stage-1 publish rows + # hit. That is the honest evidence this runner can produce, and + # it still covers everything up to the batch check: capture, + # segmentation, the FFI hand-off, the lag indicator and the + # error surface. + xcrun simctl terminate booted at.vibing.ant.stream || true + xcrun simctl launch booted at.vibing.ant.stream -antstream-shot-live + # The screen waits for `ant_init` before starting (~20 s cold). + sleep 40 + xcrun simctl io booted screenshot shots/11-live-broadcast.png + sleep 30 + xcrun simctl io booted screenshot shots/12-live-publish-lag.png xcrun simctl spawn booted log show --last 5m \ --predicate 'processImagePath contains "AntStream"' \ > shots/app-log.txt || true diff --git a/Cargo.lock b/Cargo.lock index 81a3594..6d3d443 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,6 +168,7 @@ dependencies = [ "ant-p2p", "ant-postage", "ant-retrieval", + "axum", "hex", "jni", "k256", diff --git a/crates/ant-ffi/ANTSTREAM_BENCH.md b/crates/ant-ffi/ANTSTREAM_BENCH.md index bde9986..4a16128 100644 --- a/crates/ant-ffi/ANTSTREAM_BENCH.md +++ b/crates/ant-ffi/ANTSTREAM_BENCH.md @@ -213,3 +213,22 @@ Stage 1 only. Not here, by design (they are stage 2 / 3 of #67): playlist rebuilds, `POST /soc` feed updates, drop-oldest live-edge discipline, the on-screen publish-lag indicator, foreground keep-alive beyond the bench's own idle-timer hold, and the VOD finalize path. + +### What stage 2 did to this file + +Stage 2 (`crates/ant-ffi/src/publisher.rs`) is the same loop with the +generator replaced by the real camera pipeline, so the publish path +moved *out* of `bench.rs` and into `publisher.rs` as product code: +`Target`, the loopback HTTP client, `publish_bzz` and +`data_chunk_count` now live there and `bench.rs` imports them. The +bench therefore keeps measuring exactly the call a broadcast makes — +if the two ever drift, the numbers here stop describing the product. + +The stage-1 findings that became stage-2 constants: + +| finding | where it landed | +|---|---| +| window 4 sustains, window 8 collapses the connection layer | `DEFAULT_MAX_IN_FLIGHT = 4` | +| 360p @ 900 kbit/s, 2 s segments is the reachable rendition | `DEFAULT_BITRATE_KBPS = 900`, `DEFAULT_SEGMENT_MS = 2000` | +| lag budget = 3 × segment duration | `PublisherReport::kept_up` | +| a publisher that quietly drifts behind is not a pass | drop-oldest backlog + `#EXT-X-DISCONTINUITY` | diff --git a/crates/ant-ffi/Cargo.toml b/crates/ant-ffi/Cargo.toml index 26d802d..312c85f 100644 --- a/crates/ant-ffi/Cargo.toml +++ b/crates/ant-ffi/Cargo.toml @@ -79,5 +79,12 @@ tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +[dev-dependencies] +# The live-publisher integration test stands the production +# `ant-gateway` router up on a loopback port (see +# `tests/live_publisher_gateway.rs`); `Gateway::serve` binds its own +# address, so the test needs `axum::serve` to bind port 0 and learn it. +axum.workspace = true + [lints] workspace = true diff --git a/crates/ant-ffi/include/ant.h b/crates/ant-ffi/include/ant.h index 7c8f6f4..41226ab 100644 --- a/crates/ant-ffi/include/ant.h +++ b/crates/ant-ffi/include/ant.h @@ -795,6 +795,145 @@ char *ant_bench_progress(const AntHandle *handle, char **out_err); */ char *ant_bench_stop(const AntHandle *handle, char **out_err); +/* ------------------------------------------------------------------- + * AntStream live publisher (issue #67 stage 2) + * ------------------------------------------------------------------- */ + +/* + * Start a live broadcast from this node. + * + * This is the bench loop above with the synthetic generator replaced by + * the host's camera pipeline. Per segment: POST /bzz the segment, + * rebuild the HLS media playlist over a sliding window, POST /bzz the + * playlist, and publish its reference as a sequence-feed update with + * POST /soc (bee-js shape: id = keccak256(topic || index_be8), payload + * timestamp_be8 || reference) so any bee gateway resolves the channel. + * A feed manifest is created once at start with POST /feeds; its + * reference is the single thing a viewer needs. + * + * `config_json` is a PublisherConfig document; "channel" and "batch_id" + * are required: + * { + * "channel": "Kitchen", // required, names the feed + * "topic": "0x<64 hex>", // omit -> derived per broadcast + * "gateway": "http://127.0.0.1:1633", // ant_start_gateway's address + * "batch_id": "0x<64 hex>", // required: the storage plan + * "segment_ms": 2000, // nominal segment duration + * "bitrate_kbps": 900, // 360p, the stage-1 rendition + * "max_in_flight": 4, // measured stable window + * "max_backlog": 4, // drop-oldest past this + * "playlist_window": 6, // segments in the live playlist + * "notes": "iPhone 15 Pro / LTE" + * } + * + * Do NOT raise max_in_flight without re-measuring: stage 1 found + * window 4 stable (899/899 segments) and window 8 a connection-layer + * collapse (26/316). See crates/ant-ffi/ANTSTREAM_BENCH.md. + * + * Returns immediately; the loop drives itself on the node's runtime. + * Only one broadcast at a time per handle. Returns true on success, + * false with an allocated message in *out_err (free with + * ant_free_string). + */ +bool ant_publisher_start(const AntHandle *handle, + const char *config_json, + char **out_err); + +/* + * Hand one finished capture segment to the running broadcast. + * + * `is_init` marks the fMP4 initialization segment (ftyp + moov), which + * every following media segment needs to be playable; push a fresh one + * whenever the writer restarts (camera flip, interruption recovery, + * thermal downshift) and set `discontinuity` on the first media segment + * after it. `duration_ms` is the segment's real duration (ignored for + * the initialization segment). The bytes are copied; the caller may + * free `data` as soon as this returns. + * + * CALL ORDER IS THE BROADCAST ORDER — segments are numbered as these + * calls arrive, and that number fixes both the playlist order and which + * EXT-X-MAP a media segment is listed under. Push in capture order, + * from one thread or an ordered queue: getting it wrong around a writer + * restart lists the old writer's last segment under the new writer's + * map, which no player can decode. + * + * NEVER BLOCKS — a capture pipeline stalled on the uplink drops frames. + * When the publisher is already a window behind, the oldest pending + * segment is dropped instead (live-edge discipline). + * + * Returns: + * 0 queued + * 1 queued, and the oldest pending segment was dropped to stay at + * the live edge (the playlist marks the gap EXT-X-DISCONTINUITY) + * 2 refused: the broadcast is stopping + * -1 error, with an allocated message in *out_err + */ +int32_t ant_publisher_push_segment(const AntHandle *handle, + bool is_init, + const unsigned char *data, + size_t len, + uint32_t duration_ms, + bool discontinuity, + char **out_err); + +/* + * Live progress of the broadcast, as an allocated JSON object (free + * with ant_free_string): + * {"running":true,"elapsed_s":42.0,"channel":"Kitchen", + * "topic":"<64 hex>","owner":"<40 hex>", + * "channel_reference":"<64 hex>","playlist_reference":"<64 hex>", + * "feed_index":21,"segments_pushed":22,"segments_published":21, + * "segments_listed":21,"segments_failed":0,"segments_dropped":0, + * "bytes_published":4725000, + * "playlists_published":21,"publish_ms_p50":900,"publish_ms_p95":2100, + * "lag_ms":2400,"lag_ms_max":3100,"keeping_up":true, + * "sustained_mbit_s":0.9,"peers":114,"last_error":"","error_count":0} + * + * "lag_ms" is the live-edge lag the on-screen indicator shows: how far + * behind live a viewer is right now, i.e. the age of the newest segment + * a landed feed update made playable. It is an age, not the latency of + * the last update that landed, so a broadcast whose feed updates stop + * landing keeps climbing here (and turns "keeping_up" false) instead of + * freezing at its last good figure while segments go on uploading. + * "keeping_up" is that lag inside three segment durations, the same + * budget the bench verdict uses. Non-blocking; poll it about once a + * second. Returns NULL + an error when this node is not broadcasting. + */ +char *ant_publisher_progress(const AntHandle *handle, char **out_err); + +/* + * End the broadcast and return its final report as an allocated JSON + * object (free with ant_free_string): the progress fields above plus + * duration_s, chunks_published, feed_updates, sustained_chunks_s, + * publish_ms_p50|p95|max, lag_ms_p50|p95|max|final, the first few error + * strings, and a "kept_up" verdict (at least one feed update landed AND + * every captured media segment reached a published playlist AND the + * live edge ended inside 3 x segment_ms). "lag_ms_final" is that live + * edge — the age of the newest playable segment at stop, frozen there + * so a report read later still describes the broadcast rather than how + * long the host waited to ask. The count it uses is + * "segments_listed", not "segments_published": a segment whose + * initialization segment never landed uploads fine and is still + * unplayable, so hosts rendering a verdict should key "is there a + * verdict at all?" off segments_listed too. + * + * BLOCKING: stopping is cooperative. Segments already captured are + * published — the last seconds of a broadcast are real content — and + * the playlist is closed with EXT-X-ENDLIST so viewers see a finished + * recording rather than a stream that stopped updating. Returns after + * ~130 s at the latest; a worst-case drain (the in-flight window, a + * segment the pump had already popped behind it, then the backlog — + * up to three rounds of the 60 s per-segment publish deadline) can + * still be finishing in the background past that, with the report + * returned honestly either way. Call it off the main thread. + * + * Calling it on a broadcast that already finished on its own returns + * that broadcast's report. Once a stop call has returned and released + * the slot, a second call fails with "not broadcasting" — keep the + * report from the first call rather than re-fetching it. + */ +char *ant_publisher_stop(const AntHandle *handle, char **out_err); + /* * Shut the embedded node down and free the handle. After this * returns, `handle` must not be used again. diff --git a/crates/ant-ffi/src/bench.rs b/crates/ant-ffi/src/bench.rs index 6b61b9d..c57d05d 100644 --- a/crates/ant-ffi/src/bench.rs +++ b/crates/ant-ffi/src/bench.rs @@ -4,18 +4,22 @@ //! camera: a generator produces HLS-shaped segments on a wall clock at a //! configured bitrate, and each finished segment is published with one //! `POST /bzz` against the bee-shaped gateway (`ant_start_gateway` -//! in-process on iOS, `antd` on desktop). Stage 2 replaces the generator -//! with the real capture pipeline and adds the playlist / feed writes; -//! everything below the generator is meant to survive that swap, which -//! is why the measurement harness lives in `ant-ffi` rather than in a -//! throwaway script. +//! in-process on iOS, `antd` on desktop). //! -//! Deliberately **not** in stage 1 (they belong to stage 2 of #67): -//! playlist rebuilds, `POST /soc` feed updates, drop-oldest live-edge -//! discipline, and the on-screen lag indicator. What is here is the -//! measurement those decisions need: sustained Mbit/s, chunks/s, -//! per-segment publish latency, and publish *lag* (how far behind the -//! live edge the uploader has fallen), sampled over a long run. +//! That swap has since happened: stage 2's live publisher lives in +//! [`crate::publisher`], and the publish path this harness measures — +//! the loopback HTTP client, the `POST /bzz` call, the chunk-count +//! arithmetic — **is** that module's, imported here rather than +//! duplicated. What stays in this file is only the measurement: a +//! wall-clock generator, and the statistics a go/no-go row is made of. +//! +//! Deliberately **not** in stage 1 (they are stage 2, in +//! [`crate::publisher`]): playlist rebuilds, `POST /soc` feed updates, +//! drop-oldest live-edge discipline, and the on-screen lag indicator. +//! What is here is the measurement those decisions needed: sustained +//! Mbit/s, chunks/s, per-segment publish latency, and publish *lag* (how +//! far behind the live edge the uploader has fallen), sampled over a +//! long run. //! //! Two modes, sharing one loop: //! @@ -39,27 +43,16 @@ use std::time::{Duration, Instant}; use ant_control::StatusSnapshot; use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; use tokio::sync::{watch, Semaphore}; -/// Swarm chunk payload size. Segment byte counts are converted to chunk -/// counts with this so `chunks/s` is comparable to the desktop -/// `--target-peers` sweep in PLAN.md (Phase 7g: 105.3 chunks/s at 400 -/// peers). -const CHUNK_SIZE: u64 = 4096; - -/// Branching factor of the Swarm chunk tree (128 × 32-byte references -/// per intermediate chunk). Mirrors `ant_retrieval::BRANCHES`; kept -/// local so [`data_chunk_count`] stays a pure function usable from the -/// unit tests without pulling the splitter in. -const BRANCHES: u64 = 128; +// The publish path itself is stage 2's product code (see the module +// docs): the bench measures exactly what a live broadcast runs. +use crate::publisher::{lock, ms, percentile, publish_bzz, Target}; -/// Per-segment publish deadline. A segment that has not landed within -/// this long is recorded as a failure rather than stalling the run: at -/// live-edge bitrates anything past ~1 min is already unusable, and the -/// bench must keep sampling so the report shows *where* it broke. -const PUBLISH_TIMEOUT: Duration = Duration::from_mins(1); +/// Number of chunks a segment splits into — re-exported from +/// [`crate::publisher`] so existing callers of `bench::data_chunk_count` +/// keep working. +pub use crate::publisher::data_chunk_count; /// How often the run samples the node's peer count (and, on hosts that /// supply one, the battery/thermal note). Cheap watch-channel read. @@ -261,27 +254,6 @@ impl SegmentGenerator { } } -/// Number of chunks a `payload_bytes`-long body splits into: the data -/// leaves plus every intermediate level of the Swarm chunk tree. -/// -/// Excludes the 1–2 mantaray manifest chunks a `POST /bzz` adds on top -/// (< 0.5 % at segment sizes, and they are the same for every mode), so -/// the reported `chunks/s` is strictly the *content* rate — directly -/// comparable to the desktop `--target-peers` sweep in PLAN.md. -#[must_use] -pub const fn data_chunk_count(payload_bytes: u64) -> u64 { - if payload_bytes <= CHUNK_SIZE { - return 1; - } - let mut level = payload_bytes.div_ceil(CHUNK_SIZE); - let mut total = level; - while level > 1 { - level = level.div_ceil(BRANCHES); - total += level; - } - total -} - // --------------------------------------------------------------------------- // Per-segment outcome + rolling stats // --------------------------------------------------------------------------- @@ -643,28 +615,6 @@ fn build_report(config: &BenchConfig, stats: &BenchStats, elapsed: Duration) -> report } -/// Nearest-rank percentile (`ceil(pct/100 × n)`, 1-indexed). Chosen -/// over the interpolating variant because a tail latency must never be -/// *understated*: with 5 samples, "p95" here is the worst one, not the -/// fourth. -fn percentile(sorted_ms: &[u64], pct: usize) -> u64 { - if sorted_ms.is_empty() { - return 0; - } - let rank = (sorted_ms.len() * pct).div_ceil(100).max(1); - sorted_ms[rank.min(sorted_ms.len()) - 1] -} - -fn ms(d: Duration) -> u64 { - u64::try_from(d.as_millis()).unwrap_or(u64::MAX) -} - -/// Poison-tolerant lock: a panicked bench thread must not poison the -/// stats for the reader that is about to render the report. -fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { - m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) -} - /// Start a run on `runtime`, returning the handle immediately. /// /// The caller keeps the [`BenchRun`] alive for the duration; dropping it @@ -679,7 +629,7 @@ pub fn start( ) -> Result, BenchError> { config.validate()?; let target = if config.mode() == Mode::Publish { - Some(Target::parse(&config.gateway)?) + Some(Target::parse(&config.gateway).map_err(BenchError::Config)?) } else { None }; @@ -845,30 +795,25 @@ async fn sleep_until(deadline: Instant) { // Publish path // --------------------------------------------------------------------------- -/// Publish one segment with `POST /bzz`, exactly as the stage-2 loop -/// will. Returns the segment's data-chunk count on success. +/// Publish one segment with `POST /bzz` — the live publisher's own +/// call ([`crate::publisher::publish_bzz`]), so the bench measures the +/// path a broadcast runs rather than a copy of it. Returns the +/// segment's data-chunk count on success. async fn publish_segment( target: &Target, batch: [u8; 32], seq: u64, payload: &[u8], ) -> Result { - let path = format!("{}/bzz?name=seg-{seq}.m4s", target.prefix); - let headers = [ - ("content-type".to_string(), "video/iso.segment".to_string()), - ("swarm-postage-batch-id".to_string(), hex::encode(batch)), - ]; - let response = - tokio::time::timeout(PUBLISH_TIMEOUT, http_post(target, &path, &headers, payload)) - .await - .map_err(|_| format!("segment {seq}: publish timed out after {PUBLISH_TIMEOUT:?}"))??; - if response.status != 201 { - return Err(format!( - "segment {seq}: gateway returned {} {}", - response.status, - String::from_utf8_lossy(&response.body).trim(), - )); - } + publish_bzz( + target, + batch, + &format!("seg-{seq}.m4s"), + "video/iso.segment", + payload, + ) + .await + .map_err(|e| format!("segment {seq}: {e}"))?; Ok(data_chunk_count(payload.len() as u64)) } @@ -905,129 +850,6 @@ fn parse_batch_id(raw: &str) -> Result<[u8; 32], BenchError> { }) } -// --------------------------------------------------------------------------- -// Minimal loopback HTTP/1.1 client -// --------------------------------------------------------------------------- -// -// The gateway the publisher posts to is always on loopback — in-process -// on iOS (`ant_start_gateway`), `antd` on desktop — so a full HTTP -// client stack would be dead weight in the mobile slice, which -// deliberately drops `reqwest` (see `ant-ffi/Cargo.toml`). This is the -// smallest thing that speaks the one request shape the publisher needs: -// `POST` with a `Content-Length` body, one response, connection closed. - -/// Parsed `http://host:port/prefix` gateway base. -#[derive(Debug, Clone)] -struct Target { - authority: String, - /// Path prefix, without a trailing slash (`""` for a bare host). - prefix: String, -} - -impl Target { - fn parse(url: &str) -> Result { - let rest = url.trim().strip_prefix("http://").ok_or_else(|| { - BenchError::Config(format!( - "gateway must be an http:// URL (the publisher posts to a loopback gateway), got `{url}`", - )) - })?; - let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p)); - if authority.is_empty() { - return Err(BenchError::Config(format!("gateway has no host: `{url}`"))); - } - let authority = if authority.contains(':') { - authority.to_string() - } else { - format!("{authority}:80") - }; - let prefix = path.trim_end_matches('/'); - Ok(Self { - authority, - prefix: if prefix.is_empty() { - String::new() - } else { - format!("/{prefix}") - }, - }) - } -} - -struct HttpResponse { - status: u16, - body: Vec, -} - -async fn http_post( - target: &Target, - path: &str, - headers: &[(String, String)], - body: &[u8], -) -> Result { - let mut stream = TcpStream::connect(&target.authority) - .await - .map_err(|e| format!("connect {}: {e}", target.authority))?; - // Loopback + small bodies: Nagle only adds latency to the - // measurement we are here to take. - let _ = stream.set_nodelay(true); - - let mut head = format!( - "POST {path} HTTP/1.1\r\nhost: {}\r\ncontent-length: {}\r\nconnection: close\r\n", - target.authority, - body.len(), - ); - for (name, value) in headers { - head.push_str(name); - head.push_str(": "); - head.push_str(value); - head.push_str("\r\n"); - } - head.push_str("\r\n"); - stream - .write_all(head.as_bytes()) - .await - .map_err(|e| format!("write request head: {e}"))?; - stream - .write_all(body) - .await - .map_err(|e| format!("write request body: {e}"))?; - stream - .flush() - .await - .map_err(|e| format!("flush request: {e}"))?; - - let mut raw = Vec::new(); - stream - .read_to_end(&mut raw) - .await - .map_err(|e| format!("read response: {e}"))?; - parse_response(&raw) -} - -/// Parse a `connection: close` response: status line, headers, body to -/// EOF. Chunked transfer-encoding is not handled — the gateway answers -/// uploads with a small `Content-Length` JSON object, and a body we -/// can't parse would show up as a non-201 status anyway. -fn parse_response(raw: &[u8]) -> Result { - let split = raw - .windows(4) - .position(|w| w == b"\r\n\r\n") - .ok_or_else(|| "malformed response: no header terminator".to_string())?; - let head = String::from_utf8_lossy(&raw[..split]); - let mut lines = head.lines(); - let status_line = lines - .next() - .ok_or_else(|| "malformed response: empty".to_string())?; - let status: u16 = status_line - .split_whitespace() - .nth(1) - .and_then(|c| c.parse().ok()) - .ok_or_else(|| format!("malformed status line: `{status_line}`"))?; - Ok(HttpResponse { - status, - body: raw[split + 4..].to_vec(), - }) -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1035,6 +857,7 @@ fn parse_response(raw: &[u8]) -> Result { #[cfg(test)] mod tests { use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; fn config() -> BenchConfig { BenchConfig { @@ -1075,16 +898,6 @@ mod tests { assert_eq!(a, SegmentGenerator::new(7, 8192).segment(0)); } - #[test] - fn chunk_count_covers_the_tree() { - assert_eq!(data_chunk_count(0), 1); - assert_eq!(data_chunk_count(4096), 1); - // 2 leaves + 1 root. - assert_eq!(data_chunk_count(4097), 3); - // 850 000 B → 208 leaves → 2 intermediates → 1 root. - assert_eq!(data_chunk_count(850_000), 208 + 2 + 1); - } - #[test] fn batch_id_parses_with_and_without_prefix() { let hexed = "ab".repeat(32); @@ -1093,26 +906,6 @@ mod tests { assert!(parse_batch_id("0xdead").is_err()); } - #[test] - fn target_parses_host_and_prefix() { - let t = Target::parse("http://127.0.0.1:1633").unwrap(); - assert_eq!(t.authority, "127.0.0.1:1633"); - assert_eq!(t.prefix, ""); - let t = Target::parse("http://example.test/ant/").unwrap(); - assert_eq!(t.authority, "example.test:80"); - assert_eq!(t.prefix, "/ant"); - assert!(Target::parse("https://example.test").is_err()); - } - - #[test] - fn response_parser_reads_status_and_body() { - let raw = b"HTTP/1.1 201 Created\r\ncontent-length: 2\r\n\r\n{}"; - let r = parse_response(raw).unwrap(); - assert_eq!(r.status, 201); - assert_eq!(r.body, b"{}"); - assert!(parse_response(b"garbage").is_err()); - } - #[test] fn config_validation_rejects_unusable_runs() { let mut c = config(); @@ -1249,14 +1042,6 @@ mod tests { assert!(report.markdown_row().contains("**no**")); } - #[test] - fn percentiles_are_stable_on_small_samples() { - assert_eq!(percentile(&[], 50), 0); - assert_eq!(percentile(&[5], 95), 5); - assert_eq!(percentile(&[1, 2, 3, 4, 5], 50), 3); - assert_eq!(percentile(&[1, 2, 3, 4, 5], 95), 5); - } - // ----------------------------------------------------------------- // End-to-end publish path, against a stub that speaks the gateway's // `POST /bzz` contract. This is the half the unit tests above can't diff --git a/crates/ant-ffi/src/lib.rs b/crates/ant-ffi/src/lib.rs index b454558..5adbf03 100644 --- a/crates/ant-ffi/src/lib.rs +++ b/crates/ant-ffi/src/lib.rs @@ -27,6 +27,7 @@ mod gateway; #[cfg(feature = "jni")] mod jni; mod manifest; +pub mod publisher; mod stream; // The gateway FFI lives in a private submodule; re-export its C-ABI @@ -178,6 +179,13 @@ pub struct AntHandle { /// run at a time — two concurrent runs would each measure the /// other's upload contention rather than the network's. bench: Mutex>>, + /// The live broadcast currently publishing from this node, if any + /// (issue #67 stage 2). `None` until [`ant_publisher_start`]; + /// cleared by [`ant_publisher_stop`]. Exactly one at a time — two + /// broadcasts would compete for the same uplink and the same + /// in-flight window, which is precisely what the stage-1 window + /// measurements say not to do. + publisher: Mutex>>, } /// Live snapshot of the in-flight download, maintained by the @@ -880,6 +888,7 @@ fn init_inner( data_dir: data_dir.to_path_buf(), gateway_task: Mutex::new(None), bench: Mutex::new(None), + publisher: Mutex::new(None), }) } @@ -3013,6 +3022,282 @@ pub unsafe extern "C" fn ant_bench_stop( } } +// --------------------------------------------------------------------------- +// AntStream live publisher (issue #67 stage 2) +// --------------------------------------------------------------------------- + +/// How long [`ant_publisher_stop`] waits for a cancelled broadcast to +/// settle before returning the report anyway. +/// +/// Stopping publishes what is already captured. With every upload +/// timing out that drain is at worst *three* rounds of the publisher's +/// 60 s per-segment deadline — the in-flight window, a segment the +/// pump had already popped behind it, then the backlog (capped at +/// `max_backlog`, so at most one further round) — so a fully wedged +/// uplink can outlive this grace. That is deliberate: a drain that +/// slow means the tail is lost regardless, so the call returns at +/// ~130 s with an honest report and the loop finishes in the +/// background and releases its slot. +const PUBLISHER_STOP_GRACE: Duration = Duration::from_secs(130); + +/// Poll interval while waiting for a cancelled broadcast to settle. +const PUBLISHER_STOP_POLL: Duration = Duration::from_millis(50); + +/// Start a live broadcast from this node. +/// +/// `config_json` is a [`publisher::PublisherConfig`] document; `channel` +/// and `batch_id` are required (a broadcast needs a name and a storage +/// plan). Segments come from the host's capture pipeline through +/// [`ant_publisher_push_segment`]; per segment the publisher does one +/// `POST /bzz`, rebuilds the HLS playlist, and publishes it as a +/// sequence-feed update with `POST /soc` — all against `gateway`, which +/// must already be listening (see [`ant_start_gateway`]). +/// +/// Returns immediately — the loop drives itself on the node's runtime. +/// Poll it with [`ant_publisher_progress`] and finish it with +/// [`ant_publisher_stop`]. Only one broadcast at a time per handle. +/// +/// Returns `true` on success, `false` with an allocated message in +/// `out_err` (free with [`ant_free_string`]) otherwise. +/// +/// # Safety +/// +/// `handle` must come from [`ant_init`] and must not have been passed +/// to [`ant_shutdown`]. `config_json` must be a NUL-terminated UTF-8 +/// string. `out_err`, if non-null, must point at a writable +/// `*mut c_char` slot. +#[no_mangle] +pub unsafe extern "C" fn ant_publisher_start( + handle: *const AntHandle, + config_json: *const c_char, + out_err: *mut *mut c_char, +) -> bool { + unsafe { + clear_out_err(out_err); + let Some(handle) = handle.as_ref() else { + write_out_err(out_err, "ant_publisher_start: null handle"); + return false; + }; + let config = match cstr_to_str(config_json) + .map_err(|e| e.to_string()) + .and_then(|raw| { + serde_json::from_str::(raw) + .map_err(|e| format!("ant_publisher_start: invalid config: {e}")) + }) { + Ok(c) => c, + Err(msg) => { + write_out_err(out_err, &msg); + return false; + } + }; + + // Hold the slot across check → start → store, so two concurrent + // starts can't both pass the "already broadcasting" check. + let mut slot = handle + .publisher + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.as_ref().is_some_and(|run| !run.is_finished()) { + write_out_err( + out_err, + "ant_publisher_start: this node is already broadcasting", + ); + return false; + } + match publisher::start( + handle.runtime.handle(), + config, + handle.signing_secret, + handle.eth, + Some(handle.status_rx.clone()), + ) { + Ok(run) => { + *slot = Some(run); + true + } + Err(e) => { + write_out_err(out_err, &format!("ant_publisher_start: {e}")); + false + } + } + } +} + +/// Hand one finished capture segment to the running broadcast. +/// +/// `is_init` marks the fMP4 *initialization* segment (`ftyp` + `moov`), +/// which every following media segment needs to be playable; push a +/// fresh one whenever the writer restarts (camera flip, interruption +/// recovery, bitrate downshift) and set `discontinuity` on the first +/// media segment after it. `duration_ms` is the segment's real duration +/// (ignored for the initialization segment). +/// +/// **Call order is the broadcast order.** Segments are numbered as these +/// calls arrive, and that number fixes both the playlist order and which +/// `#EXT-X-MAP` a media segment is listed under — so the caller must +/// push in capture order, from one thread or an ordered queue. Getting +/// it wrong around a writer restart lists the old writer's last segment +/// under the new writer's map, which no player can decode. +/// +/// **Never blocks**: a capture pipeline stalled on the uplink drops +/// frames. When the publisher is already a window behind, the oldest +/// pending segment is dropped instead — the live-edge discipline the +/// return value reports. +/// +/// Returns: +/// +/// * `0` — queued. +/// * `1` — queued, and the oldest pending segment was dropped to stay at +/// the live edge (the playlist marks the gap `#EXT-X-DISCONTINUITY`). +/// * `2` — refused: the broadcast is stopping. +/// * `-1` — error, with an allocated message in `out_err` (free with +/// [`ant_free_string`]). +/// +/// # Safety +/// +/// `handle` must come from [`ant_init`] and must not have been passed +/// to [`ant_shutdown`]. `data` must point at `len` readable bytes for +/// the duration of the call (the publisher copies them). `out_err`, if +/// non-null, must point at a writable `*mut c_char` slot. +#[no_mangle] +pub unsafe extern "C" fn ant_publisher_push_segment( + handle: *const AntHandle, + is_init: bool, + data: *const u8, + len: usize, + duration_ms: u32, + discontinuity: bool, + out_err: *mut *mut c_char, +) -> i32 { + unsafe { + clear_out_err(out_err); + let Some(handle) = handle.as_ref() else { + write_out_err(out_err, "ant_publisher_push_segment: null handle"); + return -1; + }; + if data.is_null() || len == 0 { + write_out_err(out_err, "ant_publisher_push_segment: empty segment"); + return -1; + } + let run = handle + .publisher + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let Some(run) = run else { + write_out_err( + out_err, + "ant_publisher_push_segment: this node is not broadcasting", + ); + return -1; + }; + let payload = std::slice::from_raw_parts(data, len).to_vec(); + let kind = if is_init { + publisher::SegmentKind::Init + } else { + publisher::SegmentKind::Media + }; + match run.push(kind, payload, duration_ms, discontinuity) { + publisher::PushOutcome::Queued => 0, + publisher::PushOutcome::QueuedDroppingOldest => 1, + publisher::PushOutcome::Closed => 2, + } + } +} + +/// Live progress of the broadcast started by [`ant_publisher_start`], as +/// an allocated [`publisher::PublisherSnapshot`] JSON string (free with +/// [`ant_free_string`]). Non-blocking — this is what drives the +/// on-screen publish-lag indicator, so it is polled once a second. +/// Returns null with an error when no broadcast has been started on this +/// handle. +/// +/// # Safety +/// +/// `handle` must come from [`ant_init`] and must not have been passed +/// to [`ant_shutdown`]. `out_err`, if non-null, must point at a +/// writable `*mut c_char` slot. +#[no_mangle] +pub unsafe extern "C" fn ant_publisher_progress( + handle: *const AntHandle, + out_err: *mut *mut c_char, +) -> *mut c_char { + unsafe { + run_string_call(out_err, "ant_publisher_progress", || { + let handle = handle.as_ref().ok_or_else(null_handle)?; + let run = handle + .publisher + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .ok_or_else(|| "this node is not broadcasting".to_string())?; + serde_json::to_string(&run.snapshot()).map_err(|e| format!("serialize snapshot: {e}")) + }) + } +} + +/// End the broadcast and return its final +/// [`publisher::PublisherReport`] as an allocated JSON string (free with +/// [`ant_free_string`]). +/// +/// **Blocking**: stopping is cooperative. Segments already captured are +/// published (the last seconds of a broadcast are real content, not a +/// truncated measurement) and the playlist is closed with +/// `#EXT-X-ENDLIST` so viewers see a finished recording rather than a +/// stream that just stopped updating. Returns after +/// [`PUBLISHER_STOP_GRACE`] (~130 s) at the latest — a worst-case +/// drain can still be finishing in the background past that (see the +/// constant) — so call it off the UI thread. +/// +/// Calling it on a broadcast that already finished on its own returns +/// that broadcast's report. Once a stop call has *returned* and +/// released the slot, a second call fails with "not broadcasting" — +/// keep the report from the first call rather than re-fetching it. +/// +/// # Safety +/// +/// `handle` must come from [`ant_init`] and must not have been passed +/// to [`ant_shutdown`]. `out_err`, if non-null, must point at a +/// writable `*mut c_char` slot. +#[no_mangle] +pub unsafe extern "C" fn ant_publisher_stop( + handle: *const AntHandle, + out_err: *mut *mut c_char, +) -> *mut c_char { + unsafe { + run_string_call(out_err, "ant_publisher_stop", || { + let handle = handle.as_ref().ok_or_else(null_handle)?; + let run = handle + .publisher + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .ok_or_else(|| "this node is not broadcasting".to_string())?; + run.cancel(); + let deadline = Instant::now() + PUBLISHER_STOP_GRACE; + while !run.is_finished() && Instant::now() < deadline { + std::thread::sleep(PUBLISHER_STOP_POLL); + } + let report = run.report(); + // Only release the slot once the loop is actually done, and + // only if it still holds *this* broadcast: while we waited, + // a concurrent `ant_publisher_start` could legitimately have + // installed a new one, which clearing would orphan (and let + // a third start run two publishers on one uplink). + if run.is_finished() { + let mut slot = handle + .publisher + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.as_ref().is_some_and(|cur| Arc::ptr_eq(cur, &run)) { + *slot = None; + } + } + serde_json::to_string(&report).map_err(|e| format!("serialize report: {e}")) + }) + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -3666,6 +3951,7 @@ mod tests { data_dir: data_dir.to_path_buf(), gateway_task: Mutex::new(None), bench: Mutex::new(None), + publisher: Mutex::new(None), } } diff --git a/crates/ant-ffi/src/publisher.rs b/crates/ant-ffi/src/publisher.rs new file mode 100644 index 0000000..7159537 --- /dev/null +++ b/crates/ant-ffi/src/publisher.rs @@ -0,0 +1,2369 @@ +//! `AntStream` live publisher loop — issue #67 stage 2. +//! +//! Stage 1 ([`crate::bench`]) established that the publisher loop is the +//! camera pipeline plus one `POST /bzz` per segment, and measured what +//! that path sustains. Stage 2 keeps the loop and replaces the synthetic +//! generator with the real capture pipeline (#65): the iOS side hands +//! finished fMP4 segments to [`LiveRun::push`], and everything below +//! that point is the code stage 1 measured — same bounded in-flight +//! window, same loopback HTTP client, same lag accounting — now carrying +//! real video. +//! +//! What stage 2 adds on top of the measured path, per segment: +//! +//! 1. `POST /bzz` the segment (and, when the writer restarts, its fMP4 +//! initialization segment). +//! 2. Rebuild the HLS media playlist over a sliding window of the most +//! recent segments. +//! 3. `POST /bzz` the playlist, then publish its reference as a +//! **sequence-feed update** with `POST /soc/{owner}/{id}` — the +//! bee-js shape (`id = keccak256(topic ‖ index_be8)`, payload +//! `timestamp_be8 ‖ reference`), so any bee gateway resolves the +//! channel with `GET /feeds/{owner}/{topic}` or through the feed +//! manifest this module creates at start with `POST /feeds`. +//! +//! Live-edge discipline, straight from the stage-1 findings: +//! +//! * The in-flight window defaults to **4**. Stage 1 measured window 4 +//! as the stable point (899/899 segments at 900 kbit/s) and window 8 +//! as a *collapse* (26/316) — per-peer stream pressure destroys +//! connections — so this is a tuned constant, not an arbitrary one. +//! * The default rendition is 360p at ~900 kbit/s with 2 s segments, +//! the row the stage-1 go/no-go landed on. +//! * When the uplink cannot keep up, the backlog **drops its oldest +//! pending segment** rather than growing without bound: a live +//! broadcast that falls minutes behind is worse than one with a gap. +//! Dropped and failed segments leave a hole, and the next segment that +//! does land is tagged `#EXT-X-DISCONTINUITY`. +//! +//! Uploads go straight to the in-process gateway (`ant_start_gateway`) +//! rather than through `UploadManager` jobs on purpose: that machinery is +//! resume-oriented VOD tooling, which is the wrong shape for a live edge +//! (a resumed segment is a segment nobody will ever play). It becomes the +//! right tool in stage 3, for the VOD finalize path. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use ant_control::StatusSnapshot; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::{watch, Notify, Semaphore}; + +// --------------------------------------------------------------------------- +// Shared publish primitives (used by the live loop and by `crate::bench`) +// --------------------------------------------------------------------------- + +/// Swarm chunk payload size. Segment byte counts are converted to chunk +/// counts with this so `chunks/s` is comparable to the desktop +/// `--target-peers` sweep in PLAN.md (Phase 7g: 105.3 chunks/s at 400 +/// peers). +pub(crate) const CHUNK_SIZE: u64 = 4096; + +/// Branching factor of the Swarm chunk tree (128 × 32-byte references +/// per intermediate chunk). Mirrors `ant_retrieval::BRANCHES`; kept +/// local so [`data_chunk_count`] stays a pure function usable from the +/// unit tests without pulling the splitter in. +const BRANCHES: u64 = 128; + +/// Per-segment publish deadline. A segment that has not landed within +/// this long is recorded as a failure rather than stalling the run: at +/// live-edge bitrates anything past ~1 min is already unusable, and both +/// the bench and the live publisher must keep going so the report (or +/// the on-screen indicator) shows *where* it broke. +pub(crate) const PUBLISH_TIMEOUT: Duration = Duration::from_mins(1); + +/// Number of chunks a `payload_bytes`-long body splits into: the data +/// leaves plus every intermediate level of the Swarm chunk tree. +/// +/// Excludes the 1–2 mantaray manifest chunks a `POST /bzz` adds on top +/// (< 0.5 % at segment sizes, and they are the same for every mode), so +/// the reported `chunks/s` is strictly the *content* rate — directly +/// comparable to the desktop `--target-peers` sweep in PLAN.md. +#[must_use] +pub const fn data_chunk_count(payload_bytes: u64) -> u64 { + if payload_bytes <= CHUNK_SIZE { + return 1; + } + let mut level = payload_bytes.div_ceil(CHUNK_SIZE); + let mut total = level; + while level > 1 { + level = level.div_ceil(BRANCHES); + total += level; + } + total +} + +/// Nearest-rank percentile (`ceil(pct/100 × n)`, 1-indexed). Chosen +/// over the interpolating variant because a tail latency must never be +/// *understated*: with 5 samples, "p95" here is the worst one, not the +/// fourth. +pub(crate) fn percentile(sorted_ms: &[u64], pct: usize) -> u64 { + if sorted_ms.is_empty() { + return 0; + } + let rank = (sorted_ms.len() * pct).div_ceil(100).max(1); + sorted_ms[rank.min(sorted_ms.len()) - 1] +} + +pub(crate) fn ms(d: Duration) -> u64 { + u64::try_from(d.as_millis()).unwrap_or(u64::MAX) +} + +/// Poison-tolerant lock: a panicked publisher task must not poison the +/// state for the reader that is about to render the progress view. +pub(crate) fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +/// Default HLS segment duration. 2 s is what the #65 capture pipeline +/// targets and what the stage-1 go/no-go row (899/899 at 900 kbit/s) +/// was measured with. +pub const DEFAULT_SEGMENT_MS: u32 = 2000; + +/// Default video bitrate: 360p, the rendition stage 1 landed on. 540p +/// (1800) was measured as out of reach at window 4 and is gated on #74 +/// (chunk-level pusher) or #68 (relay). +pub const DEFAULT_BITRATE_KBPS: u32 = 900; + +/// Default in-flight publish window. Stage 1: window 4 sustained +/// 899/899 segments; window 8 collapsed the same run to 26/316 because +/// per-peer stream pressure destroys connections. Do not raise this +/// without re-measuring per-chunk pace (see `ANTSTREAM_BENCH.md`). +pub const DEFAULT_MAX_IN_FLIGHT: usize = 4; + +/// How many captured-but-not-yet-started segments may queue behind the +/// in-flight window before the oldest is dropped. One window's worth: +/// past that the publisher is a window *and* a backlog behind live, and +/// the oldest pending segment is the one a viewer is least likely to +/// still want. +pub const DEFAULT_MAX_BACKLOG: usize = 4; + +/// Segments listed in the rolling media playlist. Apple recommends a +/// live playlist hold at least 3 target durations; 6 × 2 s = 12 s gives +/// a joining viewer a little more to buffer without making the playlist +/// (which is republished per segment) meaningfully bigger. +pub const DEFAULT_PLAYLIST_WINDOW: usize = 6; + +/// One live broadcast. Deserialised straight from the host's JSON so the +/// Swift app configures the same knobs the tests do. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PublisherConfig { + /// Human-readable channel name. Also the default topic seed (see + /// [`PublisherConfig::topic`]). + pub channel: String, + /// Feed topic, 32-byte hex (`0x` optional). Empty derives a topic + /// unique to this broadcast — + /// `keccak256("antstream///")`, the nonce a + /// process-wide counter — so a second broadcast of the same channel + /// starts a fresh feed at index 0 instead of having to discover the + /// previous head (or, restarted quickly, overwriting it). + #[serde(default)] + pub topic: String, + /// Gateway base URL, `http://host:port` (loopback: the in-process + /// `ant_start_gateway` on iOS, `antd` on desktop). + #[serde(default = "default_gateway")] + pub gateway: String, + /// Postage batch id (hex, `0x`-optional) every upload is stamped + /// with. Required: a live publisher with no batch has nowhere to put + /// the video. + pub batch_id: String, + /// Nominal segment duration. Only used for the playlist's + /// `EXT-X-TARGETDURATION` and the "keeping up" budget — the real + /// duration of each segment comes from the capture side. + #[serde(default = "default_segment_ms")] + pub segment_ms: u32, + /// Encoder target bitrate. Echoed in the report; the publisher does + /// not enforce it (the capture pipeline does). + #[serde(default = "default_bitrate")] + pub bitrate_kbps: u32, + #[serde(default = "default_max_in_flight")] + pub max_in_flight: usize, + #[serde(default = "default_max_backlog")] + pub max_backlog: usize, + #[serde(default = "default_playlist_window")] + pub playlist_window: usize, + /// Free-form host context (device, network, rendition) echoed in the + /// final report. + #[serde(default)] + pub notes: String, +} + +fn default_gateway() -> String { + "http://127.0.0.1:1633".to_string() +} +const fn default_segment_ms() -> u32 { + DEFAULT_SEGMENT_MS +} +const fn default_bitrate() -> u32 { + DEFAULT_BITRATE_KBPS +} +const fn default_max_in_flight() -> usize { + DEFAULT_MAX_IN_FLIGHT +} +const fn default_max_backlog() -> usize { + DEFAULT_MAX_BACKLOG +} +const fn default_playlist_window() -> usize { + DEFAULT_PLAYLIST_WINDOW +} + +impl PublisherConfig { + fn validate(&self) -> Result<(), PublisherError> { + if self.channel.trim().is_empty() { + return Err(PublisherError::Config( + "channel is required: it names the feed viewers subscribe to".into(), + )); + } + if self.batch_id.trim().is_empty() { + return Err(PublisherError::Config( + "batch_id is required: a broadcast needs a storage plan to stamp its segments" + .into(), + )); + } + if self.segment_ms == 0 { + return Err(PublisherError::Config("segment_ms must be > 0".into())); + } + if self.max_in_flight == 0 { + return Err(PublisherError::Config("max_in_flight must be > 0".into())); + } + if self.max_backlog == 0 { + return Err(PublisherError::Config("max_backlog must be > 0".into())); + } + if self.playlist_window == 0 { + return Err(PublisherError::Config("playlist_window must be > 0".into())); + } + Ok(()) + } + + /// The feed topic this broadcast writes to: the configured one, or a + /// per-broadcast derivation of the channel name. + fn resolve_topic(&self) -> Result<[u8; 32], PublisherError> { + // A wall-clock seed alone is not unique enough: stop + restart + // of the same channel inside one clock tick would silently reuse + // the topic, and the new broadcast's index 0 would overwrite the + // old feed's head. The process-wide counter makes every + // derivation distinct regardless of clock granularity. + static TOPIC_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let trimmed = self.topic.trim(); + if trimmed.is_empty() { + let unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let nonce = TOPIC_NONCE.fetch_add(1, Ordering::Relaxed); + return Ok(ant_crypto::keccak256( + format!("antstream/{}/{unix_ms}/{nonce}", self.channel).as_bytes(), + )); + } + parse_hex32(trimmed).map_err(|e| PublisherError::Config(format!("topic {e}"))) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PublisherError { + #[error("{0}")] + Config(String), +} + +fn parse_hex32(raw: &str) -> Result<[u8; 32], String> { + let trimmed = raw.trim().trim_start_matches("0x"); + let bytes = hex::decode(trimmed).map_err(|e| format!("is not hex: {e}"))?; + <[u8; 32]>::try_from(bytes.as_slice()) + .map_err(|_| format!("must be 32 bytes (64 hex chars), got {}", trimmed.len())) +} + +// --------------------------------------------------------------------------- +// Segments pushed in from the capture pipeline +// --------------------------------------------------------------------------- + +/// Which kind of fMP4 payload the capture pipeline handed over. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SegmentKind { + /// The fMP4 initialization segment (`ftyp` + `moov`). Referenced by + /// `#EXT-X-MAP`; re-emitted whenever the writer restarts (camera + /// flip, interruption recovery, bitrate downshift). + Init, + /// A media segment (`moof` + `mdat`). + Media, +} + +impl SegmentKind { + const fn as_str(self) -> &'static str { + match self { + Self::Init => "init", + Self::Media => "media", + } + } +} + +/// What [`LiveRun::push`] did with a segment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PushOutcome { + /// Queued for publishing. + Queued, + /// Queued, but the backlog was full so the oldest pending segment + /// was dropped to stay at the live edge. + QueuedDroppingOldest, + /// The run is stopping and no longer accepts segments. + Closed, +} + +struct Pending { + seq: u64, + kind: SegmentKind, + data: Vec, + duration_ms: u32, + /// Capture-side discontinuity: the writer restarted, so the next + /// media segment does not continue the previous one's timeline. + discontinuity: bool, + captured_at: Instant, +} + +/// A segment that landed, in playlist terms. +#[derive(Debug, Clone)] +struct MediaEntry { + /// HLS media sequence number (counts only listed segments). + number: u64, + uri: String, + duration_ms: u32, + /// `#EXT-X-DISCONTINUITY` precedes this entry. + discontinuity: bool, + /// The `#EXT-X-MAP` in force for this entry. + init_uri: String, +} + +/// Per-sequence outcome, collected out of order by the concurrent +/// publish tasks and consumed **in order** by the committer. +enum Committed { + Init { + uri: String, + }, + Media { + uri: String, + duration_ms: u32, + discontinuity: bool, + captured_at: Instant, + }, + /// Dropped before it was ever uploaded (backlog full). Only media + /// segments are ever dropped. + Dropped, + /// Upload failed. The kind matters: a failed *initialization* + /// segment retires the `#EXT-X-MAP` currently in force, because the + /// writer that emitted it has already been replaced and its map does + /// not describe what follows. + Failed(SegmentKind), +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// How many latency samples are kept for the percentile figures. A +/// multi-hour broadcast must not grow an unbounded vector, and the tail +/// of a live run is what the indicator is about anyway. +const LATENCY_SAMPLES: usize = 2048; + +#[derive(Default)] +struct PublisherState { + /// Captured, not yet started. Drop-oldest when longer than + /// `max_backlog`. + pending: VecDeque, + /// Completed-but-not-yet-committed outcomes, keyed by sequence. + done: BTreeMap, + /// Set by the pump once it has drained the backlog *and* awaited + /// every in-flight upload. The committer's exit condition keys off + /// this rather than "queues look empty": a segment the pump has + /// popped but whose upload has not landed yet is in neither queue, + /// and treating that instant as "drained" would end the broadcast + /// with its last segments never reaching a playlist. + pump_finished: bool, + /// Next sequence the committer will fold into the playlist. + next_commit: u64, + /// Next sequence [`LiveRun::push`] hands out. + next_seq: u64, + + /// The rolling media playlist. + playlist: VecDeque, + /// Media sequence number for the next listed segment. + next_number: u64, + /// `#EXT-X-DISCONTINUITY-SEQUENCE`: discontinuities that have + /// already slid out of the playlist window. + discontinuity_sequence: u64, + /// `#EXT-X-MAP` currently in force. + init_uri: Option, + /// A gap (drop or failure) since the last listed segment, so the + /// next one that lands starts a new continuous run. + gap_since_last_listed: bool, + + segments_pushed: u64, + segments_published: u64, + /// Media segments that made it into a *published* playlist, i.e. + /// that a viewer could actually play. Distinct from + /// `segments_published`: a segment whose initialization segment + /// never landed uploads fine and is still unplayable. + segments_listed: u64, + segments_failed: u64, + segments_dropped: u64, + bytes_published: u64, + chunks_published: u64, + playlists_published: u64, + feed_updates: u64, + /// Next sequence-feed index to write. Only advances on a SOC write + /// that actually landed, so a failed update is retried at the same + /// index rather than leaving a hole the finder would stop at. + feed_index: u64, + channel_reference: Option, + /// A `POST /feeds` is already in flight. The start-time attempt and + /// the committer's retry would otherwise both fire while the first + /// one is still on the wire — harmless (the manifest is + /// content-addressed) but a wasted upload on the live path. + channel_manifest_in_flight: bool, + playlist_reference: Option, + + publish_ms: VecDeque, + lag_ms: VecDeque, + lag_ms_max: u64, + /// **The live edge**: the capture instant of the newest media + /// segment a viewer can actually play, i.e. the one carried by the + /// last feed update that *landed*. Its age is the live-edge lag, so + /// it keeps growing while updates fail — unlike the lag of the last + /// landed update, which freezes the moment they stop landing. + /// + /// Bootstrapped in [`PublisherState::advance`] with the first + /// committed segment, so a broadcast whose very first feed update + /// never lands measures its lag from the content nobody saw rather + /// than from nothing at all. + live_edge: Option, + /// [`PublisherState::live_edge_lag_ms`] frozen when the run + /// finished, so a report read later does not keep ageing. + lag_frozen_ms: Option, + /// When the run finished, so the duration (and the throughput + /// figures divided by it) freezes with the broadcast — the same + /// principle as `lag_frozen_ms`: a report read a minute later must + /// describe the broadcast, not how long the host waited to ask. + ended_at: Option, + /// Longest media segment ever listed, in ms. Drives + /// `#EXT-X-TARGETDURATION`, which RFC 8216 §6.2.1 says MUST NOT + /// change across playlist reloads — so it is a run-max that can + /// only ratchet up (a keyframe-aligned cut routinely overruns the + /// configured target), never drop back when the long segment slides + /// out of the window. + longest_listed_ms: u32, + + errors: Vec, + last_error: Option, + error_count: u64, + peers: u32, + finished: bool, +} + +impl PublisherState { + fn record_error(&mut self, message: String) { + self.error_count += 1; + if self.errors.len() < 8 { + self.errors.push(message.clone()); + } + self.last_error = Some(message); + } + + fn record_latency(queue: &mut VecDeque, value: u64) { + if queue.len() == LATENCY_SAMPLES { + queue.pop_front(); + } + queue.push_back(value); + } + + /// A feed update landed, making `captured_at`'s segment playable: + /// record its latency and move the live edge up to it. + fn note_lag(&mut self, captured_at: Instant) { + let value = ms(captured_at.elapsed()); + Self::record_latency(&mut self.lag_ms, value); + self.lag_ms_max = self.lag_ms_max.max(value); + self.live_edge = Some(captured_at); + } + + /// How far behind live a viewer is **right now**: the age of the + /// newest segment a landed feed update made playable. + /// + /// Deliberately not "the lag of the last update that landed": that + /// figure only moves when an update succeeds, so once the feed + /// stops updating — a failing `POST /soc`, a playlist that will not + /// upload — it freezes at its last good value and the badge stays + /// green while the viewer is stuck on an old playlist. This one + /// grows for exactly as long as nothing new reaches a viewer. + fn live_edge_lag_ms(&self) -> u64 { + if let Some(frozen) = self.lag_frozen_ms { + return frozen; + } + self.live_edge.map_or(0, |at| ms(at.elapsed())) + } + + /// Fold every already-finished outcome from `next_commit` forward + /// into the playlist. Returns the newest committed media segment's + /// capture instant when the playlist changed (that segment's lag is + /// only known once the feed update lands), `None` when it didn't. + fn advance(&mut self, window: usize) -> Option { + let mut newest: Option = None; + while let Some(outcome) = self.done.remove(&self.next_commit) { + self.next_commit += 1; + match outcome { + Committed::Init { uri } => { + // A fresh initialization segment means a fresh + // encoder timeline: whatever follows is discontinuous + // with what came before. + self.init_uri = Some(uri); + if !self.playlist.is_empty() { + self.gap_since_last_listed = true; + } + } + Committed::Dropped | Committed::Failed(SegmentKind::Media) => { + self.gap_since_last_listed = true; + } + Committed::Failed(SegmentKind::Init) => { + // Listing later segments under the *previous* + // writer's map would hand a player an initialization + // segment that does not describe them. Better a + // shorter playlist than an unplayable one: nothing + // is listed again until a fresh init lands. + self.init_uri = None; + self.gap_since_last_listed = true; + } + Committed::Media { + uri, + duration_ms, + discontinuity, + captured_at, + } => { + let Some(init_uri) = self.init_uri.clone() else { + // A media segment whose initialization segment + // never landed is unplayable; list nothing and + // leave the gap flag set. + self.gap_since_last_listed = true; + continue; + }; + let discontinuous = discontinuity + || self.gap_since_last_listed + || self + .playlist + .back() + .is_some_and(|prev| prev.init_uri != init_uri); + self.gap_since_last_listed = false; + self.segments_listed += 1; + self.longest_listed_ms = self.longest_listed_ms.max(duration_ms); + self.playlist.push_back(MediaEntry { + number: self.next_number, + uri, + duration_ms, + discontinuity: discontinuous && self.next_number > 0, + init_uri, + }); + self.next_number += 1; + // Start the live-edge clock at the first segment + // that could have been seen. Later segments do not + // move it — only a landed feed update does, in + // `note_lag` — because a segment nobody can resolve + // yet is not the live edge. + self.live_edge.get_or_insert(captured_at); + while self.playlist.len() > window { + if let Some(evicted) = self.playlist.pop_front() { + if evicted.discontinuity { + self.discontinuity_sequence += 1; + } + } + } + newest = Some(captured_at); + } + } + } + newest + } + + /// Render the current playlist. `endlist` closes the broadcast. + fn render_playlist(&self, target_ms: u32, endlist: bool) -> String { + // Run-max, not window-max: see `longest_listed_ms`. + let target_s = self.longest_listed_ms.max(target_ms).div_ceil(1000).max(1); + use std::fmt::Write as _; + let mut out = String::from("#EXTM3U\n#EXT-X-VERSION:7\n"); + // Writing into a `String` is infallible, so the `write!` results + // are deliberately dropped rather than unwrapped. + let _ = writeln!(out, "#EXT-X-TARGETDURATION:{target_s}"); + let _ = writeln!( + out, + "#EXT-X-MEDIA-SEQUENCE:{}", + self.playlist.front().map_or(self.next_number, |e| e.number), + ); + if self.discontinuity_sequence > 0 { + let _ = writeln!( + out, + "#EXT-X-DISCONTINUITY-SEQUENCE:{}", + self.discontinuity_sequence, + ); + } + let mut current_map: Option<&str> = None; + for entry in &self.playlist { + if entry.discontinuity { + out.push_str("#EXT-X-DISCONTINUITY\n"); + } + if current_map != Some(entry.init_uri.as_str()) { + let _ = writeln!(out, "#EXT-X-MAP:URI=\"{}\"", entry.init_uri); + current_map = Some(entry.init_uri.as_str()); + } + let _ = writeln!( + out, + "#EXTINF:{:.3},\n{}", + f64::from(entry.duration_ms) / 1000.0, + entry.uri, + ); + } + if endlist { + out.push_str("#EXT-X-ENDLIST\n"); + } + out + } +} + +// --------------------------------------------------------------------------- +// Snapshot / report +// --------------------------------------------------------------------------- + +/// Live progress of a broadcast, for the on-screen indicator. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PublisherSnapshot { + pub running: bool, + pub elapsed_s: f64, + pub channel: String, + /// Feed topic, hex. With `owner` this is the address any bee gateway + /// resolves the live playlist from (`GET /feeds/{owner}/{topic}`). + pub topic: String, + pub owner: String, + /// Feed-manifest reference created at start (`POST /feeds`) — the + /// single reference that identifies the channel to a viewer. + pub channel_reference: String, + /// Reference of the most recently published playlist. + pub playlist_reference: String, + /// Sequence-feed index of the next update (i.e. how many updates + /// have landed). + pub feed_index: u64, + pub segments_pushed: u64, + pub segments_published: u64, + /// Of those, the ones that reached a published playlist — what a + /// viewer could actually play. + pub segments_listed: u64, + pub segments_failed: u64, + /// Segments the live-edge discipline dropped rather than falling + /// further behind. + pub segments_dropped: u64, + pub bytes_published: u64, + pub playlists_published: u64, + /// Publish latency of one `POST /bzz`, milliseconds. + pub publish_ms_p50: u64, + pub publish_ms_p95: u64, + /// **The live-edge lag**: how far behind live a viewer is right + /// now, i.e. the age of the newest segment a landed feed update + /// made playable. This is what the on-screen indicator shows. + /// + /// It is an age, not the latency of the last update that landed, so + /// a broadcast whose feed updates stop landing keeps climbing here + /// instead of freezing at its last good figure while the segments + /// go on uploading. + pub lag_ms: u64, + /// Highest capture → playable latency any single update recorded. + pub lag_ms_max: u64, + /// `lag_ms` inside the three-segment budget the stage-1 predicate + /// uses. The indicator turns from "live" to "behind" on this. + pub keeping_up: bool, + pub sustained_mbit_s: f64, + pub peers: u32, + pub last_error: String, + pub error_count: u64, +} + +/// Final result of a broadcast, returned by the stop call. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PublisherReport { + pub channel: String, + pub topic: String, + pub owner: String, + pub channel_reference: String, + pub playlist_reference: String, + pub notes: String, + pub target_bitrate_kbps: u32, + pub segment_ms: u32, + pub max_in_flight: usize, + /// Broadcast duration. Frozen when the run finished (like + /// `lag_ms_final`), so it — and the sustained figures divided by + /// it — stays put no matter how much later the report is read. + pub duration_s: f64, + pub segments_pushed: u64, + pub segments_published: u64, + /// Of those, the ones that reached a published playlist. This — not + /// `segments_published` — is what [`PublisherReport::kept_up`] + /// counts, because a segment that uploaded but never got listed was + /// never playable. + pub segments_listed: u64, + pub segments_failed: u64, + pub segments_dropped: u64, + pub bytes_published: u64, + pub chunks_published: u64, + pub playlists_published: u64, + pub feed_updates: u64, + pub sustained_mbit_s: f64, + pub sustained_chunks_s: f64, + pub publish_ms_p50: u64, + pub publish_ms_p95: u64, + pub publish_ms_max: u64, + pub lag_ms_p50: u64, + pub lag_ms_p95: u64, + pub lag_ms_max: u64, + /// The live-edge lag where the broadcast ended: the age of the + /// newest playable segment at stop, frozen there so a report read + /// later still describes the broadcast. + pub lag_ms_final: u64, + /// Every media segment reached a viewer-visible playlist and the + /// live edge ended inside the lag budget — the live-edge equivalent + /// of the bench's `keeps_up`. + pub kept_up: bool, + pub errors: Vec, + pub error_count: u64, +} + +// --------------------------------------------------------------------------- +// The run +// --------------------------------------------------------------------------- + +/// A broadcast in flight. Held by the FFI handle so the host can push +/// segments, poll progress and stop it. +pub struct LiveRun { + config: PublisherConfig, + topic: [u8; 32], + owner: [u8; 20], + state: Arc>, + cancel: Arc, + push_notify: Arc, + commit_notify: Arc, + started_at: Instant, +} + +impl LiveRun { + /// Hand a finished capture segment to the publisher. Never blocks: + /// the capture pipeline must not be stalled by the uplink, which is + /// what the drop-oldest backlog is for. + /// + /// Call order is the broadcast order: the sequence handed out here + /// is the commit order, which fixes both the playlist order and the + /// `#EXT-X-MAP` a media segment is listed under. Callers must push + /// in capture order. + #[must_use] + pub fn push( + &self, + kind: SegmentKind, + data: Vec, + duration_ms: u32, + discontinuity: bool, + ) -> PushOutcome { + if self.cancel.load(Ordering::SeqCst) { + return PushOutcome::Closed; + } + let mut dropped_oldest = false; + { + let mut state = lock(&self.state); + // `cancel()` can land between the check above and this lock. + // Once the pump has done its final sweep nothing will ever + // pop `pending` again, so enqueueing now would orphan the + // segment — reported queued, but never published, failed or + // dropped. The sweep and `pump_finished` are set under this + // same lock, so the race has exactly two outcomes: enqueue + // before the sweep (and be swept up as dropped) or observe + // the flag here and refuse. + if state.pump_finished { + return PushOutcome::Closed; + } + let seq = state.next_seq; + state.next_seq += 1; + state.segments_pushed += 1; + state.pending.push_back(Pending { + seq, + kind, + data, + duration_ms, + discontinuity, + captured_at: Instant::now(), + }); + // Drop-oldest: the backlog is bounded, and when it overflows + // the *oldest* pending segment is the one least worth + // sending — it is already the furthest behind live. An + // initialization segment is never dropped: without it every + // later media segment is unplayable. + while state.pending.len() > self.config.max_backlog { + let Some(index) = state + .pending + .iter() + .position(|p| p.kind == SegmentKind::Media) + else { + break; + }; + let Some(victim) = state.pending.remove(index) else { + break; + }; + state.segments_dropped += 1; + state.done.insert(victim.seq, Committed::Dropped); + dropped_oldest = true; + } + } + self.push_notify.notify_one(); + if dropped_oldest { + self.commit_notify.notify_one(); + PushOutcome::QueuedDroppingOldest + } else { + PushOutcome::Queued + } + } + + /// Elapsed broadcast time: still ticking while the run is live, + /// frozen at `ended_at` once it finished. + fn elapsed_s(&self, state: &PublisherState) -> f64 { + state + .ended_at + .unwrap_or_else(Instant::now) + .duration_since(self.started_at) + .as_secs_f64() + } + + /// Live progress. Cheap: locks the state, folds, releases. + #[must_use] + pub fn snapshot(&self) -> PublisherSnapshot { + let state = lock(&self.state); + let elapsed = self.elapsed_s(&state); + let mut publish_ms: Vec = state.publish_ms.iter().copied().collect(); + publish_ms.sort_unstable(); + PublisherSnapshot { + running: !state.finished, + elapsed_s: elapsed, + channel: self.config.channel.clone(), + topic: hex::encode(self.topic), + owner: hex::encode(self.owner), + channel_reference: state.channel_reference.clone().unwrap_or_default(), + playlist_reference: state.playlist_reference.clone().unwrap_or_default(), + feed_index: state.feed_index, + segments_pushed: state.segments_pushed, + segments_published: state.segments_published, + segments_listed: state.segments_listed, + segments_failed: state.segments_failed, + segments_dropped: state.segments_dropped, + bytes_published: state.bytes_published, + playlists_published: state.playlists_published, + publish_ms_p50: percentile(&publish_ms, 50), + publish_ms_p95: percentile(&publish_ms, 95), + lag_ms: state.live_edge_lag_ms(), + lag_ms_max: state.lag_ms_max, + keeping_up: state.feed_updates > 0 + && state.live_edge_lag_ms() <= lag_budget_ms(self.config.segment_ms), + sustained_mbit_s: throughput_mbit_s(state.bytes_published, elapsed), + peers: state.peers, + last_error: state.last_error.clone().unwrap_or_default(), + error_count: state.error_count, + } + } + + /// Ask the loop to stop. Cooperative: already-captured segments are + /// drained (the last seconds of a broadcast are worth publishing) + /// and the playlist is closed with `#EXT-X-ENDLIST`. + pub fn cancel(&self) { + self.cancel.store(true, Ordering::SeqCst); + // `notify_one` rather than `notify_waiters`: the pump and the + // committer each register their waiter *after* checking their + // queue, so a cancel that lands in that gap would be missed by + // `notify_waiters` (which stores no permit) and leave both tasks + // asleep for good. + self.push_notify.notify_one(); + self.commit_notify.notify_one(); + } + + #[must_use] + pub fn is_finished(&self) -> bool { + lock(&self.state).finished + } + + /// Final report. Safe to call while the run is still going. + #[must_use] + pub fn report(&self) -> PublisherReport { + let state = lock(&self.state); + let elapsed = self.elapsed_s(&state); + let mut publish_ms: Vec = state.publish_ms.iter().copied().collect(); + let mut lag_ms: Vec = state.lag_ms.iter().copied().collect(); + publish_ms.sort_unstable(); + lag_ms.sort_unstable(); + let media_pushed = state + .segments_published + .saturating_add(state.segments_failed) + .saturating_add(state.segments_dropped); + PublisherReport { + channel: self.config.channel.clone(), + topic: hex::encode(self.topic), + owner: hex::encode(self.owner), + channel_reference: state.channel_reference.clone().unwrap_or_default(), + playlist_reference: state.playlist_reference.clone().unwrap_or_default(), + notes: self.config.notes.clone(), + target_bitrate_kbps: self.config.bitrate_kbps, + segment_ms: self.config.segment_ms, + max_in_flight: self.config.max_in_flight, + duration_s: elapsed, + segments_pushed: state.segments_pushed, + segments_published: state.segments_published, + segments_listed: state.segments_listed, + segments_failed: state.segments_failed, + segments_dropped: state.segments_dropped, + bytes_published: state.bytes_published, + chunks_published: state.chunks_published, + playlists_published: state.playlists_published, + feed_updates: state.feed_updates, + sustained_mbit_s: throughput_mbit_s(state.bytes_published, elapsed), + sustained_chunks_s: if elapsed > 0.0 { + state.chunks_published as f64 / elapsed + } else { + 0.0 + }, + publish_ms_p50: percentile(&publish_ms, 50), + publish_ms_p95: percentile(&publish_ms, 95), + publish_ms_max: publish_ms.last().copied().unwrap_or(0), + lag_ms_p50: percentile(&lag_ms, 50), + lag_ms_p95: percentile(&lag_ms, 95), + lag_ms_max: state.lag_ms_max, + lag_ms_final: state.live_edge_lag_ms(), + // Same shape as the stage-1 predicate, scoped to what a + // viewer saw: every media segment that was captured reached + // a published playlist, at least one feed update landed, and + // the live edge finished inside the lag budget. + // + // The count is `segments_listed`, not `segments_published`: + // a segment whose initialization segment never landed + // uploads perfectly well and is still unplayable, so + // counting uploads here would let that pass as "kept up". + // + // The lag is the *live edge*, not the last landed update's + // latency: segments keep uploading and listing while the + // feed is stuck, so a frozen latency would call a broadcast + // no viewer could follow "kept up". + kept_up: state.feed_updates > 0 + && state.segments_listed > 0 + && state.segments_listed == media_pushed + && state.live_edge_lag_ms() <= lag_budget_ms(self.config.segment_ms), + errors: state.errors.clone(), + error_count: state.error_count, + } + } +} + +/// Bound on the acceptable live-edge lag: three segment durations — +/// the same budget the stage-1 bench verdict uses. +const fn lag_budget_ms(segment_ms: u32) -> u64 { + segment_ms as u64 * 3 +} + +fn throughput_mbit_s(bytes: u64, seconds: f64) -> f64 { + if seconds <= 0.0 { + return 0.0; + } + bytes as f64 * 8.0 / seconds / 1_000_000.0 +} + +/// Start a broadcast on `runtime`, returning the handle immediately. +pub fn start( + runtime: &tokio::runtime::Handle, + config: PublisherConfig, + signing_secret: [u8; 32], + owner: [u8; 20], + status_rx: Option>, +) -> Result, PublisherError> { + config.validate()?; + let topic = config.resolve_topic()?; + let target = Target::parse(&config.gateway).map_err(PublisherError::Config)?; + let batch = parse_hex32(&config.batch_id) + .map_err(|e| PublisherError::Config(format!("batch_id {e}")))?; + + let state = Arc::new(Mutex::new(PublisherState::default())); + let cancel = Arc::new(AtomicBool::new(false)); + let push_notify = Arc::new(Notify::new()); + let commit_notify = Arc::new(Notify::new()); + let run = Arc::new(LiveRun { + config: config.clone(), + topic, + owner, + state: Arc::clone(&state), + cancel: Arc::clone(&cancel), + push_notify: Arc::clone(&push_notify), + commit_notify: Arc::clone(&commit_notify), + started_at: Instant::now(), + }); + + let ctx = Arc::new(RunCtx { + config, + topic, + owner, + batch, + signing_secret, + target, + state, + cancel, + push_notify, + commit_notify, + status_rx, + }); + runtime.spawn(async move { drive(ctx).await }); + Ok(run) +} + +struct RunCtx { + config: PublisherConfig, + topic: [u8; 32], + owner: [u8; 20], + batch: [u8; 32], + signing_secret: [u8; 32], + target: Target, + state: Arc>, + cancel: Arc, + push_notify: Arc, + commit_notify: Arc, + status_rx: Option>, +} + +impl RunCtx { + fn cancelled(&self) -> bool { + self.cancel.load(Ordering::SeqCst) + } +} + +/// How often the peer count is sampled for the progress view. +const SAMPLE_INTERVAL: Duration = Duration::from_secs(5); + +async fn drive(ctx: Arc) { + let sampler = spawn_peer_sampler(&ctx); + // The channel's feed manifest: one immutable reference a viewer can + // be handed (`bzz://`) that resolves through any bee gateway to + // whatever the latest feed update points at. + // + // Concurrent with the loop, not before it: this is a real upload and + // can sit on the 60 s publish deadline if the peer set is still + // warming up, and the first minute of a broadcast must not be spent + // waiting for a reference nobody has been given yet. It is + // best-effort for the same reason — the feed updates are what carry + // the stream — and the committer retries it until it lands. + let manifest = { + let ctx = Arc::clone(&ctx); + tokio::spawn(async move { ensure_channel_manifest(&ctx).await }) + }; + + let pump = tokio::spawn(pump(Arc::clone(&ctx))); + let committer = tokio::spawn(commit_loop(Arc::clone(&ctx))); + let _ = pump.await; + let _ = committer.await; + manifest.abort(); + + if let Some(sampler) = sampler { + sampler.abort(); + } + // The lag is an age, so a report read a minute after the broadcast + // ended must still describe the broadcast rather than how long the + // host waited to ask. The committer normally freezes it with the + // last content; this is the fallback for a run that never got that + // far. + { + let mut state = lock(&ctx.state); + let lag = state.live_edge_lag_ms(); + state.lag_frozen_ms.get_or_insert(lag); + // Duration freezes with the run for the same reason the lag + // does: `duration_s` and the throughput figures divided by it + // must describe the broadcast, not the wait before the report + // was read. + state.ended_at.get_or_insert_with(Instant::now); + state.finished = true; + } +} + +fn spawn_peer_sampler(ctx: &RunCtx) -> Option> { + let status_rx = ctx.status_rx.clone()?; + let state = Arc::clone(&ctx.state); + Some(tokio::spawn(async move { + loop { + let peers = status_rx.borrow().peers.connected; + lock(&state).peers = peers; + tokio::time::sleep(SAMPLE_INTERVAL).await; + } + })) +} + +/// Take pending segments and publish them through a bounded in-flight +/// window. Returns once the run is cancelled *and* the backlog has +/// drained — the tail of a broadcast is real content, not a truncated +/// measurement. +async fn pump(ctx: Arc) { + let window = Arc::new(Semaphore::new(ctx.config.max_in_flight)); + let mut in_flight: Vec> = Vec::new(); + loop { + // Register interest *before* looking at the queue, so a push + // that lands between the check and the await still wakes us. + let notified = ctx.push_notify.notified(); + tokio::pin!(notified); + + let next = lock(&ctx.state).pending.pop_front(); + let Some(pending) = next else { + if ctx.cancelled() { + break; + } + notified.await; + continue; + }; + + let Ok(permit) = Arc::clone(&window).acquire_owned().await else { + break; + }; + in_flight.retain(|task| !task.is_finished()); + let ctx2 = Arc::clone(&ctx); + in_flight.push(tokio::spawn(async move { + let _permit = permit; + publish_pending(&ctx2, pending).await; + })); + } + for task in in_flight { + let _ = task.await; + } + // The pump is the only producer of commit work; publish that it is + // done and wake the committer so it can fold in the final segments + // and close the playlist. + // + // The sweep and the flag are one critical section: a `push` that + // raced `cancel()` past its entry check either enqueued before this + // lock (its segment is swept up as dropped here, with the committer + // still waiting on `done`) or acquires the lock after it, observes + // `pump_finished`, and refuses — so no segment can sit in `pending` + // with nobody left to pop it. + { + let mut state = lock(&ctx.state); + while let Some(victim) = state.pending.pop_front() { + if victim.kind == SegmentKind::Media { + state.segments_dropped += 1; + } + state.done.insert(victim.seq, Committed::Dropped); + } + state.pump_finished = true; + } + ctx.commit_notify.notify_one(); +} + +async fn publish_pending(ctx: &RunCtx, pending: Pending) { + let name = match pending.kind { + SegmentKind::Init => format!("init-{}.mp4", pending.seq), + SegmentKind::Media => format!("seg-{}.m4s", pending.seq), + }; + let content_type = match pending.kind { + SegmentKind::Init => "video/mp4", + SegmentKind::Media => "video/iso.segment", + }; + let bytes = pending.data.len() as u64; + let started = Instant::now(); + let result = publish_bzz(&ctx.target, ctx.batch, &name, content_type, &pending.data).await; + let elapsed = started.elapsed(); + + { + let mut state = lock(&ctx.state); + match result { + Ok(reference) => { + PublisherState::record_latency(&mut state.publish_ms, ms(elapsed)); + let uri = bzz_uri(&reference, &name); + match pending.kind { + SegmentKind::Init => { + state.done.insert(pending.seq, Committed::Init { uri }); + } + SegmentKind::Media => { + state.segments_published += 1; + state.bytes_published += bytes; + state.chunks_published += data_chunk_count(bytes); + state.done.insert( + pending.seq, + Committed::Media { + uri, + duration_ms: pending.duration_ms, + discontinuity: pending.discontinuity, + captured_at: pending.captured_at, + }, + ); + } + } + } + Err(message) => { + if pending.kind == SegmentKind::Media { + state.segments_failed += 1; + } + state.record_error(format!( + "{} segment {}: {message}", + pending.kind.as_str(), + pending.seq, + )); + state + .done + .insert(pending.seq, Committed::Failed(pending.kind)); + } + } + } + ctx.commit_notify.notify_one(); +} + +/// Fold finished segments into the playlist **in capture order**, then +/// republish the playlist and point the feed at it. Single task, so +/// feed indices are written strictly in order. +async fn commit_loop(ctx: Arc) { + loop { + let notified = ctx.commit_notify.notified(); + tokio::pin!(notified); + + let newest = { + let mut state = lock(&ctx.state); + state.advance(ctx.config.playlist_window) + }; + if let Some(captured_at) = newest { + publish_playlist(&ctx, false, Some(captured_at)).await; + continue; + } + // Nothing new. If the pump has finished (backlog drained *and* + // every upload awaited) and every sequence has been folded in, + // close the broadcast out. + let drained = { + let state = lock(&ctx.state); + state.pump_finished && state.done.is_empty() + }; + if drained { + break; + } + notified.await; + } + // Freeze the live-edge lag *with the last content*, before the + // closing playlist: what follows is bookkeeping, and a slow final + // upload must not read as a broadcast that fell behind. + { + let mut state = lock(&ctx.state); + let lag = state.live_edge_lag_ms(); + state.lag_frozen_ms = Some(lag); + } + // Final playlist: `#EXT-X-ENDLIST` turns the live channel into a + // finished recording for anyone still resolving the feed. + if !lock(&ctx.state).playlist.is_empty() { + publish_playlist(&ctx, true, None).await; + } +} + +/// Upload the current playlist and publish its reference as the next +/// feed update. `captured_at`, when present, is the newest committed +/// segment — its live-edge lag is only known once the feed update lands. +async fn publish_playlist(ctx: &Arc, endlist: bool, captured_at: Option) { + let body = { + let state = lock(&ctx.state); + state.render_playlist(ctx.config.segment_ms, endlist) + }; + let playlist_ref = match publish_bzz( + &ctx.target, + ctx.batch, + PLAYLIST_NAME, + "application/vnd.apple.mpegurl", + body.as_bytes(), + ) + .await + { + Ok(reference) => reference, + Err(message) => { + lock(&ctx.state).record_error(format!("playlist: {message}")); + return; + } + }; + let reference = match parse_hex32(&playlist_ref) { + Ok(r) => r, + Err(e) => { + lock(&ctx.state).record_error(format!("playlist reference {e}")); + return; + } + }; + { + let mut state = lock(&ctx.state); + state.playlists_published += 1; + state.playlist_reference = Some(playlist_ref); + } + + let index = lock(&ctx.state).feed_index; + match publish_feed_update(ctx, index, &reference).await { + Ok(()) => { + let mut state = lock(&ctx.state); + state.feed_index += 1; + state.feed_updates += 1; + if let Some(captured_at) = captured_at { + state.note_lag(captured_at); + } + } + Err(message) => { + lock(&ctx.state).record_error(format!("feed update {index}: {message}")); + } + } + // A channel manifest that could not be created at start is retried + // here, so a broadcast that began before the peer set was warm still + // ends up with a shareable reference — but *spawned*, never awaited: + // this runs on the committer, and a `POST /feeds` hanging on its + // 60 s deadline must not stall the playlist and feed updates for + // segments that already landed. `channel_manifest_in_flight` keeps + // it to one attempt at a time, same as at start. + if lock(&ctx.state).channel_reference.is_none() { + let ctx = Arc::clone(ctx); + tokio::spawn(async move { ensure_channel_manifest(&ctx).await }); + } +} + +/// Playlist file name. Constant so every republish lands at the same +/// manifest path and a viewer's URI stays stable across updates. +const PLAYLIST_NAME: &str = "stream.m3u8"; + +/// Create the channel's feed manifest (`POST /feeds/{owner}/{topic}`, +/// bee-js `createFeedManifest`). Idempotent in effect: the manifest is +/// content-addressed, so re-creating it yields the same reference. +async fn ensure_channel_manifest(ctx: &RunCtx) { + { + let mut state = lock(&ctx.state); + if state.channel_reference.is_some() || state.channel_manifest_in_flight { + return; + } + state.channel_manifest_in_flight = true; + } + let path = format!( + "{}/feeds/{}/{}", + ctx.target.prefix, + hex::encode(ctx.owner), + hex::encode(ctx.topic), + ); + let headers = [("swarm-postage-batch-id".to_string(), hex::encode(ctx.batch))]; + let outcome = post_reference(&ctx.target, &path, &headers, &[]).await; + let mut state = lock(&ctx.state); + state.channel_manifest_in_flight = false; + match outcome { + Ok(reference) => state.channel_reference = Some(reference), + Err(message) => state.record_error(format!("channel manifest: {message}")), + } +} + +/// Write one sequence-feed update: a single-owner chunk at +/// `id = keccak256(topic ‖ index_be8)` whose payload is bee's v1 update +/// layout `timestamp_be8 ‖ reference`. +/// +/// This is the shape bee-js's `FeedWriter.upload` produces and every bee +/// version's feed getter resolves, which is what lets a **public** bee +/// gateway serve the channel even though the segments were pushed from a +/// phone. +async fn publish_feed_update(ctx: &RunCtx, index: u64, reference: &[u8; 32]) -> Result<(), String> { + let id = ant_retrieval::sequence_update_id(&ctx.topic, index); + let mut payload = Vec::with_capacity(8 + 32); + payload.extend_from_slice( + &SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_be_bytes(), + ); + payload.extend_from_slice(reference); + + // Inner content-addressed chunk: `span(8 LE) ‖ payload`, which is + // both what the SOC signature covers and what `POST /soc` takes as + // its body. + let (cac_address, cac_wire) = + ant_crypto::cac_new(&payload).ok_or_else(|| "feed payload too large".to_string())?; + // Bee signs `keccak256(id ‖ inner_cac_address)` through its default + // (EIP-191-wrapping) signer — see `bee/pkg/soc/soc.go::Sign` and + // `ant_crypto::soc_valid`, which recovers the owner the same way. + let mut digest_input = [0u8; 64]; + digest_input[..32].copy_from_slice(&id); + digest_input[32..].copy_from_slice(&cac_address); + let signature = + ant_crypto::sign_handshake_data(&ctx.signing_secret, &ant_crypto::keccak256(&digest_input)) + .map_err(|e| format!("sign feed update: {e}"))?; + + let path = format!( + "{}/soc/{}/{}?sig={}", + ctx.target.prefix, + hex::encode(ctx.owner), + hex::encode(id), + hex::encode(signature), + ); + let headers = [ + ( + "content-type".to_string(), + "application/octet-stream".to_string(), + ), + ("swarm-postage-batch-id".to_string(), hex::encode(ctx.batch)), + ]; + post_reference(&ctx.target, &path, &headers, &cac_wire).await?; + Ok(()) +} + +/// The URI a viewer's HLS client fetches an uploaded object from. +/// Root-relative so the same playlist works against the in-process +/// gateway, `antd`, and a public bee gateway (all serve `/bzz/` at the +/// root of their API). +fn bzz_uri(reference: &str, name: &str) -> String { + format!("/bzz/{reference}/{name}") +} + +// --------------------------------------------------------------------------- +// Publish path +// --------------------------------------------------------------------------- + +/// Publish one body with `POST /bzz`, returning its reference. This is +/// the call stage 1 measured; the live loop and the bench both go +/// through it. +pub(crate) async fn publish_bzz( + target: &Target, + batch: [u8; 32], + name: &str, + content_type: &str, + payload: &[u8], +) -> Result { + let path = format!("{}/bzz?name={name}", target.prefix); + let headers = [ + ("content-type".to_string(), content_type.to_string()), + ("swarm-postage-batch-id".to_string(), hex::encode(batch)), + ]; + post_reference(target, &path, &headers, payload).await +} + +/// `POST` something the gateway answers with bee's +/// `{"reference":""}` and hand back that reference. +async fn post_reference( + target: &Target, + path: &str, + headers: &[(String, String)], + body: &[u8], +) -> Result { + let response = tokio::time::timeout(PUBLISH_TIMEOUT, http_post(target, path, headers, body)) + .await + .map_err(|_| format!("timed out after {}s", PUBLISH_TIMEOUT.as_secs()))??; + if response.status != 201 { + return Err(format!( + "gateway returned {} {}", + response.status, + String::from_utf8_lossy(&response.body).trim(), + )); + } + let parsed: serde_json::Value = serde_json::from_slice(&response.body) + .map_err(|e| format!("gateway response is not JSON: {e}"))?; + parsed + .get("reference") + .and_then(serde_json::Value::as_str) + .map(|r| r.trim_start_matches("0x").to_ascii_lowercase()) + .ok_or_else(|| "gateway response carries no reference".to_string()) +} + +// --------------------------------------------------------------------------- +// Minimal loopback HTTP/1.1 client +// --------------------------------------------------------------------------- +// +// The gateway the publisher posts to is always on loopback — in-process +// on iOS (`ant_start_gateway`), `antd` on desktop — so a full HTTP +// client stack would be dead weight in the mobile slice, which +// deliberately drops `reqwest` (see `ant-ffi/Cargo.toml`). This is the +// smallest thing that speaks the one request shape the publisher needs: +// `POST` with a `Content-Length` body, one response, connection closed. + +/// Parsed `http://host:port/prefix` gateway base. +#[derive(Debug, Clone)] +pub(crate) struct Target { + pub(crate) authority: String, + /// Path prefix, without a trailing slash (`""` for a bare host). + pub(crate) prefix: String, +} + +impl Target { + pub(crate) fn parse(url: &str) -> Result { + let rest = url.trim().strip_prefix("http://").ok_or_else(|| { + format!( + "gateway must be an http:// URL (the publisher posts to a loopback gateway), got `{url}`", + ) + })?; + let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p)); + if authority.is_empty() { + return Err(format!("gateway has no host: `{url}`")); + } + let authority = if authority.contains(':') { + authority.to_string() + } else { + format!("{authority}:80") + }; + let prefix = path.trim_end_matches('/'); + Ok(Self { + authority, + prefix: if prefix.is_empty() { + String::new() + } else { + format!("/{prefix}") + }, + }) + } +} + +pub(crate) struct HttpResponse { + pub(crate) status: u16, + pub(crate) body: Vec, +} + +pub(crate) async fn http_post( + target: &Target, + path: &str, + headers: &[(String, String)], + body: &[u8], +) -> Result { + let mut stream = TcpStream::connect(&target.authority) + .await + .map_err(|e| format!("connect {}: {e}", target.authority))?; + // Loopback + small bodies: Nagle only adds latency to the + // measurement we are here to take. + let _ = stream.set_nodelay(true); + + let mut head = format!( + "POST {path} HTTP/1.1\r\nhost: {}\r\ncontent-length: {}\r\nconnection: close\r\n", + target.authority, + body.len(), + ); + for (name, value) in headers { + head.push_str(name); + head.push_str(": "); + head.push_str(value); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + stream + .write_all(head.as_bytes()) + .await + .map_err(|e| format!("write request head: {e}"))?; + stream + .write_all(body) + .await + .map_err(|e| format!("write request body: {e}"))?; + stream + .flush() + .await + .map_err(|e| format!("flush request: {e}"))?; + + let mut raw = Vec::new(); + stream + .read_to_end(&mut raw) + .await + .map_err(|e| format!("read response: {e}"))?; + parse_response(&raw) +} + +/// Parse a `connection: close` response: status line, headers, body to +/// EOF. Chunked transfer-encoding is not handled — the gateway answers +/// uploads with a small `Content-Length` JSON object, and a body we +/// can't parse would show up as a non-201 status anyway. +pub(crate) fn parse_response(raw: &[u8]) -> Result { + let split = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or_else(|| "malformed response: no header terminator".to_string())?; + let head = String::from_utf8_lossy(&raw[..split]); + let mut lines = head.lines(); + let status_line = lines + .next() + .ok_or_else(|| "malformed response: empty".to_string())?; + let status: u16 = status_line + .split_whitespace() + .nth(1) + .and_then(|c| c.parse().ok()) + .ok_or_else(|| format!("malformed status line: `{status_line}`"))?; + Ok(HttpResponse { + status, + body: raw[split + 4..].to_vec(), + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------- + // Pure helpers shared with the bench + // ----------------------------------------------------------------- + + #[test] + fn chunk_count_covers_the_tree() { + assert_eq!(data_chunk_count(0), 1); + assert_eq!(data_chunk_count(4096), 1); + // 2 leaves + 1 root. + assert_eq!(data_chunk_count(4097), 3); + // 850 000 B → 208 leaves → 2 intermediates → 1 root. + assert_eq!(data_chunk_count(850_000), 208 + 2 + 1); + } + + #[test] + fn percentiles_are_stable_on_small_samples() { + assert_eq!(percentile(&[], 50), 0); + assert_eq!(percentile(&[5], 95), 5); + assert_eq!(percentile(&[1, 2, 3, 4, 5], 50), 3); + assert_eq!(percentile(&[1, 2, 3, 4, 5], 95), 5); + } + + #[test] + fn target_parses_host_and_prefix() { + let t = Target::parse("http://127.0.0.1:1633").unwrap(); + assert_eq!(t.authority, "127.0.0.1:1633"); + assert_eq!(t.prefix, ""); + let t = Target::parse("http://example.test/ant/").unwrap(); + assert_eq!(t.authority, "example.test:80"); + assert_eq!(t.prefix, "/ant"); + assert!(Target::parse("https://example.test").is_err()); + } + + #[test] + fn response_parser_reads_status_and_body() { + let raw = b"HTTP/1.1 201 Created\r\ncontent-length: 2\r\n\r\n{}"; + let r = parse_response(raw).unwrap(); + assert_eq!(r.status, 201); + assert_eq!(r.body, b"{}"); + assert!(parse_response(b"garbage").is_err()); + } + + // ----------------------------------------------------------------- + // Playlist assembly + // ----------------------------------------------------------------- + + fn config() -> PublisherConfig { + PublisherConfig { + channel: "test channel".into(), + topic: format!("0x{}", "11".repeat(32)), + gateway: "http://127.0.0.1:1633".into(), + batch_id: "ab".repeat(32), + segment_ms: 2000, + bitrate_kbps: 900, + max_in_flight: 4, + max_backlog: 4, + playlist_window: 3, + notes: String::new(), + } + } + + fn media(uri: &str, discontinuity: bool) -> Committed { + Committed::Media { + uri: uri.into(), + duration_ms: 2000, + discontinuity, + captured_at: Instant::now(), + } + } + + #[test] + fn playlist_lists_segments_in_capture_order_behind_one_map() { + let mut state = PublisherState::default(); + state.done.insert( + 0, + Committed::Init { + uri: "/bzz/aa/init-0.mp4".into(), + }, + ); + // Deliberately out of order: the uploads finish concurrently, the + // playlist must not. + state.done.insert(2, media("/bzz/cc/seg-2.m4s", false)); + state.done.insert(1, media("/bzz/bb/seg-1.m4s", false)); + assert!(state.advance(3).is_some()); + + let playlist = state.render_playlist(2000, false); + assert!( + playlist.starts_with("#EXTM3U\n#EXT-X-VERSION:7\n"), + "{playlist}" + ); + assert!(playlist.contains("#EXT-X-TARGETDURATION:2\n")); + assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:0\n")); + assert_eq!(playlist.matches("#EXT-X-MAP").count(), 1); + let seg1 = playlist.find("/bzz/bb/seg-1.m4s").unwrap(); + let seg2 = playlist.find("/bzz/cc/seg-2.m4s").unwrap(); + assert!(seg1 < seg2, "segments out of order:\n{playlist}"); + assert!(!playlist.contains("#EXT-X-ENDLIST")); + } + + #[test] + fn a_dropped_segment_becomes_a_discontinuity_not_a_silent_gap() { + let mut state = PublisherState::default(); + state.done.insert( + 0, + Committed::Init { + uri: "/bzz/aa/init-0.mp4".into(), + }, + ); + state.done.insert(1, media("/bzz/bb/seg-1.m4s", false)); + state.done.insert(2, Committed::Dropped); + state.done.insert(3, media("/bzz/dd/seg-3.m4s", false)); + assert!(state.advance(5).is_some()); + + let playlist = state.render_playlist(2000, false); + assert_eq!( + playlist.matches("#EXT-X-DISCONTINUITY\n").count(), + 1, + "{playlist}" + ); + // The tag belongs to the segment *after* the gap. + let tag = playlist.find("#EXT-X-DISCONTINUITY\n").unwrap(); + let after = playlist.find("/bzz/dd/seg-3.m4s").unwrap(); + let before = playlist.find("/bzz/bb/seg-1.m4s").unwrap(); + assert!(before < tag && tag < after, "{playlist}"); + // Media sequence numbering counts only listed segments. + assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:0\n")); + } + + #[test] + fn a_new_init_segment_starts_a_new_map_and_discontinuity() { + let mut state = PublisherState::default(); + state.done.insert( + 0, + Committed::Init { + uri: "/bzz/aa/init-0.mp4".into(), + }, + ); + state.done.insert(1, media("/bzz/bb/seg-1.m4s", false)); + // Interruption recovery: the writer restarts and emits a fresh + // initialization segment. + state.done.insert( + 2, + Committed::Init { + uri: "/bzz/cc/init-2.mp4".into(), + }, + ); + state.done.insert(3, media("/bzz/dd/seg-3.m4s", true)); + assert!(state.advance(5).is_some()); + + let playlist = state.render_playlist(2000, false); + assert_eq!(playlist.matches("#EXT-X-MAP").count(), 2, "{playlist}"); + assert_eq!( + playlist.matches("#EXT-X-DISCONTINUITY\n").count(), + 1, + "{playlist}" + ); + assert!(playlist.contains("#EXT-X-MAP:URI=\"/bzz/cc/init-2.mp4\"")); + // The old writer's last segment stays under the *old* map — the + // reason the capture-side hand-off has to be order-preserving. + let old_segment = playlist.find("/bzz/bb/seg-1.m4s").unwrap(); + let new_map = playlist.find("/bzz/cc/init-2.mp4").unwrap(); + assert!(old_segment < new_map, "{playlist}"); + } + + #[test] + fn the_window_slides_and_carries_the_discontinuity_sequence() { + let mut state = PublisherState::default(); + state.done.insert( + 0, + Committed::Init { + uri: "/bzz/aa/init.mp4".into(), + }, + ); + state.done.insert(1, media("/bzz/b1/seg-1.m4s", false)); + state.done.insert(2, Committed::Dropped); + state.done.insert(3, media("/bzz/b3/seg-3.m4s", false)); + state.done.insert(4, media("/bzz/b4/seg-4.m4s", false)); + state.done.insert(5, media("/bzz/b5/seg-5.m4s", false)); + state.done.insert(6, media("/bzz/b6/seg-6.m4s", false)); + // Window of 2: the first two listed segments (including the one + // carrying the discontinuity) fall out. + assert!(state.advance(2).is_some()); + + let playlist = state.render_playlist(2000, false); + assert!(!playlist.contains("/bzz/b1/seg-1.m4s"), "{playlist}"); + // Five segments were listed (numbers 0-4); the window holds the + // last two, so the playlist starts at number 3. + assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:3\n"), "{playlist}"); + assert!( + playlist.contains("#EXT-X-DISCONTINUITY-SEQUENCE:1\n"), + "{playlist}" + ); + // Every listed segment still carries a map. + assert!(playlist.contains("#EXT-X-MAP:URI=\"/bzz/aa/init.mp4\"")); + } + + #[test] + fn target_duration_never_decreases_when_a_long_segment_slides_out() { + // RFC 8216 §6.2.1: `EXT-X-TARGETDURATION` MUST NOT change across + // reloads. Keyframe-aligned cutting routinely overruns the + // configured target, so a window-max would raise the tag while + // the long segment is listed and lower it again when it slides + // out — strict players size reload timers off it and treat that + // as a malformed live stream. + let mut state = PublisherState::default(); + state.done.insert( + 0, + Committed::Init { + uri: "/bzz/aa/init.mp4".into(), + }, + ); + state.done.insert(1, media("/bzz/b1/seg-1.m4s", false)); + state.done.insert( + 2, + Committed::Media { + uri: "/bzz/b2/seg-2.m4s".into(), + duration_ms: 3400, + discontinuity: false, + captured_at: Instant::now(), + }, + ); + state.advance(2); + assert!( + state + .render_playlist(2000, false) + .contains("#EXT-X-TARGETDURATION:4\n"), + "an overrunning segment must raise the target", + ); + // Two more nominal segments slide the 3.4 s one out of the + // window; the tag must stay ratcheted rather than dropping back. + state.done.insert(3, media("/bzz/b3/seg-3.m4s", false)); + state.done.insert(4, media("/bzz/b4/seg-4.m4s", false)); + state.advance(2); + let playlist = state.render_playlist(2000, false); + assert!(!playlist.contains("seg-2.m4s"), "{playlist}"); + assert!(playlist.contains("#EXT-X-TARGETDURATION:4\n"), "{playlist}"); + } + + #[test] + fn media_before_its_init_segment_is_never_listed() { + // The initialization segment failed to upload: listing the media + // segments anyway would produce a playlist no player can start. + let mut state = PublisherState::default(); + state.done.insert(0, Committed::Failed(SegmentKind::Init)); + state.done.insert(1, media("/bzz/bb/seg-1.m4s", false)); + assert!(state.advance(3).is_none()); + assert!(state.playlist.is_empty()); + } + + #[test] + fn the_final_playlist_is_closed_with_endlist() { + let mut state = PublisherState::default(); + state.done.insert( + 0, + Committed::Init { + uri: "/bzz/aa/init.mp4".into(), + }, + ); + state.done.insert(1, media("/bzz/bb/seg-1.m4s", false)); + state.advance(3); + assert!(state + .render_playlist(2000, true) + .ends_with("#EXT-X-ENDLIST\n")); + } + + #[test] + fn config_validation_rejects_unusable_broadcasts() { + let mut c = config(); + c.channel = " ".into(); + assert!(c.validate().is_err()); + let mut c = config(); + c.batch_id = String::new(); + assert!(c.validate().is_err()); + let mut c = config(); + c.max_in_flight = 0; + assert!(c.validate().is_err()); + assert!(config().validate().is_ok()); + } + + #[test] + fn an_omitted_topic_is_derived_per_broadcast() { + let mut c = config(); + c.topic = String::new(); + let a = c.resolve_topic().unwrap(); + assert_ne!(a, [0u8; 32]); + // Two derivations must differ even inside one clock tick — + // otherwise a quick stop + restart of the same channel would + // write its fresh index 0 over the old feed's head. + let b = c.resolve_topic().unwrap(); + assert_ne!(a, b, "derived topics must be per-broadcast"); + // An explicit topic is honoured verbatim, with or without `0x`. + c.topic = "cd".repeat(32); + assert_eq!(c.resolve_topic().unwrap(), [0xcd; 32]); + c.topic = "0xnothex".into(); + assert!(c.resolve_topic().is_err()); + } + + // ----------------------------------------------------------------- + // End-to-end against a stub gateway that speaks bee's contract and + // validates what it is sent. + // ----------------------------------------------------------------- + + /// One request the stub gateway saw. + #[derive(Clone, Debug)] + struct SeenRequest { + method_path: String, + headers: Vec, + body: Vec, + } + + impl SeenRequest { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find_map(|h| h.strip_prefix(&format!("{name}: "))) + } + } + + #[derive(Default)] + struct StubLog { + seen: Vec, + /// `POST /bzz` responses are content-addressed in the stub too + /// (a counter, not a real BMT hash) so the publisher's playlist + /// URIs are distinguishable. + next_reference: u64, + fail_bzz: bool, + /// Refuse every feed update from now on, the segments + /// themselves still uploading fine. + fail_soc: bool, + } + + /// A gateway stub that answers bee-shaped `{"reference":...}` to + /// `/bzz`, `/feeds` and `/soc`, recording every request. + async fn stub_gateway(log: Arc>, delay: Duration) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let log = Arc::clone(&log); + tokio::spawn(async move { + let mut raw = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let Ok(n) = socket.read(&mut buf).await else { + return; + }; + if n == 0 { + break; + } + raw.extend_from_slice(&buf[..n]); + if let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&raw[..split]).to_string(); + let want: usize = head + .lines() + .find_map(|l| { + l.strip_prefix("content-length: ")?.trim().parse().ok() + }) + .unwrap_or(0); + if raw.len() - split - 4 >= want { + let mut lines = head.lines(); + let method_path = lines + .next() + .unwrap_or_default() + .split_whitespace() + .take(2) + .collect::>() + .join(" "); + let request = SeenRequest { + method_path, + headers: lines.map(str::to_string).collect(), + body: raw[split + 4..split + 4 + want].to_vec(), + }; + let fail = { + let mut slot = lock(&log); + slot.seen.push(request.clone()); + (slot.fail_bzz && request.method_path.contains("/bzz")) + || (slot.fail_soc && request.method_path.contains("/soc/")) + }; + tokio::time::sleep(delay).await; + let response = if fail { + let body = br#"{"message":"batch not usable"}"#; + let mut r = format!( + "HTTP/1.1 400 Bad Request\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len(), + ) + .into_bytes(); + r.extend_from_slice(body); + r + } else { + let reference = { + let mut slot = lock(&log); + slot.next_reference += 1; + let mut r = [0u8; 32]; + r[..8].copy_from_slice(&slot.next_reference.to_be_bytes()); + hex::encode(r) + }; + let body = + format!("{{\"reference\":\"{reference}\"}}").into_bytes(); + let mut r = format!( + "HTTP/1.1 201 Created\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len(), + ) + .into_bytes(); + r.extend_from_slice(&body); + r + }; + let _ = socket.write_all(&response).await; + let _ = socket.shutdown().await; + return; + } + } + } + }); + } + }); + addr + } + + const TEST_SECRET: [u8; 32] = [0x2a; 32]; + + fn test_owner() -> [u8; 20] { + let sk = k256::ecdsa::SigningKey::from_bytes(&TEST_SECRET.into()).unwrap(); + ant_crypto::ethereum_address_from_public_key(sk.verifying_key()) + } + + async fn settle(run: &Arc) { + for _ in 0..400 { + if run.is_finished() { + return; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("publisher never finished"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_broadcast_publishes_segments_playlist_and_a_valid_feed_update() { + let log = Arc::new(Mutex::new(StubLog::default())); + let addr = stub_gateway(Arc::clone(&log), Duration::from_millis(2)).await; + let mut config = config(); + config.gateway = format!("http://{addr}"); + let owner = test_owner(); + let run = start( + &tokio::runtime::Handle::current(), + config.clone(), + TEST_SECRET, + owner, + None, + ) + .unwrap(); + + let _ = run.push(SegmentKind::Init, vec![7u8; 800], 0, false); + for i in 0..3u32 { + let _ = run.push(SegmentKind::Media, vec![i as u8; 4096], 2000, false); + tokio::time::sleep(Duration::from_millis(30)).await; + } + run.cancel(); + settle(&run).await; + + let report = run.report(); + assert_eq!(report.segments_published, 3, "{report:?}"); + assert_eq!(report.segments_failed, 0); + assert!(report.feed_updates > 0); + assert!(!report.channel_reference.is_empty()); + assert!(report.kept_up, "{report:?}"); + // The lag is an age and the duration a stopwatch, both frozen + // with the run: asking again later must still describe the + // broadcast, not the wait. + tokio::time::sleep(Duration::from_millis(300)).await; + let again = run.report(); + assert_eq!(again.lag_ms_final, report.lag_ms_final, "{again:?}"); + assert!(again.kept_up, "{again:?}"); + assert!( + (again.duration_s - report.duration_s).abs() < 1e-9, + "duration kept ticking after the run: {} vs {}", + again.duration_s, + report.duration_s, + ); + assert!( + (again.sustained_mbit_s - report.sustained_mbit_s).abs() < 1e-9, + "throughput drifted after the run: {} vs {}", + again.sustained_mbit_s, + report.sustained_mbit_s, + ); + + let seen = lock(&log).seen.clone(); + // 1. the channel's feed manifest. + let manifest = seen + .iter() + .find(|r| r.method_path.contains("/feeds/")) + .expect("feed manifest created"); + assert!( + manifest.method_path.contains(&format!( + "/feeds/{}/{}", + hex::encode(owner), + "11".repeat(32) + )), + "{}", + manifest.method_path, + ); + // 2. segments, stamped with the batch. + let segment = seen + .iter() + .find(|r| r.method_path.contains("/bzz?name=seg-")) + .expect("segment uploaded"); + assert_eq!( + segment.header("swarm-postage-batch-id"), + Some("ab".repeat(32).as_str()), + ); + assert_eq!(segment.header("content-type"), Some("video/iso.segment")); + // 3. the playlist, as an HLS media playlist. + let playlist = seen + .iter() + .rev() + .find(|r| r.method_path.contains("/bzz?name=stream.m3u8")) + .expect("playlist uploaded"); + assert_eq!( + playlist.header("content-type"), + Some("application/vnd.apple.mpegurl"), + ); + let body = String::from_utf8(playlist.body.clone()).unwrap(); + assert!(body.starts_with("#EXTM3U"), "{body}"); + assert!(body.contains("#EXT-X-ENDLIST"), "final playlist:\n{body}"); + assert_eq!(body.matches("#EXTINF").count(), 3, "{body}"); + + // 4. the feed update: a single-owner chunk ant's own validator + // accepts, at the bee-js sequence id for index 0. + let soc = seen + .iter() + .find(|r| r.method_path.contains("/soc/")) + .expect("feed update written"); + let (path, sig_hex) = soc.method_path.split_once("?sig=").expect("sig query"); + let id_hex = path.rsplit('/').next().unwrap(); + assert_eq!( + id_hex, + hex::encode(ant_retrieval::sequence_update_id(&[0x11; 32], 0)), + "feed update must sit at keccak256(topic ‖ index_be8)", + ); + let id: [u8; 32] = hex::decode(id_hex).unwrap().try_into().unwrap(); + let sig: [u8; 65] = hex::decode(sig_hex).unwrap().try_into().unwrap(); + let mut wire = Vec::new(); + wire.extend_from_slice(&id); + wire.extend_from_slice(&sig); + wire.extend_from_slice(&soc.body); + let mut addr_input = [0u8; 52]; + addr_input[..32].copy_from_slice(&id); + addr_input[32..].copy_from_slice(&owner); + assert!( + ant_crypto::soc_valid(&ant_crypto::keccak256(&addr_input), &wire), + "the SOC ant's own gateway validates must accept our feed update", + ); + // v1 update payload: `timestamp_be8 ‖ reference`, after the + // 8-byte little-endian CAC span. + assert_eq!(soc.body.len(), 8 + 8 + 32, "bee v1 feed payload shape"); + assert_eq!( + u64::from_le_bytes(soc.body[..8].try_into().unwrap()), + 40, + "inner CAC span covers the 40-byte update payload", + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_backlog_drops_its_oldest_segment_rather_than_falling_behind() { + let log = Arc::new(Mutex::new(StubLog::default())); + // Every upload takes 200 ms and only one runs at a time, so a + // burst of segments arriving back to back with a backlog of 2 + // forces the publisher to shed rather than queue them all. + let addr = stub_gateway(Arc::clone(&log), Duration::from_millis(200)).await; + let mut config = config(); + config.gateway = format!("http://{addr}"); + config.max_in_flight = 1; + config.max_backlog = 2; + let run = start( + &tokio::runtime::Handle::current(), + config, + TEST_SECRET, + test_owner(), + None, + ) + .unwrap(); + + // Get one segment safely published first, so the gap the drops + // create sits *between* listed segments — which is where a + // player needs the discontinuity marker. + let _ = run.push(SegmentKind::Init, vec![7u8; 64], 0, false); + let _ = run.push(SegmentKind::Media, vec![0u8; 64], 2000, false); + for _ in 0..40 { + if run.snapshot().segments_published > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert_eq!(run.snapshot().segments_published, 1); + + let mut outcomes = Vec::new(); + for i in 1..8u32 { + outcomes.push(run.push(SegmentKind::Media, vec![i as u8; 64], 2000, false)); + } + assert!( + outcomes.contains(&PushOutcome::QueuedDroppingOldest), + "a full backlog must report the drop: {outcomes:?}", + ); + run.cancel(); + settle(&run).await; + + let report = run.report(); + assert!(report.segments_dropped > 0, "{report:?}"); + assert_eq!( + report.segments_published + report.segments_dropped + report.segments_failed, + 8, + "{report:?}", + ); + // Falling behind is not a pass, even though nothing errored. + assert!(!report.kept_up, "{report:?}"); + // The dropped segments left a discontinuity rather than a silent + // gap in the published playlist. + let seen = lock(&log).seen.clone(); + let playlist = seen + .iter() + .rev() + .find(|r| r.method_path.contains("stream.m3u8")) + .expect("playlist uploaded"); + let body = String::from_utf8(playlist.body.clone()).unwrap(); + assert!(body.contains("#EXT-X-DISCONTINUITY"), "{body}"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_rejecting_gateway_is_reported_rather_than_counted_as_published() { + let log = Arc::new(Mutex::new(StubLog { + fail_bzz: true, + ..StubLog::default() + })); + let addr = stub_gateway(Arc::clone(&log), Duration::ZERO).await; + let mut config = config(); + config.gateway = format!("http://{addr}"); + let run = start( + &tokio::runtime::Handle::current(), + config, + TEST_SECRET, + test_owner(), + None, + ) + .unwrap(); + let _ = run.push(SegmentKind::Init, vec![7u8; 64], 0, false); + let _ = run.push(SegmentKind::Media, vec![1u8; 64], 2000, false); + tokio::time::sleep(Duration::from_millis(50)).await; + run.cancel(); + settle(&run).await; + + let report = run.report(); + assert_eq!(report.segments_published, 0); + assert_eq!(report.segments_failed, 1); + assert!(!report.kept_up); + assert!( + report.errors.iter().any(|e| e.contains("400")), + "{:?}", + report.errors, + ); + // No playlist can be built without an initialization segment, so + // nothing bogus was published either. + assert_eq!(report.playlists_published, 0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_feed_that_stops_updating_stops_keeping_up() { + // The failure the last landed update's latency cannot see: the + // segments keep uploading and listing, so every count still + // looks perfect, but no feed update lands — a viewer is frozen + // on the playlist the last one pointed at. Lag is an age, not a + // latency, so it must keep growing. + let log = Arc::new(Mutex::new(StubLog::default())); + let addr = stub_gateway(Arc::clone(&log), Duration::ZERO).await; + let mut config = config(); + config.gateway = format!("http://{addr}"); + // Lag budget of 3 × 300 ms, so the stall below clears it. + config.segment_ms = 300; + let run = start( + &tokio::runtime::Handle::current(), + config, + TEST_SECRET, + test_owner(), + None, + ) + .unwrap(); + + let _ = run.push(SegmentKind::Init, vec![7u8; 64], 0, false); + let _ = run.push(SegmentKind::Media, vec![0u8; 64], 300, false); + for _ in 0..100 { + if run.snapshot().feed_index > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let live = run.snapshot(); + assert!(live.keeping_up, "a broadcast at the live edge: {live:?}"); + + // From here every `POST /soc` is refused; the segments still + // upload and still get listed. + lock(&log).fail_soc = true; + for i in 1..5u32 { + let _ = run.push(SegmentKind::Media, vec![i as u8; 64], 300, false); + tokio::time::sleep(Duration::from_millis(300)).await; + } + + let stalled = run.snapshot(); + assert!( + stalled.lag_ms > 900, + "lag must age with the stall: {stalled:?}", + ); + assert!(!stalled.keeping_up, "{stalled:?}"); + + run.cancel(); + settle(&run).await; + let report = run.report(); + assert!(!report.kept_up, "{report:?}"); + // Every other input to the verdict still reads clean — the lag + // is the only thing standing between this run and a "kept up". + assert!(report.feed_updates > 0, "{report:?}"); + assert_eq!(report.segments_listed, report.segments_published); + assert_eq!(report.segments_failed + report.segments_dropped, 0); + } + + #[test] + fn a_segment_that_uploaded_but_never_got_listed_is_not_kept_up() { + // The narrow case `segments_published` alone would let pass: a + // *later* initialization segment fails, so the media segments + // after it upload fine and are counted published — but they + // carry no `#EXT-X-MAP` and can never be listed, i.e. no viewer + // can play them. + let mut state = PublisherState::default(); + state.done.insert( + 0, + Committed::Init { + uri: "/bzz/aa/init.mp4".into(), + }, + ); + state.done.insert(1, media("/bzz/b1/seg-1.m4s", false)); + state.advance(6); + assert_eq!(state.segments_listed, 1); + // Writer restart whose initialization segment fails to upload. + state.done.insert(2, Committed::Failed(SegmentKind::Init)); + state.done.insert(3, media("/bzz/b3/seg-3.m4s", false)); + state.advance(6); + // Listed count did not move even though the upload succeeded, + // which is what keeps `kept_up` honest. + assert_eq!(state.segments_listed, 1); + assert_eq!(state.playlist.len(), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pushing_after_stop_is_refused() { + let log = Arc::new(Mutex::new(StubLog::default())); + let addr = stub_gateway(log, Duration::ZERO).await; + let mut config = config(); + config.gateway = format!("http://{addr}"); + let run = start( + &tokio::runtime::Handle::current(), + config, + TEST_SECRET, + test_owner(), + None, + ) + .unwrap(); + run.cancel(); + assert_eq!( + run.push(SegmentKind::Media, vec![1u8; 64], 2000, false), + PushOutcome::Closed, + ); + settle(&run).await; + assert_eq!(run.report().segments_pushed, 0); + } +} diff --git a/crates/ant-ffi/tests/live_publisher_gateway.rs b/crates/ant-ffi/tests/live_publisher_gateway.rs new file mode 100644 index 0000000..928e4d0 --- /dev/null +++ b/crates/ant-ffi/tests/live_publisher_gateway.rs @@ -0,0 +1,197 @@ +//! The stage-2 live publisher against the **real** `ant-gateway`. +//! +//! The unit tests in `src/publisher.rs` drive a stub that speaks bee's +//! wire contract; this one drives the router the iOS app actually runs +//! (`ant_start_gateway` → `ant_gateway`), in `light_mode`, with a stub +//! node loop standing in for the swarm. That covers the half a stub +//! gateway cannot: whether `POST /bzz`, `POST /feeds` and `POST /soc` +//! *as this publisher writes them* are accepted by ant's own handlers — +//! header names, the `?sig=` query, the inner-CAC body shape, and the +//! SOC signature ant validates with `soc_valid` before it will dispatch +//! anything. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use ant_control::{ControlAck, ControlCommand, StatusSnapshot}; +use ant_ffi::publisher::{self, PublisherConfig, SegmentKind}; +use ant_gateway::{CorsConfig, GatewayChainState, GatewayHandle, GatewayIdentity, TagRegistry}; +use tokio::sync::{mpsc, watch}; + +const SECRET: [u8; 32] = [0x5e; 32]; + +/// What the stub node loop was asked to push. +#[derive(Default)] +struct NodeLog { + chunks: usize, + socs: Vec<([u8; 32], Vec)>, + /// SOC wires that failed ant's own validator. Must stay empty: the + /// gateway rejects those with 401 before they reach the node, so an + /// entry here would mean the gateway let a bad SOC through. + invalid_socs: usize, +} + +/// Answer the upload commands the gateway dispatches, the way a healthy +/// node would: ack each chunk/SOC with its own address as the reference. +async fn stub_node(mut rx: mpsc::Receiver, log: Arc>) { + while let Some(cmd) = rx.recv().await { + match cmd { + ControlCommand::PushChunk { wire, ack, .. } => { + let Some(address) = bmt_address(&wire) else { + let _ = ack.send(ControlAck::Error { + message: "unsplittable chunk".into(), + }); + continue; + }; + log.lock().unwrap().chunks += 1; + let _ = ack.send(ControlAck::ChunkUploaded { + reference: hex::encode(address), + }); + } + ControlCommand::PushSoc { + address, wire, ack, .. + } => { + { + let mut log = log.lock().unwrap(); + if !ant_crypto::soc_valid(&address, &wire) { + log.invalid_socs += 1; + } + log.socs.push((address, wire)); + } + let _ = ack.send(ControlAck::ChunkUploaded { + reference: hex::encode(address), + }); + } + other => drop(other), + } + } +} + +fn bmt_address(wire: &[u8]) -> Option<[u8; 32]> { + let span: [u8; 8] = wire.get(..8)?.try_into().ok()?; + ant_crypto::bmt::bmt_hash_with_span(&span, &wire[8..]) +} + +/// Bring up the production router on a loopback port, backed by +/// [`stub_node`]. Mirrors how `ant_start_gateway` builds its handle, +/// including `light_mode` (the mode `AntStream` publishes in). +async fn serve_gateway(log: Arc>) -> String { + let (cmd_tx, cmd_rx) = mpsc::channel::(64); + let (_status_tx, status_rx) = watch::channel(StatusSnapshot::default()); + tokio::spawn(stub_node(cmd_rx, log)); + + let handle = GatewayHandle { + agent: Arc::new("ant-ffi/test".to_string()), + api_version: Arc::new("7.2.0".to_string()), + identity: Arc::new(GatewayIdentity { + overlay_hex: String::new(), + ethereum_hex: String::new(), + public_key_hex: String::new(), + peer_id: String::new(), + }), + status: status_rx, + commands: cmd_tx, + activity: ant_control::GatewayActivity::new(), + tags: Arc::new(TagRegistry::new()), + cors: Arc::new(CorsConfig::new(["null"])), + chain_state: GatewayChainState { + light_mode: true, + chain: None, + } + .preset(), + act_secret: Arc::new(SECRET), + }; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let app = ant_gateway::testkit::build_router(handle); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + addr +} + +fn owner() -> [u8; 20] { + let sk = k256::ecdsa::SigningKey::from_bytes(&SECRET.into()).unwrap(); + ant_crypto::ethereum_address_from_public_key(sk.verifying_key()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_broadcast_is_accepted_end_to_end_by_the_real_gateway() { + let log = Arc::new(Mutex::new(NodeLog::default())); + let addr = serve_gateway(Arc::clone(&log)).await; + + let config = PublisherConfig { + channel: "integration".into(), + topic: "33".repeat(32), + gateway: format!("http://{addr}"), + batch_id: "ab".repeat(32), + segment_ms: 2000, + bitrate_kbps: 900, + max_in_flight: 4, + max_backlog: 4, + playlist_window: 6, + notes: String::new(), + }; + let run = publisher::start( + &tokio::runtime::Handle::current(), + config, + SECRET, + owner(), + None, + ) + .unwrap(); + + let _ = run.push(SegmentKind::Init, vec![0x11; 900], 0, false); + for i in 0..3u8 { + let _ = run.push(SegmentKind::Media, vec![i; 40_000], 2000, false); + tokio::time::sleep(Duration::from_millis(50)).await; + } + run.cancel(); + for _ in 0..400 { + if run.is_finished() { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!(run.is_finished(), "publisher never finished"); + + let report = run.report(); + assert_eq!(report.segments_failed, 0, "{:?}", report.errors); + assert_eq!(report.segments_published, 3, "{report:?}"); + assert!(report.playlists_published > 0, "{report:?}"); + assert!(report.feed_updates > 0, "{report:?}"); + assert!( + !report.channel_reference.is_empty(), + "POST /feeds must yield a shareable channel reference: {report:?}", + ); + assert!(report.kept_up, "{report:?}"); + + let log = log.lock().unwrap(); + assert_eq!(log.invalid_socs, 0, "gateway dispatched an invalid SOC"); + // Every feed update reached the node as a single-owner chunk at the + // bee sequence address for its index. (The node also sees the + // dispersed-replica SOCs bee's default redundancy level mints for + // every upload, so match by address rather than by count.) + let feed = ant_retrieval::Feed { + owner: owner(), + topic: [0x33; 32], + kind: ant_retrieval::FeedType::Sequence, + }; + for index in 0..report.feed_updates { + let expected = ant_retrieval::sequence_update_address(&feed, index); + assert!( + log.socs.iter().any(|(address, _)| *address == expected), + "feed update {index} never reached the node at its sequence address", + ); + } + // …and nothing was written past the last index the publisher + // reported, which is what a viewer's feed walk relies on. + let past_end = ant_retrieval::sequence_update_address(&feed, report.feed_updates); + assert!( + !log.socs.iter().any(|(address, _)| *address == past_end), + "a feed update landed past the reported head index", + ); + // Segments, playlists and the feed manifest all went out as chunks. + assert!(log.chunks > 0); +} diff --git a/crates/ant-gateway/src/retrieval.rs b/crates/ant-gateway/src/retrieval.rs index b581691..e18e6b6 100644 --- a/crates/ant-gateway/src/retrieval.rs +++ b/crates/ant-gateway/src/retrieval.rs @@ -1317,22 +1317,7 @@ pub async fn upload_soc( resp } ControlAck::NotReady { message } => json_error(StatusCode::SERVICE_UNAVAILABLE, message), - ControlAck::Error { message } => { - let status = if message.contains("not usable") { - StatusCode::BAD_REQUEST - } else if message.contains("rejected by") && message.contains("not found on-chain") { - // Storer peers rejected the stamp: the batch is not in - // their chain-synced batchstore (phantom batch — - // never created on this chain, expired, or unsynced). - // Deterministic and NOT retryable, so it must be - // distinguishable from transient pushsync exhaustion - // (502): 422 with the batch id + a peer's own words. - StatusCode::UNPROCESSABLE_ENTITY - } else { - StatusCode::BAD_GATEWAY - }; - json_error(status, message) - } + ControlAck::Error { message } => json_error(upload_error_status(&message), message), other => { warn!(target: "ant_gateway", ?other, "unexpected ack from PushSoc"); json_error(StatusCode::INTERNAL_SERVER_ERROR, "unexpected node ack") @@ -2122,6 +2107,44 @@ fn map_manifest_error(e: ant_retrieval::manifest_writer::ManifestWriteError) -> /// doesn't open 1 K simultaneous outbound libp2p streams. Returns an /// HTTP error response on the first chunk that fails to push. #[allow(clippy::result_large_err)] +/// Map a node-side upload failure onto bee's HTTP status. +/// +/// One function for every write endpoint that dispatches a `PushChunk` +/// / `PushSoc` (`/bzz`, `/bytes`, `/chunks`, `/soc`, `/feeds`): the +/// status class is what a client's retry logic keys off, so `/soc` +/// answering a saturated batch with a different code than `/bzz` would +/// make the same condition look transient on one endpoint and fatal on +/// the other. +/// +/// * `503` — the node cannot stamp at all (no upload runtime). +/// * `402 "batch is overissued"` (bee's `postage.ErrBucketFull` +/// mapping) — the batch's collision bucket is full. **Not** retryable: +/// the caller has to dilute or buy. This is the one a long-running +/// writer hits, e.g. an `AntStream` broadcast walking a batch's +/// buckets for an hour (#67 stage 2), and reporting it as a 502 sent +/// publishers into an unbounded retry against a permanently full +/// bucket. +/// * `400` — the batch is not registered/usable here. +/// * `422` — storer peers attested the batch does not exist on-chain +/// (phantom / expired / unsynced). Deterministic, not retryable. +/// * `502` — everything else: transient pushsync failure. +pub(crate) fn upload_error_status(message: &str) -> StatusCode { + if message.starts_with("uploads not configured") { + StatusCode::SERVICE_UNAVAILABLE + } else if message.contains("overissued") + || message.contains("bucket full") + || message.contains("saturated") + { + StatusCode::PAYMENT_REQUIRED + } else if message.contains("not usable") { + StatusCode::BAD_REQUEST + } else if message.contains("rejected by") && message.contains("not found on-chain") { + StatusCode::UNPROCESSABLE_ENTITY + } else { + StatusCode::BAD_GATEWAY + } +} + pub(crate) async fn push_chunks( handle: &GatewayHandle, chunks: &[SplitChunk], @@ -2170,24 +2193,10 @@ pub(crate) async fn push_chunks( ); Ok(()) } - Ok(Ok(ControlAck::Error { message })) => { - let status = if message.starts_with("uploads not configured") { - StatusCode::SERVICE_UNAVAILABLE - } else if message.contains("not usable") { - StatusCode::BAD_REQUEST - } else if message.contains("rejected by") - && message.contains("not found on-chain") - { - // Peer-attested phantom batch: deterministic, - // NOT retryable — distinct from transient - // pushsync exhaustion (502) so clients stop - // treating a dead batch as a flaky network. - StatusCode::UNPROCESSABLE_ENTITY - } else { - StatusCode::BAD_GATEWAY - }; - Err(json_error(status, format!("push chunk failed: {message}"))) - } + Ok(Ok(ControlAck::Error { message })) => Err(json_error( + upload_error_status(&message), + format!("push chunk failed: {message}"), + )), Ok(Ok(other)) => { warn!(target: "ant_gateway", ?other, "unexpected ack from PushChunk"); Err(json_error( @@ -4752,6 +4761,47 @@ mod humantime { mod tests { use super::*; + /// Every write endpoint shares one status mapping, and a saturated + /// batch is bee's `402 "batch is overissued"` — not a 502 a client + /// will retry forever. The saturation strings are the ones the node + /// actually emits: `PushChunk`/`PushSoc`'s immutable-batch refusal + /// and `ant_postage::PostageError::BucketFull`'s `Display`. + #[test] + fn a_saturated_batch_is_payment_required_not_a_bad_gateway() { + for message in [ + "batch 0xab… saturated: collision bucket full at depth 20 on an immutable batch — stamping would evict an existing chunk; buy or dilute to a larger batch", + "stamp issue failed: bucket full", + "batch is overissued", + ] { + assert_eq!( + upload_error_status(message), + StatusCode::PAYMENT_REQUIRED, + "{message}", + ); + } + } + + #[test] + fn the_other_upload_error_classes_keep_their_status() { + assert_eq!( + upload_error_status("uploads not configured: node cannot stamp"), + StatusCode::SERVICE_UNAVAILABLE, + ); + assert_eq!( + upload_error_status("batch 0xab not usable"), + StatusCode::BAD_REQUEST, + ); + assert_eq!( + upload_error_status("rejected by 0xpeer: batch 0xab not found on-chain",), + StatusCode::UNPROCESSABLE_ENTITY, + ); + // Transient pushsync trouble stays retryable. + assert_eq!( + upload_error_status("pushsync: exhausted pushsync peers"), + StatusCode::BAD_GATEWAY, + ); + } + /// Go `parseRange` parity, case by case (matches Go 1.26's /// `net/http` — bee serves ranges via `http.ServeContent`). #[test] diff --git a/crates/ant-p2p/src/behaviour.rs b/crates/ant-p2p/src/behaviour.rs index 047c62f..e65bf86 100644 --- a/crates/ant-p2p/src/behaviour.rs +++ b/crates/ant-p2p/src/behaviour.rs @@ -2202,6 +2202,41 @@ fn handle_control_command( }); return; }; + // Same collision-bucket guard `PushChunk` applies, and + // for the same reason. SOCs are not a rare write: every + // redundant upload mints dispersed replicas as SOCs, and + // a live feed writes one per playlist update (#67 + // stage 2), so a long broadcast walks a batch's buckets + // exactly like a bulk file upload does. Without this the + // immutable case surfaced as a bare `stamp issue failed: + // bucket full` and the mutable case wrapped a bucket — + // evicting somebody's chunk — with no log at all. + if !issuer.has_stamp(&address) && issuer.bucket_is_full(&address) { + if issuer.immutable() { + warn!( + target: "ant_p2p", + batch = %hex::encode(batch_id), + depth = issuer.batch_depth(), + addr = %hex::encode(address), + "refusing to stamp soc: collision bucket full on immutable batch — stamping would evict an existing chunk (buy/dilute a larger batch)", + ); + let _ = ack.send(ControlAck::Error { + message: format!( + "batch 0x{} saturated: collision bucket full at depth {} on an immutable batch — stamping would evict an existing chunk; buy or dilute to a larger batch", + hex::encode(batch_id), + issuer.batch_depth(), + ), + }); + return; + } + warn!( + target: "ant_p2p", + batch = %hex::encode(batch_id), + depth = issuer.batch_depth(), + addr = %hex::encode(address), + "collision bucket full on mutable batch — wrapping (evicting the oldest stamp in this bucket); consider diluting to a larger batch", + ); + } match ant_postage::sign_stamp_bytes(&upload.stamp_key, issuer, &address) { Ok(s) => s, Err(e) => { diff --git a/examples/ios-stream/AntStream.xcodeproj/project.pbxproj b/examples/ios-stream/AntStream.xcodeproj/project.pbxproj index cf0a1bc..8cf4fb2 100644 --- a/examples/ios-stream/AntStream.xcodeproj/project.pbxproj +++ b/examples/ios-stream/AntStream.xcodeproj/project.pbxproj @@ -17,6 +17,9 @@ BA00000000000000000000011 /* GetStartedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000000013 /* GetStartedView.swift */; }; BA00000000000000000000012 /* AccountKeystore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000000014 /* AccountKeystore.swift */; }; BA00000000000000000000013 /* BenchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000000015 /* BenchView.swift */; }; + BA00000000000000000000014 /* LiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000000016 /* LiveView.swift */; }; + BA00000000000000000000015 /* LiveBroadcast.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000000017 /* LiveBroadcast.swift */; }; + BA00000000000000000000016 /* CaptureEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000000018 /* CaptureEngine.swift */; }; BA00000000000000000000008 /* libant_ffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FA00000000000000000000012 /* libant_ffi.a */; }; BA00000000000000000000009 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000000010 /* Assets.xcassets */; }; BA00000000000000000000010 /* peers.seed.json in Resources */ = {isa = PBXBuildFile; fileRef = FA00000000000000000000011 /* peers.seed.json */; }; @@ -34,6 +37,9 @@ FA00000000000000000000013 /* GetStartedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStartedView.swift; sourceTree = ""; }; FA00000000000000000000014 /* AccountKeystore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountKeystore.swift; sourceTree = ""; }; FA00000000000000000000015 /* BenchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BenchView.swift; sourceTree = ""; }; + FA00000000000000000000016 /* LiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveView.swift; sourceTree = ""; }; + FA00000000000000000000017 /* LiveBroadcast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveBroadcast.swift; sourceTree = ""; }; + FA00000000000000000000018 /* CaptureEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureEngine.swift; sourceTree = ""; }; FA00000000000000000000008 /* AntStream-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "AntStream-Bridging-Header.h"; sourceTree = ""; }; FA00000000000000000000009 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FA00000000000000000000010 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -79,6 +85,9 @@ FA00000000000000000000004 /* StorageView.swift */, FA00000000000000000000013 /* GetStartedView.swift */, FA00000000000000000000015 /* BenchView.swift */, + FA00000000000000000000016 /* LiveView.swift */, + FA00000000000000000000017 /* LiveBroadcast.swift */, + FA00000000000000000000018 /* CaptureEngine.swift */, FA00000000000000000000005 /* AntNode.swift */, FA00000000000000000000006 /* StreamModels.swift */, FA00000000000000000000014 /* AccountKeystore.swift */, @@ -198,6 +207,9 @@ BA00000000000000000000004 /* StorageView.swift in Sources */, BA00000000000000000000011 /* GetStartedView.swift in Sources */, BA00000000000000000000013 /* BenchView.swift in Sources */, + BA00000000000000000000014 /* LiveView.swift in Sources */, + BA00000000000000000000015 /* LiveBroadcast.swift in Sources */, + BA00000000000000000000016 /* CaptureEngine.swift in Sources */, BA00000000000000000000005 /* AntNode.swift in Sources */, BA00000000000000000000006 /* StreamModels.swift in Sources */, BA00000000000000000000012 /* AccountKeystore.swift in Sources */, diff --git a/examples/ios-stream/AntStream/AntNode.swift b/examples/ios-stream/AntStream/AntNode.swift index 9a04cd3..c093c47 100644 --- a/examples/ios-stream/AntStream/AntNode.swift +++ b/examples/ios-stream/AntStream/AntNode.swift @@ -595,6 +595,106 @@ final class AntNode: ObservableObject { return report } + // MARK: - Live publisher (#67 stage 2) + + /// Start a live broadcast on the node. `batchId` is the connected + /// plan's postage batch — every segment, playlist and feed update is + /// stamped with it, so a broadcast without a plan is refused here + /// rather than failing per segment on the gateway. + /// + /// The publish window is left at the node's default (4): stage 1 + /// measured 4 as the stable point and 8 as a connection-layer + /// collapse, so it is not a knob this screen should offer. + func startPublisher( + channel: String, + batchId: String, + bitrateKbps: UInt32, + segmentMs: UInt32, + notes: String + ) async throws { + let config: [String: Any] = [ + "channel": channel, + "gateway": "http://\(Self.gatewayAddress)", + "batch_id": batchId, + "bitrate_kbps": bitrateKbps, + "segment_ms": segmentMs, + "notes": notes, + ] + guard let data = try? JSONSerialization.data(withJSONObject: config), + let json = String(data: data, encoding: .utf8) else { + throw AntError.op("could not encode the broadcast configuration") + } + // `nil` from `withHandle` means there is no node; a non-nil + // inner value is the failure detail (`nil` inside = started). + let failure: String?? = await withHandle { h in + await Task.detached(priority: .userInitiated) { () -> String? in + var errPtr: UnsafeMutablePointer? = nil + let ok = json.withCString { ant_publisher_start(h, $0, &errPtr) } + let detail = ok ? nil : errPtr.map { String(cString: $0) } + if let errPtr { ant_free_string(errPtr) } + return ok ? nil : (detail ?? "could not start the broadcast") + }.value + } + guard let started = failure else { throw AntError.notReady } + if let message = started { throw AntError.op(message) } + } + + /// Hand one captured segment to the publisher. Returns the FFI's + /// disposition: `0` queued, `1` queued after dropping the oldest + /// pending segment, `2` refused (stopping), `-1` error. + /// + /// Non-blocking node-side, so the capture pipeline is never stalled + /// by the uplink — which is the whole point of the drop-oldest + /// backlog behind it. + @discardableResult + func pushSegment(_ segment: CaptureEngine.Segment) async -> Int32 { + let outcome: Int32? = await withHandle { h in + await Task.detached(priority: .userInitiated) { () -> Int32 in + segment.data.withUnsafeBytes { raw -> Int32 in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return -1 } + var errPtr: UnsafeMutablePointer? = nil + let code = ant_publisher_push_segment( + h, + segment.isInitialization, + base, + raw.count, + segment.durationMs, + segment.discontinuity, + &errPtr + ) + if let errPtr { ant_free_string(errPtr) } + return code + } + }.value + } + return outcome ?? -1 + } + + /// Live progress of the broadcast, or `nil` when there is none. + func publisherProgress() async -> PublisherSnapshot? { + guard let json = try? await ffiString(name: "broadcast progress", { h, errPtr in + ant_publisher_progress(h, errPtr) + }) else { return nil } + return StreamDecoder.publisherSnapshot(from: json) + } + + /// End the broadcast and take its final report. + /// + /// `ant_publisher_stop` blocks while the already-captured segments + /// are published and the playlist is closed with `#EXT-X-ENDLIST` + /// (up to ~130 s, its stop grace), so this must not run on the main + /// actor's thread — ``ffiString`` already hops onto a detached task + /// for that reason. + func stopPublisher() async throws -> PublisherReport { + let json = try await ffiString(name: "stop broadcast") { h, errPtr in + ant_publisher_stop(h, errPtr) + } + guard let report = StreamDecoder.publisherReport(from: json) else { + throw AntError.op("could not read the broadcast report") + } + return report + } + // MARK: - Refresh func refreshAll() async { @@ -679,6 +779,40 @@ final class AntNode: ObservableObject { /// sample in place for the capture. Never set outside the shot path. private var screenshotSampleActive = false + /// A modest connected plan: `plan.enabled` is what gates both the + /// deposit card and the going-live screen, and the storage meter + /// shows a populated bar rather than "no plan". + private static func sampleConnectedPlan(batchId: String) -> StoragePlan { + StoragePlan( + enabled: true, + batchId: batchId, + batchDepth: 22, + immutable: false, + totalCapacityChunks: 1_250_000, // ~5 GB at 4 KiB/chunk + issuedChunks: 40_000, // ~160 MB used + worstCaseRemainingChunks: 1_210_000 + ) + } + + /// Let `-antstream-shot-live` reach the going-live screen on a + /// runner. A fresh simulator account has no storage plan, and + /// broadcasting is refused without one, so a sample plan is + /// published to get past that gate. + /// + /// The batch id is deliberately **not** a real one: the segments are + /// captured, encoded, segmented and handed to the publisher for + /// real, and the gateway then rejects the stamp — the same "no + /// usable batch" wall the stage-1 publish rows hit. Nothing here + /// fakes a successful upload. + /// + /// Sample only, and only ever called behind `-antstream-shot-live`. + func installBroadcastSample() { + screenshotSampleActive = true + plan = Self.sampleConnectedPlan( + batchId: "0x" + String(repeating: "a1", count: 32) + ) + } + /// Drive the Storage tab to the deposit-0 top-up state for a CI /// screenshot. /// @@ -696,16 +830,8 @@ final class AntNode: ObservableObject { /// `refreshAccount`), so only the settlement figures are synthetic. func installDepositTopUpSample() { screenshotSampleActive = true - // A modest connected plan so `plan.enabled` gates the card in, and - // the storage meter shows a populated bar rather than "no plan". - plan = StoragePlan( - enabled: true, - batchId: "0x0000000000000000000000000000000000000000000000000000000000000000", - batchDepth: 22, - immutable: false, - totalCapacityChunks: 1_250_000, // ~5 GB at 4 KiB/chunk - issuedChunks: 40_000, // ~160 MB used - worstCaseRemainingChunks: 1_210_000 + plan = Self.sampleConnectedPlan( + batchId: "0x0000000000000000000000000000000000000000000000000000000000000000" ) // A deployed chequebook (settlement enabled) … settlement = SettlementInfo( diff --git a/examples/ios-stream/AntStream/BroadcastView.swift b/examples/ios-stream/AntStream/BroadcastView.swift index f4335c5..58fb894 100644 --- a/examples/ios-stream/AntStream/BroadcastView.swift +++ b/examples/ios-stream/AntStream/BroadcastView.swift @@ -5,14 +5,12 @@ import SwiftUI /// deployed chequebook, live storage plan, gateway listening — without /// the user ever touching `antctl`. /// -/// The camera capture pipeline (#65) and the publish loop (#67) attach to -/// the **Go live** button below; everything they need (a light-mode -/// gateway on `AntNode.gatewayURL`, a stamped batch, working settlement) -/// is what this checklist guarantees. +/// The camera capture pipeline (#65) and the publish loop (#67 stage 2) +/// hang off the **Go live** button below, which opens ``LiveView``; +/// everything they need (a light-mode gateway on `AntNode.gatewayURL`, a +/// stamped batch, working settlement) is what this checklist guarantees. struct BroadcastView: View { @EnvironmentObject var node: AntNode - @StateObject private var banner = BannerState() - @State private var showGetStarted = false /// Opened by the **Run bench** button — and, on launch, by /// `-antstreamShowBench YES`, which iOS folds into `UserDefaults`. @@ -21,6 +19,10 @@ struct BroadcastView: View { /// screenshot of the button alone is not evidence that the sheet /// behind it renders. @State private var showBench = UserDefaults.standard.bool(forKey: "antstreamShowBench") + /// The going-live screen. Full-screen rather than a sheet: a + /// broadcast owns the camera and the screen for its duration, and a + /// swipe-dismissable sheet would orphan a run in progress. + @State private var showLive = false var body: some View { ZStack { @@ -42,9 +44,9 @@ struct BroadcastView: View { .refreshable { await node.refreshAll() } } .preferredColorScheme(.dark) - .overlay(alignment: .top) { BannerView(message: banner.message) } .sheet(isPresented: $showGetStarted) { GetStartedView() } .sheet(isPresented: $showBench) { BenchView() } + .fullScreenCover(isPresented: $showLive) { LiveView() } .task { await node.refreshAll() } } @@ -78,17 +80,14 @@ struct BroadcastView: View { .font(.system(.title2, design: .rounded).weight(.bold)) .foregroundStyle(.white) Text(node.isReadyToBroadcast - ? "Your account, storage plan and network settlement are all set. Camera capture arrives in the next release." + ? "Your account, storage plan and network settlement are all set. Segments publish straight to Swarm and the channel's feed follows the live edge." : "Finish the steps below and this device can go live.") .font(.subheadline) .foregroundStyle(.white.opacity(0.7)) .multilineTextAlignment(.center) Button { - // Capture + publish land in the follow-up tickets; - // until then the button reports the state the - // pipeline will start from. - banner.flash("Camera capture lands in the next release") + showLive = true } label: { Text("Go live") .font(.headline) diff --git a/examples/ios-stream/AntStream/CaptureEngine.swift b/examples/ios-stream/AntStream/CaptureEngine.swift new file mode 100644 index 0000000..52cc2af --- /dev/null +++ b/examples/ios-stream/AntStream/CaptureEngine.swift @@ -0,0 +1,803 @@ +import AVFoundation +import CoreMedia +import Foundation +import UIKit +import UniformTypeIdentifiers + +/// Camera → H.264 → HLS fMP4 segments (issue #65). +/// +/// `AVCaptureSession` (camera + mic) feeds an `AVAssetWriter` running in +/// `.mpeg4AppleHLS` mode, which hands back one *initialization* segment +/// (`ftyp` + `moov`) followed by a stream of *separable* media segments +/// (`moof` + `mdat`) on a wall clock — exactly the shape the publisher +/// (#67 stage 2) uploads and an `AVPlayer` plays back from a playlist. +/// +/// Two things this class is careful about, because they are what a +/// 30-minute broadcast actually runs into: +/// +/// * **Interruptions end a segment cleanly.** An incoming call, the app +/// backgrounding, a camera flip, an orientation change or a thermal +/// downshift all finish the current writer (so its last segment is a +/// complete, playable one) and start a fresh writer on recovery. The +/// first segment of the new writer carries a fresh initialization +/// segment and is flagged `discontinuity`, which is what lets the +/// playlist mark it `#EXT-X-DISCONTINUITY` instead of handing a player +/// a timeline that silently jumps. +/// * **Bitrate and segment duration are runtime-configurable.** #65 and +/// #67 were built in parallel, so the rendition is a parameter, not a +/// constant. Changing either restarts the writer (the encoder's +/// bitrate and keyframe interval are set at input creation), which is +/// the same clean-cut path an interruption takes. +/// +/// Segments are also kept in a small on-disk ring buffer. They are +/// uploaded from memory, so the ring is not on the publish path; it +/// exists so a segment can be inspected, replayed locally with +/// `AVPlayer`, or (stage 3) rolled into the VOD finalize pass. +final class CaptureEngine: NSObject { + // MARK: configuration + + /// The rendition. Defaults are the stage-1 go/no-go row: 360p at + /// ~900 kbit/s with 2 s segments (`crates/ant-ffi/ANTSTREAM_BENCH.md`). + struct Settings: Equatable { + var width = 640 + var height = 360 + var bitrateKbps = 900 + var segmentSeconds = 2.0 + var frameRate = 30 + /// Segments kept on disk. 60 × 2 s = the last two minutes. + var ringCapacity = 60 + + /// The rendition the thermal guard falls back to. Halving the + /// bitrate is the cheapest lever that keeps a broadcast alive on + /// a hot phone; dropping resolution as well would need a new + /// session configuration, which is a longer interruption. + func downshifted() -> Settings { + var next = self + next.bitrateKbps = max(200, bitrateKbps / 2) + return next + } + } + + /// Where the frames come from. + enum Source: Equatable { + /// The device camera and microphone. + case camera + /// A generated test pattern, no camera or microphone. The + /// simulator has no capture device at all, so this is how the + /// encode → segment → publish path is exercised in CI (and how + /// `antstream-visual` can screenshot a live broadcast). Always + /// labelled as such on screen — it must never be mistaken for + /// real capture. + case testPattern + } + + enum State: Equatable { + case idle + case starting + case running + /// Capture is paused by something outside our control (call, + /// backgrounding, another app took the camera). Recovery starts + /// a new segment automatically. + case interrupted(String) + case failed(String) + + var isRunning: Bool { self == .running } + } + + /// One finished fMP4 segment. + struct Segment { + let data: Data + let isInitialization: Bool + let durationMs: UInt32 + /// This segment does not continue the previous one's timeline. + let discontinuity: Bool + /// Ring-buffer copy, when it could be written. + let fileURL: URL? + } + + // MARK: callbacks + + /// Called for every finished segment, on the writer's own delivery + /// queue (not ``queue``) — hop to your own actor before touching + /// anything shared. + var onSegment: ((Segment) -> Void)? + /// Called on the main actor whenever ``state`` changes — the type + /// carries that isolation so a SwiftUI observer can publish straight + /// from it. + var onStateChange: (@MainActor (State) -> Void)? + + private(set) var state: State = .idle { + didSet { + guard state != oldValue else { return } + let newState = state + Task { @MainActor [onStateChange] in onStateChange?(newState) } + } + } + + private(set) var settings: Settings + let source: Source + + /// Everything below runs here: capture callbacks, writer lifecycle, + /// and the test-pattern generator. One serial queue means the writer + /// can never be torn down underneath an in-flight `append`. + private let queue = DispatchQueue(label: "antstream.capture", qos: .userInitiated) + + private let session = AVCaptureSession() + private var videoOutput: AVCaptureVideoDataOutput? + private var audioOutput: AVCaptureAudioDataOutput? + private var videoDeviceInput: AVCaptureDeviceInput? + private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? + private var rotationObservation: NSKeyValueObservation? + + private var writer: AVAssetWriter? + private var videoInput: AVAssetWriterInput? + private var audioInput: AVAssetWriterInput? + private var pixelAdaptor: AVAssetWriterInputPixelBufferAdaptor? + private var sessionStarted = false + /// Set while a writer is being torn down, so late samples from the + /// capture outputs are dropped instead of appended to a finished + /// writer (which throws). + private var restarting = false + /// The rendition the operator asked for, before any thermal + /// downshift — so recovery restores it rather than the reduced one. + private var baseSettings: Settings + + /// Guards the fields the `AVAssetWriterDelegate` callback touches. + /// Those callbacks arrive on the writer's own queue, not ``queue``, + /// and they must not hop onto ``queue``: `finishWriter` blocks there + /// waiting for `finishWriting`, whose final segment callbacks would + /// then deadlock behind it. + private let delegateLock = NSLock() + private var pendingDiscontinuityLocked = false + private var segmentIndexLocked = 0 + private var ringFilesLocked: [URL] = [] + /// Snapshot of the settings the delegate needs, republished under + /// ``delegateLock`` whenever the rendition changes. + private var delegateSettings: (segmentSeconds: Double, ringCapacity: Int) + + private var observingSystemEvents = false + private var testPatternTimer: DispatchSourceTimer? + private var testPatternFrame = 0 + private var pixelBufferPool: CVPixelBufferPool? + + private let ringDirectory: URL + + /// The preview layer's session — `nil` in test-pattern mode, where + /// there is nothing to preview. + var previewSession: AVCaptureSession? { source == .camera ? session : nil } + + init(source: Source, settings: Settings = Settings()) { + self.source = source + self.settings = settings + self.baseSettings = settings + self.delegateSettings = (settings.segmentSeconds, settings.ringCapacity) + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + ringDirectory = base.appendingPathComponent("antstream/segments", isDirectory: true) + super.init() + try? FileManager.default.createDirectory(at: ringDirectory, withIntermediateDirectories: true) + } + + // MARK: - Lifecycle + + /// Ask for the permissions this source needs. Returns the reason it + /// can't run, or `nil` when it can. + static func permissionProblem(for source: Source) async -> String? { + guard source == .camera else { return nil } + guard await requestAccess(.video) else { + return "AntStream needs camera access to broadcast. Enable it in Settings › AntStream." + } + guard await requestAccess(.audio) else { + return "AntStream needs microphone access to broadcast. Enable it in Settings › AntStream." + } + return nil + } + + private static func requestAccess(_ media: AVMediaType) async -> Bool { + switch AVCaptureDevice.authorizationStatus(for: media) { + case .authorized: return true + case .notDetermined: return await AVCaptureDevice.requestAccess(for: media) + default: return false + } + } + + func start() { + queue.async { [weak self] in + guard let self, !self.state.isRunning else { return } + self.state = .starting + self.observeSystemEvents() + switch self.source { + case .camera: + do { + try self.configureSession() + } catch { + self.state = .failed(error.localizedDescription) + return + } + self.startWriter() + self.session.startRunning() + case .testPattern: + self.startWriter() + self.startTestPattern() + } + self.state = .running + } + } + + /// Stop capturing and flush the segment in progress, so the last + /// thing published is a complete segment rather than a truncated one. + /// + /// `completion` runs after the final segment has been delivered to + /// ``onSegment``. Callers that stop the publisher next must wait for + /// it, or the last segment of the broadcast is handed to a publisher + /// that has already closed its queue. + func stop(completion: (() -> Void)? = nil) { + queue.async { [weak self] in + guard let self else { + completion?() + return + } + self.stopTestPattern() + if self.session.isRunning { self.session.stopRunning() } + self.finishWriter() + NotificationCenter.default.removeObserver(self) + self.observingSystemEvents = false + self.rotationObservation?.invalidate() + self.rotationObservation = nil + self.rotationCoordinator = nil + self.state = .idle + completion?() + } + } + + /// Change the rendition mid-broadcast. Restarts the writer, so the + /// current segment is finished cleanly and the next one starts a new + /// (discontinuous) timeline. + func apply(settings newSettings: Settings) { + queue.async { [weak self] in + guard let self, self.settings != newSettings else { return } + self.baseSettings = newSettings + self.publish(settings: newSettings) + guard self.state.isRunning || self.state == .starting else { return } + self.restartWriter() + } + } + + /// Flip between the front and back camera. A different device means + /// a different encoder session, so this takes the same clean-cut + /// path an interruption does. + func flipCamera() { + queue.async { [weak self] in + guard let self, self.source == .camera, + let current = self.videoDeviceInput else { return } + let wantFront = current.device.position != .front + guard let device = Self.captureDevice(front: wantFront), + let input = try? AVCaptureDeviceInput(device: device) else { return } + self.session.beginConfiguration() + self.session.removeInput(current) + if self.session.canAddInput(input) { + self.session.addInput(input) + self.videoDeviceInput = input + } else { + self.session.addInput(current) + } + self.session.commitConfiguration() + self.observeRotation(of: self.videoDeviceInput?.device) + self.restartWriter() + } + } + + // MARK: - Capture session + + private static func captureDevice(front: Bool) -> AVCaptureDevice? { + AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: front ? .front : .back + ) + } + + private func configureSession() throws { + session.beginConfiguration() + defer { session.commitConfiguration() } + session.sessionPreset = .high + + guard let camera = Self.captureDevice(front: false) ?? Self.captureDevice(front: true) + else { + throw CaptureError.noDevice("This device has no camera.") + } + let videoIn = try AVCaptureDeviceInput(device: camera) + guard session.canAddInput(videoIn) else { + throw CaptureError.noDevice("The camera is unavailable.") + } + session.addInput(videoIn) + videoDeviceInput = videoIn + + if let mic = AVCaptureDevice.default(for: .audio), + let audioIn = try? AVCaptureDeviceInput(device: mic), + session.canAddInput(audioIn) { + session.addInput(audioIn) + } + + let vOut = AVCaptureVideoDataOutput() + vOut.alwaysDiscardsLateVideoFrames = true + vOut.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: + Int(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange), + ] + vOut.setSampleBufferDelegate(self, queue: queue) + guard session.canAddOutput(vOut) else { + throw CaptureError.noDevice("The camera output is unavailable.") + } + session.addOutput(vOut) + videoOutput = vOut + + let aOut = AVCaptureAudioDataOutput() + aOut.setSampleBufferDelegate(self, queue: queue) + if session.canAddOutput(aOut) { + session.addOutput(aOut) + audioOutput = aOut + } + observeRotation(of: camera) + } + + /// Keep the encoded frames upright as the device turns. A rotation + /// changes the encoded dimensions, so it also restarts the writer — + /// #65's "orientation" interruption case. + private func observeRotation(of device: AVCaptureDevice?) { + rotationObservation?.invalidate() + guard let device else { return } + let coordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: nil) + rotationCoordinator = coordinator + applyRotation(coordinator.videoRotationAngleForHorizonLevelCapture) + rotationObservation = coordinator.observe( + \.videoRotationAngleForHorizonLevelCapture, + options: [.new] + ) { [weak self] _, change in + guard let angle = change.newValue else { return } + self?.queue.async { self?.applyRotation(angle) } + } + } + + private func applyRotation(_ angle: CGFloat) { + guard let connection = videoOutput?.connection(with: .video), + connection.isVideoRotationAngleSupported(angle), + connection.videoRotationAngle != angle + else { return } + connection.videoRotationAngle = angle + if state.isRunning { restartWriter() } + } + + // MARK: - Writer + + private func startWriter() { + // HLS mode: no output file, segments arrive through the delegate. + let writer = AVAssetWriter(contentType: UTType.mpeg4Movie) + writer.outputFileTypeProfile = .mpeg4AppleHLS + writer.preferredOutputSegmentInterval = CMTime( + seconds: settings.segmentSeconds, + preferredTimescale: 600 + ) + // `initialSegmentStartTime` is set in `beginSession` — it must + // equal the source time passed to `startSession`, and that time + // is the first sample's own clock (seconds since boot on the + // camera path, the running frame counter on the test pattern), + // which is not known until the sample arrives. + writer.delegate = self + + var compression: [String: Any] = [ + AVVideoAverageBitRateKey: settings.bitrateKbps * 1000, + AVVideoProfileLevelKey: AVVideoProfileLevelH264MainAutoLevel, + // One keyframe per segment: a segment that does not start on + // an IDR frame is not independently playable, which is the + // whole point of segmenting. + AVVideoMaxKeyFrameIntervalDurationKey: settings.segmentSeconds, + AVVideoExpectedSourceFrameRateKey: settings.frameRate, + ] + compression[AVVideoAllowFrameReorderingKey] = false + let vInput = AVAssetWriterInput( + mediaType: .video, + outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: settings.width, + AVVideoHeightKey: settings.height, + AVVideoCompressionPropertiesKey: compression, + ] + ) + vInput.expectsMediaDataInRealTime = true + guard writer.canAdd(vInput) else { + state = .failed("The video encoder rejected this rendition.") + return + } + writer.add(vInput) + videoInput = vInput + + if source == .camera { + let aInput = AVAssetWriterInput( + mediaType: .audio, + outputSettings: [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVNumberOfChannelsKey: 1, + AVSampleRateKey: 44_100, + AVEncoderBitRateKey: 64_000, + ] + ) + aInput.expectsMediaDataInRealTime = true + if writer.canAdd(aInput) { + writer.add(aInput) + audioInput = aInput + } + } else { + audioInput = nil + pixelAdaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: vInput, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA), + kCVPixelBufferWidthKey as String: settings.width, + kCVPixelBufferHeightKey as String: settings.height, + ] + ) + } + + sessionStarted = false + self.writer = writer + } + + /// Start writing at the first video sample's own timestamp. + /// + /// Apple's fragmented-MP4 authoring contract anchors segmentation + /// boundaries at `initialSegmentStartTime`, so it and the + /// `startSession` source time must be the same instant — a `.zero` + /// start against capture-clock timestamps (hours since boot) risks + /// a wrong first-segment duration or an immediate cut. Setting it + /// here, just before `startWriting`, keeps the two in agreement on + /// every path, including a writer restarted mid-broadcast whose + /// first sample is nowhere near time zero. + private func beginSession(of writer: AVAssetWriter, at time: CMTime) -> Bool { + writer.initialSegmentStartTime = time + guard writer.startWriting() else { + state = .failed(writer.error?.localizedDescription ?? "The video encoder did not start.") + return false + } + writer.startSession(atSourceTime: time) + sessionStarted = true + return true + } + + /// Finish the writer so the segment in progress is emitted complete. + private func finishWriter() { + guard let writer, writer.status == .writing else { + // A writer that never saw a video sample never started + // writing (see `beginSession`); nothing to flush. + self.writer = nil + videoInput = nil + audioInput = nil + pixelAdaptor = nil + sessionStarted = false + return + } + restarting = true + videoInput?.markAsFinished() + audioInput?.markAsFinished() + let group = DispatchGroup() + group.enter() + writer.finishWriting { group.leave() } + // Bounded: the delegate callbacks for the final segment are + // delivered before `finishWriting` completes, and a writer that + // hangs must not wedge the capture queue for good. + _ = group.wait(timeout: .now() + 5) + self.writer = nil + videoInput = nil + audioInput = nil + pixelAdaptor = nil + sessionStarted = false + restarting = false + } + + /// End the current segment cleanly and open a new timeline. Every + /// interruption path funnels through here. + private func restartWriter() { + finishWriter() + flagDiscontinuity() + startWriter() + } + + /// The next media segment starts a new timeline. + private func flagDiscontinuity() { + delegateLock.lock() + pendingDiscontinuityLocked = true + delegateLock.unlock() + } + + // MARK: - System events + + private func observeSystemEvents() { + guard !observingSystemEvents else { return } + observingSystemEvents = true + let center = NotificationCenter.default + center.addObserver( + self, selector: #selector(sessionInterrupted(_:)), + name: AVCaptureSession.wasInterruptedNotification, object: session + ) + center.addObserver( + self, selector: #selector(sessionInterruptionEnded(_:)), + name: AVCaptureSession.interruptionEndedNotification, object: session + ) + center.addObserver( + self, selector: #selector(sessionRuntimeError(_:)), + name: AVCaptureSession.runtimeErrorNotification, object: session + ) + center.addObserver( + self, selector: #selector(didEnterBackground), + name: UIApplication.didEnterBackgroundNotification, object: nil + ) + center.addObserver( + self, selector: #selector(willEnterForeground), + name: UIApplication.willEnterForegroundNotification, object: nil + ) + center.addObserver( + self, selector: #selector(thermalStateChanged), + name: ProcessInfo.thermalStateDidChangeNotification, object: nil + ) + } + + @objc private func sessionInterrupted(_ note: Notification) { + let raw = note.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int + let reason = AVCaptureSession.InterruptionReason(rawValue: raw ?? 0) + queue.async { [weak self] in + guard let self else { return } + // Finish the segment in flight *now*: an interrupted session + // stops delivering samples, and a half-written segment is + // not playable. + self.finishWriter() + self.flagDiscontinuity() + self.state = .interrupted(Self.label(for: reason)) + } + } + + private static func label(for reason: AVCaptureSession.InterruptionReason?) -> String { + switch reason { + case .audioDeviceInUseByAnotherClient, .videoDeviceInUseByAnotherClient: + return "Paused — another app is using the camera or microphone" + case .videoDeviceNotAvailableInBackground: + return "Paused — AntStream is in the background" + case .videoDeviceNotAvailableWithMultipleForegroundApps: + return "Paused — Split View is using the camera" + case .videoDeviceNotAvailableDueToSystemPressure: + return "Paused — the device is too warm" + default: + return "Paused — capture was interrupted" + } + } + + @objc private func sessionInterruptionEnded(_ note: Notification) { + queue.async { [weak self] in + guard let self, self.state != .idle else { return } + // Recovery starts a *new* segment on a new timeline. + if self.writer == nil { self.startWriter() } + self.state = .running + } + } + + @objc private func sessionRuntimeError(_ note: Notification) { + let error = note.userInfo?[AVCaptureSessionErrorKey] as? NSError + queue.async { [weak self] in + guard let self else { return } + self.finishWriter() + self.flagDiscontinuity() + self.state = .interrupted(error?.localizedDescription ?? "Capture error") + if self.source == .camera, !self.session.isRunning { + self.session.startRunning() + } + self.startWriter() + self.state = .running + } + } + + @objc private func didEnterBackground() { + queue.async { [weak self] in + guard let self, self.state != .idle else { return } + self.stopTestPattern() + self.finishWriter() + self.flagDiscontinuity() + self.state = .interrupted("Paused — AntStream is in the background") + } + } + + @objc private func willEnterForeground() { + queue.async { [weak self] in + guard let self, case .interrupted = self.state else { return } + if self.writer == nil { self.startWriter() } + if self.source == .testPattern { self.startTestPattern() } + self.state = .running + } + } + + /// Thermal downshift: a phone that throttles mid-broadcast drops + /// frames long before it stops, so halve the bitrate once it is + /// seriously warm and restore the configured one when it cools. + @objc private func thermalStateChanged() { + let thermal = ProcessInfo.processInfo.thermalState + queue.async { [weak self] in + guard let self, self.state.isRunning else { return } + let hot = thermal == .serious || thermal == .critical + let wanted = hot ? self.baseSettings.downshifted() : self.baseSettings + guard wanted != self.settings else { return } + self.publish(settings: wanted) + self.restartWriter() + } + } + + private func publish(settings newSettings: Settings) { + settings = newSettings + pixelBufferPool = nil + delegateLock.lock() + delegateSettings = (newSettings.segmentSeconds, newSettings.ringCapacity) + delegateLock.unlock() + } + + // MARK: - Test pattern + + private func startTestPattern() { + guard source == .testPattern, testPatternTimer == nil else { return } + let interval = 1.0 / Double(max(1, settings.frameRate)) + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: interval) + timer.setEventHandler { [weak self] in self?.emitTestFrame() } + testPatternTimer = timer + timer.resume() + } + + private func stopTestPattern() { + testPatternTimer?.cancel() + testPatternTimer = nil + } + + private func emitTestFrame() { + guard let writer, !restarting else { return } + let time = CMTime( + value: CMTimeValue(testPatternFrame), + timescale: CMTimeScale(max(1, settings.frameRate)) + ) + if !sessionStarted { + guard beginSession(of: writer, at: time) else { return } + } + guard writer.status == .writing, + let adaptor = pixelAdaptor, let input = videoInput, input.isReadyForMoreMediaData, + let buffer = makeTestPixelBuffer() + else { return } + adaptor.append(buffer, withPresentationTime: time) + testPatternFrame += 1 + } + + /// A moving bar over a slowly-shifting background. Deliberately not + /// a static image: identical frames encode to nothing, which would + /// make the segments unrepresentative of real video. + private func makeTestPixelBuffer() -> CVPixelBuffer? { + if pixelBufferPool == nil { + let attributes: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA), + kCVPixelBufferWidthKey as String: settings.width, + kCVPixelBufferHeightKey as String: settings.height, + kCVPixelBufferCGImageCompatibilityKey as String: true, + ] + var pool: CVPixelBufferPool? + CVPixelBufferPoolCreate(nil, nil, attributes as CFDictionary, &pool) + pixelBufferPool = pool + } + guard let pool = pixelBufferPool else { return nil } + var buffer: CVPixelBuffer? + guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &buffer) == kCVReturnSuccess, + let pixels = buffer + else { return nil } + CVPixelBufferLockBaseAddress(pixels, []) + defer { CVPixelBufferUnlockBaseAddress(pixels, []) } + guard let base = CVPixelBufferGetBaseAddress(pixels) else { return nil } + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixels) + let width = CVPixelBufferGetWidth(pixels) + let height = CVPixelBufferGetHeight(pixels) + let phase = Double(testPatternFrame) + let barX = Int((sin(phase / 18.0) * 0.4 + 0.5) * Double(width - 40)) + let shade = UInt8(24 + 24 * (sin(phase / 40.0) * 0.5 + 0.5)) + for y in 0..= barX && x < barX + 40 + let p = row.advanced(by: x * 4) + p[0] = inBar ? 200 : shade // B + p[1] = inBar ? 90 : UInt8(shade / 2) // G + p[2] = inBar ? 40 : UInt8(y * 200 / max(1, height)) // R + p[3] = 255 + } + } + return pixels + } + + // MARK: - Ring buffer + + /// Write a segment into the on-disk ring and evict the oldest past + /// `capacity`. Called with ``delegateLock`` held. + private func storeLocked(_ data: Data, name: String, capacity: Int) -> URL? { + let url = ringDirectory.appendingPathComponent(name) + do { + try data.write(to: url, options: .atomic) + } catch { + return nil + } + ringFilesLocked.append(url) + while ringFilesLocked.count > capacity { + let victim = ringFilesLocked.removeFirst() + try? FileManager.default.removeItem(at: victim) + } + return url + } + + enum CaptureError: LocalizedError { + case noDevice(String) + + var errorDescription: String? { + switch self { + case .noDevice(let m): return m + } + } + } +} + +// MARK: - Sample delivery + +extension CaptureEngine: AVCaptureVideoDataOutputSampleBufferDelegate, + AVCaptureAudioDataOutputSampleBufferDelegate { + func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + guard !restarting, let writer else { return } + let time = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + if !sessionStarted { + // Video first: starting the session on an audio sample can + // leave the first video frames before the session start and + // the writer rejects them. + guard output is AVCaptureVideoDataOutput else { return } + guard beginSession(of: writer, at: time) else { return } + } + guard writer.status == .writing else { return } + let input = output is AVCaptureVideoDataOutput ? videoInput : audioInput + guard let input, input.isReadyForMoreMediaData else { return } + input.append(sampleBuffer) + } +} + +// MARK: - Segment delivery + +extension CaptureEngine: AVAssetWriterDelegate { + func assetWriter( + _ writer: AVAssetWriter, + didOutputSegmentData segmentData: Data, + segmentType: AVAssetSegmentType, + segmentReport: AVAssetSegmentReport? + ) { + let isInit = segmentType == .initialization + delegateLock.lock() + let (segmentSeconds, ringCapacity) = delegateSettings + segmentIndexLocked += 1 + let name = isInit ? "init-\(segmentIndexLocked).mp4" : "seg-\(segmentIndexLocked).m4s" + let url = storeLocked(segmentData, name: name, capacity: ringCapacity) + let discontinuity = !isInit && pendingDiscontinuityLocked + if discontinuity { pendingDiscontinuityLocked = false } + delegateLock.unlock() + + let reported = segmentReport?.trackReports.first?.duration + let durationMs = reported.map { UInt32(max(0, $0.seconds) * 1000) } + ?? UInt32(segmentSeconds * 1000) + onSegment?( + Segment( + data: segmentData, + isInitialization: isInit, + durationMs: isInit ? 0 : durationMs, + discontinuity: discontinuity, + fileURL: url + ) + ) + } +} diff --git a/examples/ios-stream/AntStream/Info.plist b/examples/ios-stream/AntStream/Info.plist index 7184958..ae878e9 100644 --- a/examples/ios-stream/AntStream/Info.plist +++ b/examples/ios-stream/AntStream/Info.plist @@ -24,6 +24,15 @@ $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS + + NSCameraUsageDescription + AntStream uses the camera to record the video you broadcast. + NSMicrophoneUsageDescription + AntStream uses the microphone to record the audio you broadcast. NSAppTransportSecurity