Skip to content

AntStream: camera capture + live publisher loop (#67 stage 2, #65) - #76

Open
meinharrd wants to merge 10 commits into
mainfrom
alan/antstream-stage2-live-publisher
Open

AntStream: camera capture + live publisher loop (#67 stage 2, #65)#76
meinharrd wants to merge 10 commits into
mainfrom
alan/antstream-stage2-live-publisher

Conversation

@meinharrd

Copy link
Copy Markdown
Contributor

Part of #67 (stage 2), implements #65.

Stage 1 (#72) built the publisher loop minus the camera and measured what it
sustains. Stage 2 swaps the synthetic generator for the real capture pipeline
and adds the two things a viewer needs: a rolling HLS playlist and a feed that
points at it.

What changed

Rust — crates/ant-ffi/src/publisher.rs (new)

The live loop, grown out of bench.rs rather than beside it. Target, the
loopback HTTP client, publish_bzz and data_chunk_count moved into
publisher.rs as product code and bench.rs now imports them, so the bench
keeps measuring exactly the call a broadcast makes. If the two ever drift, the
stage-1 numbers stop describing the product.

Per segment:

  1. POST /bzz the segment (and the fMP4 initialization segment whenever the
    writer restarts).
  2. Rebuild the HLS media playlist over a sliding window.
  3. POST /bzz the playlist, then publish its reference as a sequence-feed
    update
    with POST /soc — the bee-js shape (id = keccak256(topic ‖ index_be8), payload timestamp_be8 ‖ reference), so any bee gateway
    resolves the channel. POST /feeds at start yields the feed manifest that
    is the one reference worth sharing.

Live-edge discipline, straight from the stage-1 findings:

stage-1 finding stage-2 constant
window 4 sustains (899/899), window 8 collapses the connection layer (26/316) DEFAULT_MAX_IN_FLIGHT = 4, documented as measured, not exposed as a UI knob
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

Two correctness properties the tests pin: segments commit in capture order
even though four uploads finish out of order (so the playlist is never
reordered), and a media segment whose initialization segment did not land is
never listed (a playlist no player can start is worse than a shorter one).

Direct gateway POSTs only — deliberately not UploadManager jobs: that is
resume-oriented VOD tooling and a resumed segment is a segment nobody will ever
play. It becomes the right tool in stage 3.

C ABI: ant_publisher_start / _push_segment / _progress / _stop,
documented in include/ant.h.

Swift — examples/ios-stream

  • CaptureEngine.swift (AntStream: camera capture → HLS fMP4 segments (pure AVFoundation) #65)AVCaptureSession (camera + mic) →
    VideoToolbox H.264 → AVAssetWriter in .mpeg4AppleHLS mode emitting fMP4
    segments + playlist data. Bitrate and segment duration are runtime
    parameters; a local ring buffer keeps the last two minutes of segments on
    disk. Interruptions — incoming call, backgrounding, camera flip, orientation,
    thermal downshift — finish the current writer so its last segment is complete
    and playable, and recovery starts a fresh writer whose first media segment is
    flagged discontinuous.
  • LiveBroadcast.swift — joins capture to ant_publisher_* and owns the
    foreground keep-alive (idle timer held off, audio session configured for
    recording, both released on every exit path). Stopping waits for the writer's
    final segment to reach the publisher before closing its queue.
  • LiveView.swift — the going-live screen: preview, the publish-lag
    indicator
    (capture → the feed update that makes the segment playable) and
    the dropped/failed counters. The badge's colour is the node's own
    keeping_up, so the UI can't disagree with the report.
  • Camera/microphone usage descriptions; Go live now opens LiveView.

ant-side gaps found during integration

Both are on the exact write path a broadcast hammers, and both were found by
running the publisher against the real gateway:

  1. A saturated postage batch was reported as 502 Bad Gateway on
    POST /bzz and POST /soc — the retryable class. A broadcast walking a
    batch's collision buckets for an hour would retry forever against a
    permanently full bucket and the UI would report a network error. One shared
    upload_error_status now answers bee's 402 "batch is overissued"
    everywhere, matching what /chunks/stream and /stewardship already did.
  2. PushSoc had none of PushChunk's collision-bucket guard. SOCs are not
    a rare write — every redundant upload mints dispersed replicas as SOCs, and
    a live feed writes one per playlist update — so on an immutable batch a feed
    update surfaced as a bare stamp issue failed: bucket full, and on a
    mutable one it wrapped a bucket (evicting somebody's chunk) with no log at
    all. It now takes the same guarded path, with the same actionable message.

Verification

  • cargo test --workspace — 35 test binaries, 0 failures. cargo clippy --workspace --all-targets clean, cargo fmt --check clean.
  • New unit tests (publisher.rs): playlist assembly in capture order behind
    one #EXT-X-MAP; a dropped segment becoming a discontinuity rather than a
    silent gap; a new initialization segment starting a new map and a
    discontinuity; the window sliding with a correct
    EXT-X-MEDIA-SEQUENCE / EXT-X-DISCONTINUITY-SEQUENCE; media before its init
    segment never listed; #EXT-X-ENDLIST on close; per-broadcast topic
    derivation; config validation.
  • New end-to-end tests against a stub gateway that records what it is sent:
    a broadcast publishes segments, playlist and a feed update whose SOC ant's
    own soc_valid accepts
    , at exactly keccak256(topic ‖ index_be8) for
    index 0, with the bee v1 40-byte ts ‖ ref payload; a full backlog sheds its
    oldest segment and marks the gap; a rejecting gateway is reported rather than
    counted as published (and no bogus playlist is published without an init
    segment); pushing after stop is refused.
  • New integration test against the real ant-gateway router
    (crates/ant-ffi/tests/live_publisher_gateway.rs): the production router in
    light_mode on a loopback port, backed by a stub node loop, driven by the
    publisher. Asserts every feed update reached the node at its sequence
    address, none landed past the reported head, and the gateway never dispatched
    an invalid SOC. This is what surfaced gap 1 above.
  • New gateway unit tests for the status mapping: every batch-saturation
    string the node actually emits maps to 402, and the other four error classes
    keep their status.
  • Simulator screenshots below, from antstream-visual.

Not verifiable here, stated plainly: the camera path itself is device-only.
The simulator has no capture device, so CI runs the same encoder, segmenter and
publisher from a generated test pattern (labelled as such on screen), and its
uploads stop at the gateway's batch check because the runner has no real
postage batch — the same wall the stage-1 publish rows hit. A real
device broadcast (and the owed #67 device rows) still needs hardware and a
funded plan.

Stage 1 built the publisher loop minus the camera and measured what it
sustains. Stage 2 replaces the synthetic generator with the real capture
pipeline and adds the two things a viewer needs — a rolling HLS playlist
and a feed that points at it.

Rust (crates/ant-ffi/src/publisher.rs, new)
  * The live loop, grown out of bench.rs rather than beside it: Target,
    the loopback HTTP client, publish_bzz and data_chunk_count moved
    here as product code and bench.rs now imports them, so the bench
    keeps measuring exactly the call a broadcast makes.
  * Per segment: POST /bzz -> rebuild the media playlist over a sliding
    window -> POST /bzz the playlist -> publish its reference as a
    sequence-feed update with POST /soc (bee-js shape: id =
    keccak256(topic || index_be8), payload timestamp_be8 || reference).
    POST /feeds at start yields the channel manifest a viewer resolves.
  * Live-edge discipline from the stage-1 findings: in-flight window 4
    (8 collapsed the connection layer), drop-oldest backlog rather than
    unbounded lag, and a gap marked #EXT-X-DISCONTINUITY instead of a
    silent hole. Default rendition 360p/900 kbit/s/2 s segments.
  * Segments commit in capture order even though uploads finish out of
    order, so the playlist is never reordered; a media segment whose
    initialization segment did not land is never listed.
  * C ABI: ant_publisher_start/_push_segment/_progress/_stop, documented
    in include/ant.h.

Swift (examples/ios-stream)
  * CaptureEngine.swift (#65): AVCaptureSession -> H.264 ->
    AVAssetWriter in .mpeg4AppleHLS emitting fMP4 segments, runtime
    bitrate/segment duration, on-disk segment ring, and interruption
    handling (call, backgrounding, camera flip, orientation, thermal
    downshift) that ends the current segment cleanly and starts a new
    one flagged discontinuous.
  * LiveBroadcast.swift joins capture to the publisher and holds the
    foreground keep-alive (idle timer, audio session).
  * LiveView.swift is the going-live screen: preview, the publish-lag
    indicator, and the dropped/failed counters.
  * Camera + microphone usage descriptions; Go live now opens LiveView.

ant-side gaps found during integration
  * A saturated postage batch was reported as 502 Bad Gateway on
    POST /bzz and POST /soc, which reads as retryable — a long
    broadcast would retry forever against a permanently full bucket.
    One shared mapping now answers bee's 402 "batch is overissued"
    everywhere, matching what /chunks/stream and /stewardship already
    did.
  * PushSoc had none of PushChunk's collision-bucket guard, so on an
    immutable batch a feed update surfaced as a bare "stamp issue
    failed: bucket full" and on a mutable one wrapped a bucket —
    evicting somebody's chunk — with no log. It now takes the same path.

Part of #67 (stage 2), implements #65.
- ant_publisher_stop's grace was 75 s but stopping publishes two rounds
  of the 60 s per-segment deadline (the in-flight window, then the
  bounded backlog behind it). Raised to 130 s and corrected on every doc
  surface the call has: lib.rs and ant.h.
- PublisherReport::kept_up is a conjunction whose first clause is 'at
  least one segment published', so a broadcast that published nothing
  reported false — and the summary card rendered that as 'fell behind',
  blaming the uplink for a broadcast that never started. The card is now
  three-state, the same treatment BenchReport.hasMeasurement gives the
  throughput verdict, with one predicate both the label and the tint
  read.
Found by a regression test for the kept_up predicate: when a *later*
initialization segment failed to upload (a writer restart after an
interruption), the media segments that followed were still listed under
the previous writer's #EXT-X-MAP — an initialization segment that does
not describe them, i.e. a playlist a player cannot start. They also
counted as published, so kept_up could report a broadcast as keeping up
while the segments after the failure were unplayable.

Committed::Failed now carries the segment kind, a failed init clears the
map in force (nothing is listed again until a fresh one lands), and
kept_up counts segments_listed — segments that reached a published
playlist — rather than segments uploaded. segments_listed is surfaced on
the snapshot/report (shown as PLAYABLE on the live screen) so the Swift
verdict reads the same first clause the Rust predicate does.
POST /feeds is a real upload and can sit on the 60 s publish deadline
while the peer set warms up. Awaiting it before spawning the pump spent
the first minute of a broadcast waiting for a reference nobody had been
handed yet, while the capture backlog shed segments behind it. It now
runs alongside the loop, with an in-flight guard so the start-time
attempt and the committer's retry can't both be on the wire at once.
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 7, 2026
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 7, 2026
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 7, 2026
ByteCountFormatter's default nonnumeric formatting rendered 0 as 'Zero
KB' — visible in the broadcast checklist ('Zero KB free') and now next
to the live counters ('SENT Zero KB'), where every neighbouring cell is
a numeral. Both surfaces are in the simulator evidence for this PR.
@meinharrd

Copy link
Copy Markdown
Contributor Author

Simulator evidence — antstream-visual run 31196395122

The Swift build passes with the three new files, and the new
-antstream-shot-live capture drives the real pipeline end to end on the
runner. Read the two live shots as a pair 30 s apart.

The going-live screen, 40 s after launch — 19 segments captured, encoded and
handed to the publisher:

live-broadcast

…and 30 s later, 35:

live-publish-lag

What these actually demonstrate:

  • The capture pipeline runs and holds its cadence. 19 → 35 segments in the
    30 s between the two shots is 1.9 s per segment against a 2 s configured
    segment duration — AVAssetWriter in .mpeg4AppleHLS mode really is
    emitting fMP4 segments on the wall clock, on a device with no camera, from
    the generated pattern (labelled as such on screen so it can't be mistaken
    for real capture).
  • Every segment reached the publisher and a real POST /bzz. The error
    line is the gateway's own words: media segment 35: gateway returned 400 {"code":400,"message":"push chunk failed: batch 0xa1a1a1a…}. The runner has
    no postage batch, so the sample plan's batch id is rejected at the batch
    check — the same wall the stage-1 publish rows hit. Nothing here fakes a
    successful upload; PLAYABLE stays 0 and the badge stays yellow
    Connecting… because no playlist has been published, which is the
    three-state verdict behaving correctly rather than a green "live" over a
    broadcast nobody can watch.
  • The node is genuinely on the network underneath it: 101 peers.
  • DROPPED is 0, i.e. the live-edge discipline had nothing to shed — the
    encoder was never ahead of the (immediately-failing) uploader here. The
    drop-oldest path is covered by the unit test instead.

The gate in front of it, unchanged:

broadcast-checklist

A runner account has no storage plan, so Go live is correctly disabled and
LiveBroadcast.start refuses with "Connect a storage plan before going live"
(the -antstream-shot-live path installs a sample plan purely to get past that
gate for the capture).

One thing the shots caught that the code review wouldn't have: both surfaces
rendered a zero byte count as the word "Zero KB" (ByteCountFormatter's
default nonnumeric formatting) — "Zero KB free" in the checklist, "SENT Zero
KB" beside four numerals. Fixed in f18f65e.

Still device-only, stated plainly: the camera path itself, the interruption
cases (incoming call, camera flip, orientation, thermal downshift) and a
broadcast whose uploads actually land all need hardware and a funded plan. The
publish half of that is covered here by the integration test against the real
ant-gateway router, not by these screenshots.

meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 7, 2026
@meinharrd

Copy link
Copy Markdown
Contributor Author

Re-captured after f18f65e (run 31199128706, green): SENT now reads 0 bytes rather than Zero KB, and the rest reproduces — 34 segments captured and pushed at the 2 s cadence, 100 peers, uploads rejected at the batch check.

live-publish-lag-fixed

Both workflows are green on the head commit: CI (Rust build/clippy/tests, plus the iOS-simulator symbol smoke) and antstream-visual (Xcode build + 13 captures).

@meinharrd meinharrd added the alan:reviewing alan loop currently running on this PR label Aug 7, 2026
@meinharrd

Copy link
Copy Markdown
Contributor Author

[alan-review R1] Blocking — keeping_up/kept_up go stale when feed updates stop landing: the live badge stays green and the final report says "kept up" while viewers are frozen at the last update that landed.

crates/ant-ffi/src/publisher.rs:

  • note_lag is only called on a successful feed update (publish_playlist, ~line 1169), so lag_ms_last freezes at the last update that landed.
  • PublisherSnapshot.keeping_up (~line 776) and PublisherReport.kept_up (~line 859) both test that frozen lag_ms_last <= lag_budget_ms, and neither predicate notices that feed updates have been failing since then. segments_listed doesn't catch it either: it increments in advance() when a segment enters the in-memory playlist, before the playlist upload or the feed update is even attempted — so segments_listed == media_pushed holds even when no viewer can see any of it.

Failure scenario — and it's the one this PR itself identified as real (gap 2: SOC writes hitting a full collision bucket, now surfaced as a hard 402/Error): feed update 0 lands, then every subsequent POST /soc fails. Segments keep uploading and get "listed"; viewers resolving the feed are stuck at the first playlist for the rest of the broadcast. The on-screen badge (LiveView.lagTint keys off keeping_up) stays green "LIVE, 0.0 s behind" and the end-of-run summary says "Broadcast kept up" — exactly the "publisher that quietly drifts behind looks identical to a healthy one" failure the lag indicator exists to prevent, per this PR's own description.

Empirically confirmed with a temporary #[test] against the real start()/LiveRun and the stub gateway, with /soc failing after the first success (segment_ms = 100, so lag budget = 300 ms; reverted after the run):

SNAPSHOT keeping_up=true lag_ms=25 feed_index=1 errors=5    <- >1.1 s of unwatchable content on a 300 ms budget
...
report: segments_listed: 6, feed_updates: 1, lag_ms_final: 25, kept_up: true,
        errors: ["feed update 1: gateway returned 400 ...", ...] x6

Both asserts of honest behavior (!keeping_up, !kept_up) fail on this head.

Suggested direction: compute the effective lag at read time as something like max(lag_ms_last, elapsed since the newest committed-but-not-yet-feed-published segment's capture) — i.e. track the capture instant of the newest committed media segment whose feed update has not landed, and let snapshot()/report() derive lag from it — or gate keeping_up/kept_up on "the newest listed segment's feed update landed inside the budget" rather than on the last successful update's lag. (Same family as PR #72's keeps_up() window-scoping findings: every input to the verdict must describe the same window — here the lag clause describes only the successes.)

@meinharrd

Copy link
Copy Markdown
Contributor Author

[alan-review R1] Minor findings (combined, non-blocking):

  1. Stop-bound doc mismatch across FFI doc surfaces. crates/ant-ffi/src/lib.rs (PUBLISHER_STOP_GRACE) and include/ant.h both document ant_publisher_stop as bounded at ~130 s, but examples/ios-stream/AntStream/AntNode.swift (stopPublisher, ~line 680) says "(up to ~75 s)". Same class as the repeated ant.h/jni.rs doc-drift findings from PRs AntStream: app shell, onboarding port, Keychain/Secure Enclave keys (examples/ios-stream) #69/onboarding: fund the chequebook the storage-buy flow deploys #75 — one surface didn't get the final number.

  2. ant_publisher_stop doc overclaims idempotence. ant.h / lib.rs: "Safe to call on an already-finished broadcast: it returns the same report." True only while the slot still holds the run (a broadcast that finished on its own). After a successful stop clears the slot, a second call returns an error ("this node is not broadcasting"), not the same report. Worth one clarifying clause so a host doesn't rely on re-fetching the report.

  3. lagLabel can read "0.0 s behind" for a lag that was never measured. StreamModels.swift PublisherSnapshot.lagLabel gates "Connecting…" on playlistsPublished > 0, but lag_ms is only ever set when a feed update lands. If playlists publish while every POST /soc fails (possible: gap 2's saturated-batch 402 on the SOC path), the badge shows an orange dot with "0.0 s behind" — a number that means "no update ever landed", not "0 s". Gating the label on feedIndex > 0 (matching keeping_up's first clause) would keep the two surfaces consistent. (Largely subsumed by the blocking stale-lag finding — flagging so the fix covers the label too.)

  4. Channel-manifest retry runs inline on the committer. publisher.rs publish_playlist tail-calls ensure_channel_manifest(ctx).await on the commit loop when channel_reference is still unset. The start-time attempt was deliberately made concurrent ("the first minute of a broadcast must not be spent waiting"), but the retry can stall the committer — and therefore playlist/feed updates for landed segments — for up to the 60 s PUBLISH_TIMEOUT per cycle if POST /feeds hangs while /bzz is healthy. Spawning the retry (guarded by the existing channel_manifest_in_flight flag) would keep the live path clean.

  5. Derived topic has 1-second granularity. PublisherConfig::resolve_topic derives keccak256("antstream/<channel>/<unix_secs>"). A stop + restart of the same channel within the same second reuses the topic, so the new broadcast rewrites sequence index 0 over the old feed's head — the "fresh feed at index 0" property the doc promises silently fails. Cheap fix: include a per-process counter or millisecond timestamp in the seed.

@meinharrd

Copy link
Copy Markdown
Contributor Author

[alan-verify R1] R1-F1 (publisher.rs:776, stale lag_ms_last behind keeping_up/kept_up) — CONFIRMED.

Reproduced independently against the real publisher with a temporary #[test] in crates/ant-ffi/src/publisher.rs (stub gateway extended with a fail_soc switch; reverted, nothing committed). segment_ms = 100, so the lag budget is 300 ms:

  • One good segment → feed update 0 lands, lag_ms = 30.
  • POST /soc then fails for every subsequent playlist while POST /bzz keeps succeeding (exactly the class this PR's own gap-2 fix addresses: a SOC hitting a full collision bucket on an immutable batch while ordinary chunk uploads are fine).
  • After 8 further segments / ~1.2 s with the live edge frozen at update 0:
    lag_ms=30 keeping_up=true feed_index=1 error_count=8 last_error="feed update 1: ..."
    and the final report: kept_up=true lag_final=30 listed=9 published=9 failed=0 dropped=0 feed_updates=1.

So both clauses hold as the reviewer described: feed_updates > 0 latches on update 0, segments_listed == media_pushed holds because advance() counts a segment as listed before the playlist/feed publish is even attempted, and lag_ms_last only moves inside the Ok(()) arm of publish_feed_update. Nothing else vetoes the verdict — kept_up never looks at error_count, feed_index staleness, or wall-clock time since the last landed update.

Broader trigger, also reproduced: with all uploads failing after the first update (fail_bzz + fail_soc), keeping_up is still true with lag_ms=32 while segments_failed=8 — the snapshot predicate has no segments_listed == media_pushed clause at all, so the live badge goes stale-green under total upload failure too (only the end-of-run kept_up catches that one).

UI impact is direct, not inferred: LiveView.lagTint returns .green on progress.keepingUp once playlistsPublished > 0 (playlists keep uploading fine in this scenario), PublisherSnapshot.lagLabel renders the frozen value as "0.0 s behind", and PublisherReport.verdictLabel prints "Broadcast kept up" in green via verdictTint.

Visual note: the antstream-visual run for this PR (12-live-publish-lag.png) captures only the pre-first-update path — yellow badge, "Connecting…", FAILED 34 — which behaves correctly. The stale-green state needs at least one feed update to land first, which the runner can't produce (no funded postage batch), so this one is confirmed from the Rust source of truth the badge reads verbatim rather than from a simulator shot.

`keeping_up` and `kept_up` were computed from the latency of the last
feed update that *landed*. That figure only moves on success, so once
`POST /soc` starts failing it freezes at its last good value while the
segments go on uploading and listing — `segments_listed == media_pushed`
still holds, `feed_updates` is still > 0 — and the badge stays green
with a frozen lag while no viewer sees anything past the last playlist
that landed.

Measure the live edge instead: keep the capture instant of the newest
segment a landed feed update made playable, and report its *age*. That
grows for exactly as long as nothing new reaches a viewer, so a stuck
feed clears the three-segment budget and both the badge and the verdict
turn. Bootstrapped with the first committed segment so a broadcast whose
very first update never lands measures from the content nobody saw, and
frozen at run finish so a report read later still describes the
broadcast rather than how long the host waited to ask.

Docs updated on every surface that describes the field: `ant.h`, the
snapshot/report doc comments, and the mirrored Swift models.
@meinharrd
meinharrd force-pushed the alan/antstream-stage2-live-publisher branch from 91b8bbc to 1ddea92 Compare August 7, 2026 17:25
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 7, 2026
@meinharrd

Copy link
Copy Markdown
Contributor Author

[alan-fix R1] — commit 1ddea92

R1-F1 — keeping_up/kept_up froze when feed updates stopped landing (blocking) — fixed

Root cause was the kind of number, not the predicate: both verdicts read lag_ms_last, the latency of the last feed update that landed. That only moves on success, so once POST /soc starts failing it freezes at its last good value while segments keep uploading and listing — segments_listed == media_pushed still holds, feed_updates is still > 0 — and the badge stays green on a stream no viewer can follow.

The fix measures the live edge instead: PublisherState::live_edge holds the capture instant of the newest segment a landed feed update made playable, and live_edge_lag_ms() reports its age. An age grows for exactly as long as nothing new reaches a viewer, so a stuck feed clears the three-segment budget on its own — no extra clause to keep in sync with the other counters.

Three details worth calling out:

  • Bootstrap. advance() seeds the edge with the first committed segment (get_or_insert, so later segments never move it — only a landed update does). Without that, a broadcast whose very first update never lands would measure lag from nothing at all and read 0.
  • Frozen with the last content, in commit_loop just before the closing #EXT-X-ENDLIST publish. An age must not keep ticking after the run, and freezing after the endlist upload would have let a slow final upload read as a broadcast that fell behind — the opposite false verdict. run_loop keeps a get_or_insert fallback for a run that never got that far.
  • lag_ms_max and lag_ms_p50/p95 keep their old meaning (per-update capture → playable latency); only lag_ms/lag_ms_final are ages now, and the doc comments say which is which.

Docs updated on every surface that describes the field (per the repeat lesson): ant.h for both ant_publisher_progress and ant_publisher_stop, the PublisherSnapshot/PublisherReport doc comments, and the mirrored Swift models + LiveView.liveBadge.

Evidence

New regression test publisher::tests::a_feed_that_stops_updating_stops_keeping_up, driving the real start/push/snapshot/report against the stub gateway with a new fail_soc flag (segments still upload; only the feed update is refused). Two-sided against the real code path:

Pre-fix (live_edge_lag_ms temporarily reverted to the last landed update's latency) — 5 segments listed, 4 feed updates rejected, and the badge is still green:

lag_ms: 27, keeping_up: true, feed_index: 1, segments_listed: 5,
last_error: "feed update 1: gateway returned 400 ...", error_count: 4

Post-fix — the same run, the exact JSON ant_publisher_progress hands the app:

healthy: "feed_index":1,"segments_listed":1,"lag_ms":34,   "keeping_up":true
stalled: "feed_index":1,"segments_listed":5,"lag_ms":1240, "keeping_up":false
report : kept_up=false lag_ms_final=1257 feed_updates=1 listed=5 published=5

The badge goes green → amber (lagLabel renders "1.2 s behind"), and the report says "Broadcast fell behind" — while feed_updates, segments_listed and segments_published all still read clean, i.e. the lag is the only thing that catches it. A second assertion in the happy-path test pins the freeze: re-reading the report 300 ms after the run still returns the same lag_ms_final.

cargo fmt/clippy/test -p ant-ffi: 61 tests green, including the real-gateway integration test a_broadcast_is_accepted_end_to_end_by_the_real_gateway, which still reports kept_up. All four PR checks pass.

On the visual layer

antstream-visual run 31202203005 is green and the live surface renders unchanged — but it cannot give acceptance evidence for this one, and I would rather say so than imply otherwise: -antstream-shot-live deliberately broadcasts against a bogus batch id, so /bzz is refused before any playlist lands and the badge never leaves the yellow "Connecting…" state (playlists_published == 0). Reaching green-then-amber on the runner needs a funded batch plus a gateway that accepts /bzz and refuses /soc — which is precisely what the Rust test above stands up. Regression side, post-fix:

live-badge-after-fix

@meinharrd

Copy link
Copy Markdown
Contributor Author

[alan-review R2] Blocking — the capture → publisher hand-off is not order-preserving, but every playlist-correctness property this PR pins rests on push order.

crates/ant-ffi/src/publisher.rs:756 assigns the commit sequence at ant_publisher_push_segment arrival (state.next_seq), and advance() folds outcomes strictly in that order — so "segments commit in capture order" and "media before its init segment is never listed" hold only if the FFI calls arrive in capture order.

On the Swift side nothing guarantees that order:

  • examples/ios-stream/AntStream/LiveBroadcast.swift:109 — each onSegment callback spawns an independent unstructured Task { @MainActor … }. Ordering between separately-created tasks is not FIFO-guaranteed by the Swift runtime.
  • Even where the main-actor hop happens to run FIFO, AntNode.pushSegment (AntNode.swift:650) suspends at an inner Task.detached { … ant_publisher_push_segment … }, releasing the main actor before the FFI call runs. Two segments delivered close together become two detached tasks racing on the global executor — whichever is scheduled first gets the lower seq.

Failure scenario — every writer restart, a first-class feature of this PR (camera flip, orientation change, thermal downshift, interruption recovery): the old writer's final media segment and the new writer's initialization segment are delivered back-to-back (finishWriterstartWriter, tens of ms apart). If the init's FFI call wins the race, advance() commits Init first and then lists the old writer's final segment under the new #EXT-X-MAP — after an orientation/thermal restart that map has different dimensions/parameters, so viewers get an undecodable segment, with the #EXT-X-DISCONTINUITY attached to the wrong entry. The mirror inversion (first new media beating its init) lists the new timeline's first segment under the old writer's map. Either way the invariant the unit tests pin is silently violated, the segment still counts as segments_listed, and kept_up stays true — nothing detects or repairs it.

Same root cause weakens stop()'s final-segment guard (LiveBroadcast.swift:147): capturedSegments is incremented inside the same unordered hop, so snapshot.segmentsPushed < capturedSegments can compare against a stale count, break early, and let ant_publisher_stop cancel before the final push lands (push then returns Closed and the broadcast's last segment is dropped).

Suggested direction: make the hand-off single-file. ant_publisher_push_segment is documented NEVER BLOCKS, so it can be called synchronously in delivery order (capture the handle once at broadcast start and call straight from the delegate callback), or funnel segments through one AsyncStream consumed by a single task that awaits each push before taking the next.

Verification note: no Swift toolchain on this box, so this is confirmed-from-source on both sides: the Rust side's order dependence is direct from push/advance (and its tests, which construct out-of-order done maps keyed by seq — seq is the order), and the Swift side's non-guarantee is the documented semantics of unstructured/detached tasks. The delegate side itself is ordered (the group.wait in finishWriter sequences old-writer callbacks before the restart), so the race window is precisely the Task hops.

@meinharrd

Copy link
Copy Markdown
Contributor Author

[alan-review R2] Minor findings (combined, non-blocking):

  1. initialSegmentStartTime = .zero disagrees with the camera path's startSession time. CaptureEngine.swift:381 pins writer.initialSegmentStartTime = .zero, but on the camera source startSession(atSourceTime:) (CaptureEngine.swift:735) is called with the first sample's capture-clock PTS — seconds-since-boot scale, hours ≫ 0. Apple's fragmented-MP4 authoring contract (WWDC20 "Author fragmented MPEG-4 content", and the preferredOutputSegmentInterval docs) anchors segmentation boundaries at initialSegmentStartTime; a mismatch risks a wrong first-segment duration / immediate segment cut / writer error on exactly the path CI cannot exercise — the test pattern starts its clock at frame 0 (CaptureEngine.swift:639), so .zero happens to match there and antstream-visual's green run is not evidence for the camera path. Uncertain (device-only to verify), flagged so the first hardware run checks the first segment's EXTINF/playability rather than assuming it. Cheap hardening: set initialSegmentStartTime to the same source time passed to startSession, or rebase sample PTS to zero.

  2. Stop-drain bound undercounts by one round. lib.rs (PUBLISHER_STOP_GRACE) and ant.h document ~130 s as "two rounds of the 60 s deadline — the in-flight window, then the backlog behind it", but pump (publisher.rs:1068) pops a segment from pending before acquire_owned, so at cancel time up to max_backlog + 1 segments sit behind a full window: worst case is in-flight (60 s) + ⌈5/4⌉ backlog rounds ≈ 180 s. Consequence is contained — the call still returns at 130 s with an honest report and the loop finishes in the background — but the doc's "a broadcast that has not closed out by then is not going to" can be wrong by a round.

  3. Bookkeeping: R1 minors Format & Lint #1 (AntNode.swift stopPublisher doc still says "~75 s" vs the 130 s bound), feat(gateway): SOC and feed endpoints #2 (lib.rs ant_publisher_stop doc still claims "it returns the same report" for an already-finished broadcast — ant.h dropped that clause, lib.rs kept it), feat(android): add Compose smoke-test app on a JNI surface #4 (channel-manifest retry still inline on the committer, publisher.rs:1251) and Bee compatibility: match /wallet schema and stamp purchase failure behavior #5 (1-second topic derivation granularity) remain open at 1ddea92 — not re-raised as new findings, just noting the fix addressed R1-F1 only.

@meinharrd

Copy link
Copy Markdown
Contributor Author

[alan-verify R2]

R2-F1 — CONFIRMED.

The hand-off is genuinely unordered, and the consequence lands in the playlist.

Why the hop is unordered. LiveBroadcast.onSegment (LiveBroadcast.swift:109) creates an unstructured Task { @MainActor } per segment; that task's body then does await node.pushSegment(segment), which is await withHandle { await Task.detached(...).value } (AntNode.swift:650). Task N suspends at that .value before the FFI call happens, freeing the main actor for task N+1, which spawns its own detached task. So the two ant_publisher_push_segment calls are two concurrent global-executor tasks racing for state — nothing serializes them. Note this also coalesces: two callbacks delivered tens of ms apart while the main thread is busy (a camera flip / rotation is exactly when it is busy re-laying-out the preview) get their detached pushes created microseconds apart, which is the worst case, not the best.

The capture side is fine — finishWriter() blocks on the finishWriting group before startWriter(), so onSegment(old media) really does precede onSegment(new init). The inversion is introduced entirely by the Swift hand-off.

Why it matters. publisher::Run::push assigns seq under the state lock at arrival (publisher.rs:756), and PublisherState::advance binds init_uri to everything committed after it (publisher.rs:498–545). Reproduced with a temporary #[tokio::test] in publisher.rs driving the real start/push/loop against the existing stub gateway, pushing (new init, old media) in the inverted order the race produces (test reverted; tree is clean):

#EXT-X-MAP:URI=".../init-0.mp4"
#EXTINF:2.000,
.../seg-1.m4s
#EXT-X-DISCONTINUITY
#EXT-X-MAP:URI=".../init-2.mp4"     <- new writer's init
#EXTINF:2.000,
.../seg-3.m4s                        <- old writer's tail media, wrong map
#EXTINF:2.000,
.../seg-4.m4s

(seg-N/init-N are named from pending.seq, publisher.rs:1099, so seg-3 is provably the old writer's segment that arrived after the new init.) The run reported listed=3 published=3 failed=0 dropped=0 kept_up=true — exactly the "nothing detects it" the finding claims. After a flip/orientation/thermal restart the two writers' avcC/dimensions differ, so that entry is undecodable under the map it is listed with, and the #EXT-X-DISCONTINUITY sits one entry early.

The existing playlist_lists_segments_in_capture_order_behind_one_map test only pins ordering against upload completion reordering; the seq assignment itself is the unprotected step.

Caveat, for fairness: the finding's second clause — that the same unordered hop makes stop()'s segmentsPushed < capturedSegments wait unreliable — I could not substantiate. That wait compares counts, which are order-insensitive, and capturedSegments is incremented in the main-actor prologue before the detached push, so the final segment's increment is enqueued ahead of the continuation resume from engine.stop. It is a count race only if MainActor job order is violated, which I can't demonstrate. The blocking half is the seq/EXT-X-MAP inversion above; a fix that serialises the pushes (a single ordered channel/queue feeding the FFI, or assigning the sequence number on the capture side and passing it through) closes both readings anyway.

No screenshot evidence: the defect is invisible in the app by construction — every on-screen counter and the keeping_up badge read clean while the playlist is wrong, which is confirmed above by the report line.

The publisher assigns each segment its commit sequence by the order the
push_segment calls arrive, and that sequence is both the playlist order
and the #EXT-X-MAP a media segment is listed under. The capture hand-off
spawned an unstructured Task per segment, each of which then awaited a
detached FFI task, so arrival order was not the writer's order.

At a writer restart (camera flip, orientation, thermal downshift,
interruption recovery) the old writer's final media segment and the new
writer's initialization segment are emitted milliseconds apart; an
inversion lists the old segment under the new map -- undecodable after a
dimension or bitrate change -- with the discontinuity tag on the wrong
entry, while segments_listed and kept_up stay clean.

onSegment now enqueues into an AsyncStream from the writer's delivery
queue (synchronous, FIFO, still never blocking capture) and a single
pump task awaits one push at a time. stop() closes the feed and waits
for the pump to drain rather than polling segmentsPushed against
capturedSegments, which could not converge on a failed push.

Also state the ordering contract on every doc surface of the call
(publisher.rs, lib.rs, ant.h) and pin it in the restart playlist test.
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 7, 2026
@meinharrd

Copy link
Copy Markdown
Contributor Author

[alan-fix R2] — fixed in 1feb015.

R2-F1 — capture→publisher hand-off is now order-preserving (blocking)

Root cause. LiveBroadcast.onSegment spawned an unstructured Task { @MainActor … } per segment, and each of those then awaited a second, detached task inside AntNode.pushSegment. Neither hop is ordered: two segments delivered milliseconds apart could reach ant_publisher_push_segment inverted. publisher.rs assigns the commit sequence strictly by FFI arrival order (LiveRun::pushstate.next_seq), and that sequence is both the playlist order and the #EXT-X-MAP each media segment is listed under.

I reproduced the consequence against the real playlist assembler with a temporary #[test] in publisher.rs (reverted before committing) — arrival order init-0, init-new, seg-old, seg-new renders:

#EXT-X-MAP:URI="/bzz/cc/init-new.mp4"
#EXTINF:2.000,
/bzz/bb/seg-old.m4s          <-- old writer's segment, new writer's map
#EXT-X-DISCONTINUITY
#EXTINF:2.000,
/bzz/dd/seg-new.m4s          <-- tag on the wrong entry

segments_listed, segments_dropped and keeping_up are all clean here — exactly the silent failure the finding describes.

Fix. The hand-off is now a single ordered queue instead of a task per segment:

  • onSegment yields into an unbounded AsyncStream straight from the writer's delivery queue — synchronous and FIFO, so the order is settled at the point capture emits it, and the enqueue still never blocks capture (backpressure stays the node's drop-oldest backlog).
  • One pump task consumes the stream and awaits pushSegment one at a time, so FFI arrival order == writer order by construction.
  • stop() now finish()es the feed after the writer's final segment is delivered and waits for the pump to drain, replacing the segmentsPushed < capturedSegments poll. That poll was the second half of the finding: it could never converge on a failed push (which increments no counter), so it burned its bound and let ant_publisher_stop cancel with the last segment still in flight. The drain is finite — every push returns a disposition, failures included — and is still bounded so a wedged push can't hang the Stop button.

Contract, on every doc surface (per the repo lesson about shared FFI contracts): LiveRun::push, ant_publisher_push_segment in lib.rs, and ant.h now all state that call order is the broadcast order and why. (jni.rs has no publisher binding, so nothing to mirror there.) The in-order restart test a_new_init_segment_starts_a_new_map_and_discontinuity gained a positional assertion pinning that the old writer's last segment stays under the old map.

Verification

  • cargo test -p ant-ffi — 60 + 1 pass; cargo clippy --all-targets clean. CI green (31205254307).
  • antstream-visual 31205254810success, including Build for simulator (the Swift change compiles).
  • Live acceptance on the simulator (-antstream-shot-live: generated pattern → AVAssetWriter .mpeg4AppleHLS → ant_publisher_push_segment): the pipeline runs end to end through the new pump, 89 segments handed over in order, 0 dropped, publisher numbering strictly monotone ("media segment 89"). Uploads still 400 because the sample batch isn't a real postage batch — same wall as before, unchanged from the pre-fix run.

live-ordered-handoff-post-fix

Not covered by evidence: the writer-restart inversion itself can't be shown end-to-end on the runner — the simulator has no camera to flip and the fake batch means no playlist is ever assembled to inspect. The failure mode is proven at the playlist layer (above) and the ordering guarantee is now structural in the hand-off; a restart on hardware stays a manual check.

@meinharrd

Copy link
Copy Markdown
Contributor Author

[alan-review R3] Minor findings (combined, non-blocking). No blocking findings this round: I re-drove the R1/R2 fix areas (live-edge age semantics, the ordered Swift hand-off, the pump/committer drain protocol, the shared upload_error_status, the PushSoc bucket guard) against the real code and they hold — cargo test -p ant-ffi (61 tests incl. the real-gateway integration test), -p ant-gateway, -p ant-p2p all green on 1feb015.

  1. #EXT-X-TARGETDURATION changes across playlist reloads — RFC 8216 §6.2.1 says it MUST NOT. crates/ant-ffi/src/publisher.rs:569-578 (render_playlist) computes target_s as the max duration over the current window (floored at the configured target). A media segment that overruns the 2 s target — which keyframe-aligned cutting produces routinely (AVAssetWriterInput cuts at the next IDR after preferredOutputSegmentInterval) — raises the tag while it's in the window and lowers it again when it slides out. Confirmed empirically with a temporary #[test] against the real render_playlist (reverted): window [2000 ms, 3400 ms] renders #EXT-X-TARGETDURATION:4, and after two more 2 s segments slide the long one out, the same feed's next playlist renders #EXT-X-TARGETDURATION:2. Strict players/validators (Apple's mediastreamvalidator, players that size reload timers and buffers off the tag) treat a changing target duration as a malformed live stream. Cheap fix: make it a run-max (self.target_s = max(self.target_s, …)) so it only ever ratchets up, or pin it at start with headroom (e.g. 2 × segment_ms).

  2. A segment pushed concurrently with cancel() can be silently orphaned while its push call reports "queued". publisher.rs:748-800 (LiveRun::push) checks cancel once at entry, then enqueues into pending; pump (publisher.rs:1064-1100) exits when it observes cancelled && pending.is_empty(). Interleaving: push loads cancel == falsecancel() lands and the pump drains, breaks, sets pump_finishedpush resumes, enqueues, notify_one goes to nobody. The segment sits in pending forever: the committer's exit condition (pump_finished && done.is_empty(), publisher.rs:1179-1185) never looks at pending, so the segment is never published, failed, or dropped — segments_pushed exceeds the sum of the outcome counters and the FFI returned 0 (queued) for a segment that vanished. Not reachable from the shipped Swift app (the R2 pump drains before stop()), but ant_publisher_push_segment/ant_publisher_stop are a public C contract callable from different threads. Cheap fix: have the pump do one final pending sweep after cancel (marking leftovers Dropped), or re-check cancel under the state lock in push and return Closed.

  3. PublisherReport.duration_s / sustained_mbit_s / sustained_chunks_s keep moving after the run finishes — the same "a report read later must describe the broadcast" principle the R1 fix froze lag_ms_final for. publisher.rs:859-895 computes elapsed = self.started_at.elapsed() at read time, so a report taken N seconds after finished shows duration_s inflated by N and throughput deflated by it (bytes ÷ a still-growing elapsed). The happy-path test pins the freeze for lag_ms_final only; re-reading the report 300 ms later already shifts the other three. Harmless on the normal path (ant_publisher_stop reports within one 50 ms poll of finished), but inconsistent with the fix's own doc claim — freezing an ended_at alongside lag_frozen_ms closes it.

  4. Bookkeeping — still open at 1feb015, not re-raised as new: R1 Format & Lint #1 (AntNode.swift:685 stopPublisher doc still says "~75 s" vs the 130 s bound), R1 feat(gateway): SOC and feed endpoints #2 (lib.rs:3249 still claims stop "returns the same report" for an already-finished broadcast; ant.h dropped that clause), R1 feat(android): add Compose smoke-test app on a JNI surface #4 (channel-manifest retry still inline on the committer, publisher.rs:1256), R1 Bee compatibility: match /wallet schema and stamp purchase failure behavior #5 (1-second topic-derivation granularity), R2 Format & Lint #1 (initialSegmentStartTime = .zero vs the camera clock — device-only to verify; note the test-pattern path also stops matching .zero after any writer restart, since testPatternFrame is never reset), R2 feat(gateway): SOC and feed endpoints #2 (stop-drain doc bound undercounts by one backlog round).

@meinharrd meinharrd added alan:clean alan loop finished: no confirmed blocking findings and removed alan:reviewing alan loop currently running on this PR labels Aug 7, 2026
- EXT-X-TARGETDURATION is a run-max that only ratchets up: RFC 8216
  $6.2.1 forbids it changing across reloads, and keyframe-aligned cuts
  routinely overrun the configured target (R3 #1, with a test pinning
  the slide-out case).
- A push racing cancel() can no longer orphan a segment: push refuses
  under the state lock once pump_finished is set, and the pump's final
  sweep (same critical section) drops any segment that slipped in
  before it — every push now ends as published, failed or dropped
  (R3 #2).
- duration_s / sustained_mbit_s / sustained_chunks_s freeze with the
  run via ended_at, the same principle as lag_ms_final; the happy-path
  test now pins all four (R3 #3).
- The channel-manifest retry is spawned instead of awaited on the
  committer, so a hanging POST /feeds cannot stall playlist and feed
  updates for landed segments (R1 #4).
- Derived topics seed from milliseconds plus a process-wide nonce, so
  stop + restart of a channel inside one clock tick cannot overwrite
  the old feed's head at index 0 (R1 #5, with a same-tick test).
initialSegmentStartTime was pinned to .zero while the camera path
starts its session at the first sample's capture-clock PTS (hours since
boot) — and Apple's fMP4 authoring contract anchors segmentation
boundaries at initialSegmentStartTime, so the mismatch risked a wrong
first-segment duration or an immediate cut on exactly the path CI
cannot exercise (review R2 #1). startWriting is now deferred to the
first video sample: beginSession() sets initialSegmentStartTime to that
sample's own timestamp and starts the session at the same instant, on
the camera and test-pattern paths alike — which also covers a restarted
writer, whose first sample is nowhere near time zero (the test-pattern
frame counter is deliberately not reset).
- The stop-drain worst case is three rounds of the 60 s deadline, not
  two: the pump pops a segment before acquiring a window permit, so at
  cancel time up to max_backlog + 1 segments can sit behind a full
  window. The grace stays at 130 s — a drain that slow means every
  upload is timing out and the tail is lost regardless — but lib.rs and
  ant.h now say so instead of claiming nothing can outlive it (R2 #2).
- ant_publisher_stop's docs no longer claim a second call returns the
  same report: that only holds for a broadcast that finished on its
  own; once a stop has returned and released the slot, another call
  fails with "not broadcasting" (R1 #2). lib.rs now matches ant.h.
- AntNode.stopPublisher's comment said ~75 s where the bound is the
  130 s stop grace (R1 #1).
@meinharrd

Copy link
Copy Markdown
Contributor Author

Review minors — all 9 open findings addressed in 5c013ec / ee84069 / 8756c64 (follow-up sweep after the loop closed clean):

finding fix
R3 #1EXT-X-TARGETDURATION changes across reloads (RFC 8216 §6.2.1) run-max that only ratchets up (longest_listed_ms); new test target_duration_never_decreases_when_a_long_segment_slides_out pins the slide-out case
R3 #2 — a push racing cancel() can orphan a segment push refuses under the state lock once pump_finished is set, and the pump's final sweep (same critical section) drops anything that slipped into pending first — every push now ends as published, failed, or dropped
R3 #3duration_s/throughput keep moving after the run frozen via ended_at alongside lag_frozen_ms; happy-path test now pins duration and throughput too
R1 #4 — channel-manifest retry inline on the committer spawned, never awaited on the commit loop (channel_manifest_in_flight still bounds it to one attempt)
R1 #5 — 1-second topic derivation granularity seed is now antstream/<channel>/<unix_ms>/<nonce> with a process-wide counter; test asserts two same-tick derivations differ
R2 #1initialSegmentStartTime = .zero vs the camera clock startWriting deferred to the first video sample: beginSession() sets initialSegmentStartTime to that sample's own timestamp and starts the session at the same instant — camera and test-pattern paths alike, including restarted writers (first hardware run should still eyeball the first segment's EXTINF, per the finding)
R2 #2 — stop-drain doc bound undercounts by one round lib.rs + ant.h now state the honest worst case (three rounds ≈ 180 s, call still returns at the 130 s grace with the loop finishing in the background)
R1 #1stopPublisher doc says ~75 s now ~130 s, matching the stop grace
R1 #2 — stop doc overclaims idempotence lib.rs matches ant.h: same report only for a broadcast that finished on its own; after a returned stop, a second call fails with "not broadcasting"

cargo fmt / clippy --all-targets clean; cargo test -p ant-ffi 62 tests green including the real-gateway integration test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

alan:clean alan loop finished: no confirmed blocking findings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant