Summary
outgoing.server is live-settable. Applying it rewrites transport state on the HTTP thread while the encode thread is part-way through sending an access unit's datagrams. All three backends guard this with the same seqlock + per-frame snapshot — the encode thread copies the transport fields once per frame so every datagram of one frame goes to the same place.
Two gaps in that protection remain open. This is not a regression: the pattern in src/cv610_runtime.c is copied from src/star6e_output.c and src/maruko_output.c, so all three backends share it. CV610 simply made it reachable on a third platform, which is how it surfaced.
Filed to keep it visible rather than to block anything — every outcome below is a dropped or split access unit, not corruption.
Gap A — the snapshot does not cover the destination in the shipped default
connectedUdp: true is the default in all three configs. In that mode output_socket_send_parts() sets msg_name = NULL and the destination lives in the kernel socket, not in the snapshotted fields. A retarget changes it with connect() on the live fd.
So for a udp://a → udp://b retarget the snapshot protects nothing it was built to protect: one access unit can split across two receivers. Bounded — the bootstrap IDR that follows re-anchors the stream — and the misleading comments that claimed otherwise are corrected in #120.
Gap B — a live transport-type change closes and reopens the fd
output_socket_configure() closes and reopens when the transport type changes (udp:// ↔ unix://), while the encode thread still holds the old fd number in its snapshot. The reopen usually reclaims the same number, so a remaining sendmsg() for that frame can hit a socket of the wrong family, or EBADF in the gap. The send loop breaks on the first short send, so damage is bounded to a dropped access unit plus an output_drops increment.
Already fixed in #120
A third gap in the same area is closed: output_socket_configure() also closes the fd when destination fill fails with the type unchanged — reachable by setting a hostname URI, since nothing resolves names (inet_pton only). On a craft running a ring transport this was worse than a dropped frame: the value was committed and the daemon respawned into it, failed to bring output up, and stayed down until the config was repaired over ssh. The destination is now validated above both branches, before anything mutates.
Why the remaining two are not fixed here
The clean fix for Gap B is to route a live transport-type change to the restart class, reusing the pattern the ring branch already uses (commit the value, request a reinit, report reinit_pending). It is also the honest classification, since the fd genuinely cannot be swapped under a live producer.
Doing that on one backend changes observable API behaviour there and not on the other two: a client switching transport type would get reinit_pending: true and a respawn on one craft and a live change on another. That is a product decision about fleet consistency, not a bug fix, which is why it is an issue rather than a patch.
Options
- Fix all three backends together — consistent, and the right end state. Largest change; touches the SigmaStar output paths.
- Fix one backend now, accept the divergence — smallest change, but leaves the fleet inconsistent in behaviour on top of being inconsistent in correctness.
- Leave as documented — current state. The gaps and what the seqlock does not cover are written into the
transport_gen note.
Gap A is inherent to a live retarget and is probably not worth code in any option; it needs the destination to move into the snapshot, or connected UDP to be dropped for retargetable outputs.
Notes
An adversarial review of this area specifically tried and could not demonstrate a send landing in an unrelated subsystem's descriptor — the structural risk exists but has no live trigger, because nothing else allocates descriptors per-iteration while the HTTP thread is blocked in its own dispatch. Worth not overstating the severity when this is picked up.
The audio side channel was checked and is not affected: its apply path returns early when no socket is open and always derives a UDP type, so neither close path is reachable there.
Summary
outgoing.serveris live-settable. Applying it rewrites transport state on the HTTP thread while the encode thread is part-way through sending an access unit's datagrams. All three backends guard this with the same seqlock + per-frame snapshot — the encode thread copies the transport fields once per frame so every datagram of one frame goes to the same place.Two gaps in that protection remain open. This is not a regression: the pattern in
src/cv610_runtime.cis copied fromsrc/star6e_output.candsrc/maruko_output.c, so all three backends share it. CV610 simply made it reachable on a third platform, which is how it surfaced.Filed to keep it visible rather than to block anything — every outcome below is a dropped or split access unit, not corruption.
Gap A — the snapshot does not cover the destination in the shipped default
connectedUdp: trueis the default in all three configs. In that modeoutput_socket_send_parts()setsmsg_name = NULLand the destination lives in the kernel socket, not in the snapshotted fields. A retarget changes it withconnect()on the live fd.So for a
udp://a → udp://bretarget the snapshot protects nothing it was built to protect: one access unit can split across two receivers. Bounded — the bootstrap IDR that follows re-anchors the stream — and the misleading comments that claimed otherwise are corrected in #120.Gap B — a live transport-type change closes and reopens the fd
output_socket_configure()closes and reopens when the transport type changes (udp://↔unix://), while the encode thread still holds the old fd number in its snapshot. The reopen usually reclaims the same number, so a remainingsendmsg()for that frame can hit a socket of the wrong family, orEBADFin the gap. The send loop breaks on the first short send, so damage is bounded to a dropped access unit plus anoutput_dropsincrement.Already fixed in #120
A third gap in the same area is closed:
output_socket_configure()also closes the fd when destination fill fails with the type unchanged — reachable by setting a hostname URI, since nothing resolves names (inet_ptononly). On a craft running a ring transport this was worse than a dropped frame: the value was committed and the daemon respawned into it, failed to bring output up, and stayed down until the config was repaired over ssh. The destination is now validated above both branches, before anything mutates.Why the remaining two are not fixed here
The clean fix for Gap B is to route a live transport-type change to the restart class, reusing the pattern the ring branch already uses (commit the value, request a reinit, report
reinit_pending). It is also the honest classification, since the fd genuinely cannot be swapped under a live producer.Doing that on one backend changes observable API behaviour there and not on the other two: a client switching transport type would get
reinit_pending: trueand a respawn on one craft and a live change on another. That is a product decision about fleet consistency, not a bug fix, which is why it is an issue rather than a patch.Options
transport_gennote.Gap A is inherent to a live retarget and is probably not worth code in any option; it needs the destination to move into the snapshot, or connected UDP to be dropped for retargetable outputs.
Notes
An adversarial review of this area specifically tried and could not demonstrate a send landing in an unrelated subsystem's descriptor — the structural risk exists but has no live trigger, because nothing else allocates descriptors per-iteration while the HTTP thread is blocked in its own dispatch. Worth not overstating the severity when this is picked up.
The audio side channel was checked and is not affected: its apply path returns early when no socket is open and always derives a UDP type, so neither close path is reachable there.