Skip to content

Live output retarget: stop a bad destination taking the socket, or the craft, down - #122

Merged
snokvist merged 4 commits into
OpenIPC:masterfrom
snokvist:master
Sep 6, 2026
Merged

Live output retarget: stop a bad destination taking the socket, or the craft, down#122
snokvist merged 4 commits into
OpenIPC:masterfrom
snokvist:master

Conversation

@snokvist

@snokvist snokvist commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Three commits, follow-up to #120, closing the shared half of #121 plus a worse failure found while testing it. All device-verified on CV610, Star6E and Maruko.

1. output_socket_configure() must not destroy a working socket

The destination fill ran after the socket open/reuse and closed the fd when it failed — including on the reuse path, where the transport type was unchanged and the socket was working. Nothing here resolves names (the fill is inet_pton only), so outgoing.server=udp://somehost:5600 took a live output down on every backend that calls this.

Filling a local and committing only on success also makes the call all-or-nothing: no path now leaves the destination updated but the socket closed, or the reverse.

Arm Maruko Star6E
good retarget udp→udp ok, packetsSent 10372 → 11995 ok, 15170 → 16866
bad hostname URI refused, config unchanged, packets 13659 → 14754 refused, config unchanged, 18504 → 19585
retarget again afterwards ok ok

The third arm shows the socket is not merely still open after a refusal, but still usable.

2. A shared helper for the question, instead of open-coding it

output_socket_destination_is_usable() replaces a throwaway sockaddr_storage declared purely to ask a yes/no question. Ring URIs answer 0 — they carry no sockaddr, and their validity belongs to whoever creates the ring. It exists for callers that commit or respawn on a URI without reaching configure(), which is the class of bug in (3).

3. A bad outgoing.server must not brick the craft at boot

Measured on all three: one unusable URI in the config brought the whole pipeline up — sensor, ISP, VPSS, encoder, JPEG, sidecar, even the HTTP server on Maruko — then tore it all down and exited. No video and no API, so fixing a one-line config error needed ssh, which a craft in the field does not have.

The output now comes up inert: loud ERROR, socket unopened, daemon runs, API reachable. Recovery verified end to end, no restart:

Backend Inert After setting a good destination
CV610 active:false active:true, packetsSent 4892 → 8521
Star6E active:false active:true, packetsSent 2273 → 3894
Maruko active:false active:true, packetsSent 7092 → 12427

active:false matters as much as the recovery: the inert state is reported honestly rather than as a live output silently dropping every frame.

A live retarget still refuses a bad URI — there the craft has a working output to lose. At boot it has none either way.

The policy sits in each backend's startup orchestration, not in the output modules: star6e_output_init() and maruko_output_init() still return -1 for a URI they cannot use, which is their contract and which an existing test already pinned. Maruko needed one extra change that only the bench found — maruko_pipeline_run() refused to start without an output, so the daemon exited anyway despite the rest working.

Still open in #121

Gap A (connectedUdp puts the destination in the kernel socket, outside the seqlock snapshot) and Gap B (a live transport-type change closes and reopens the fd). Both are bounded to a single dropped or split access unit during an operator-initiated retarget. Gap B has a clean fix, but it changes observable API behaviour, so doing it on one backend diverges it from the other two — that is the decision the issue holds.

Validation

  • make build clean on all three backends; make verify; make test-werror 0 failures
  • New host coverage: test_output_socket_configure_is_all_or_nothing — fresh open, same-type reuse keeping the fd, close+reopen type change, destination untouched on refusal, plus the helper. Mutation-checked: the old ordering fails exactly three arms

🤖 Generated with Claude Code

https://claude.ai/code/session_01GwG8Jmv3daUQgZhMYyz3wB

snokvist and others added 3 commits September 6, 2026 07:46
The destination fill ran AFTER the open/reuse block and closed the fd when
it failed -- including on the REUSE path, where the transport type was
unchanged and the socket was working.  Nothing here resolves names (the
fill is inet_pton only), so `outgoing.server=udp://somehost:5600` took a
live output down on EVERY backend that calls this.

#280 guarded the CV610 caller.  This fixes the function, which is the
shared one, so Star6E and Maruko get it too -- and the guard in the CV610
caller stays, because it also covers that backend's ring branch, which
never reaches this function at all.

Filling a local and committing only on success also makes the whole call
all-or-nothing: no path now leaves the destination updated but the socket
closed, or the reverse.

Device-verified on BOTH SigmaStar backends -- each temporarily moved onto
udp:// for the run, since a craft on frame-shm:// refuses a live retarget
before this code is reached, then restored:

  Maruko (.233)          Star6E (.232)
  good retarget          ok, packetsSent 10372 -> 11995    ok, 15170 -> 16866
  bad hostname URI       REFUSED, config unchanged,        REFUSED, config
                         packets 13659 -> 14754            unchanged, 18504
                                                           -> 19585
  retarget again after   ok                                ok

The third arm is the one worth having: it shows the socket is not merely
still open after a refusal but still usable.

Host coverage pins the rest of the contract -- fresh open, same-type reuse
keeping the fd, the close+reopen type change, and the destination being
left untouched on refusal.  Mutation-checked: restoring the old ordering
fails cfg_bad_uri_kept_handle, cfg_bad_uri_fd_still_open and
cfg_bad_uri_dst_untouched, and nothing else.


Claude-Session: https://claude.ai/code/session_01GwG8Jmv3daUQgZhMYyz3wB

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng it (#283)

The CV610 retarget guard declared a throwaway sockaddr_storage and a
socklen_t purely to ask a yes/no question, and threw both away.  That is
the only place the idiom appears, and it would have to be copied verbatim
by any future caller that commits or respawns on a URI without going
through output_socket_configure().

output_socket_destination_is_usable() gives it a name and a home next to
the fill it wraps.  Ring URIs answer 0 -- they carry no sockaddr, and
their validity belongs to whoever creates the ring.

No behaviour change; this is the same call the guard already made.
Device-verified on the CV610 bench anyway, because the call site moved:

  bad hostname URI   refused, config still frame-shm://venc_frame,
                     frames 3523 -> 3833, pid 1054 unchanged (no respawn)
  good ring URI      still accepted -- the helper rejects ring types, so
                     this arm is what shows the caller's type check still
                     lets them through

Host coverage: good udp, good unix, bad host, NULL, and a ring URI.


Claude-Session: https://claude.ai/code/session_01GwG8Jmv3daUQgZhMYyz3wB

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on all three benches: one unusable `outgoing.server` in the config
brought the whole pipeline up -- sensor, ISP, VPSS, encoder, JPEG, sidecar,
even the HTTP server on Maruko -- and then tore all of it down and exited.
No video AND no API, so correcting a one-line config error needed ssh,
which a craft in the field does not have.

The API can no longer persist such a URI (#280, #282), but a config
written by an older build, a hand edit or a restore still can, and the
failure is total.

Now the output comes up inert instead: loud ERROR naming the URI, socket
left unopened, daemon runs, API reachable.  `outgoing.server` is
live-settable, so the operator fixes it remotely -- verified end to end on
all three, from the inert state to packets flowing, with no restart:

  cv610   active:false -> set udp:// -> active:true, packetsSent 4892 -> 8521
  star6e  active:false -> set udp:// -> active:true, packetsSent 2273 -> 3894
  maruko  active:false -> set udp:// -> active:true, packetsSent 7092 -> 12427

`active:false` matters as much as the recovery: the inert state is
reported honestly rather than as a live output with silent drops.

The asymmetry with the live retarget is deliberate.  A retarget still
REFUSES a bad URI, because there the craft has a working output to lose;
at boot it has none either way.

Placement: the policy sits in each backend's startup orchestration, not in
the output modules.  star6e_output_init() and maruko_output_init() still
return -1 for a URI they cannot use -- that is their contract, and a test
already pinned it.  The callers tear the output down (which leaves
socket_handle -1 and the destination zeroed) and carry on.  Every send path
already refuses a negative handle, so frames are counted as drops.

Maruko needed one more change, and only the bench found it:
maruko_pipeline_run() refused to start at all without an output, so the
pipeline came up, the API came up, and then the run loop returned -1 and
the daemon exited anyway.  Star6E and CV610 have no such precondition.


Claude-Session: https://claude.ai/code/session_01GwG8Jmv3daUQgZhMYyz3wB

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Protect live output and keep pipelines reachable on invalid destinations

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Validates destinations before mutation, preserving live output after rejected retargets.
• Starts all backends with inert output when configured destinations are unusable.
• Adds regression coverage for atomic socket configuration and destination usability.
Diagram

graph TD
  BOOT["Backend Startup"] --> CHECK["Validate Destination"] --> USABLE{"Destination usable?"}
  USABLE -->|Yes| ACTIVE["Active Output"] --> PIPE["Running Pipeline"]
  USABLE -->|No at startup| INERT["Inert Output"] --> PIPE
  API["Retarget API"] --> CHECK
  USABLE -->|No during retarget| ACTIVE
Loading
High-Level Assessment

The current approach is appropriate: shared socket configuration validates before mutation, while backend startup orchestration decides whether failure is fatal. Centralizing startup tolerance inside output modules was reasonably avoided because it would weaken their existing failure contracts and could hide errors from live-retarget callers.

Files changed (6) +237 / -26

Bug fix (5) +140 / -26
output_socket.hExpose side-effect-free destination usability check +13/-0

Expose side-effect-free destination usability check

• Declares a shared helper for checking whether parsed UDP or Unix destinations can produce usable socket addresses. Documents that ring URI validation remains with ring creation code.

include/output_socket.h

cv610_runtime.cPreserve CV610 output and allow inert startup recovery +34/-13

Preserve CV610 output and allow inert startup recovery

• Replaces open-coded destination probing with the shared usability helper. Invalid startup destinations now leave output inert without aborting the daemon, and the applied-server marker is cleared so API recovery can proceed.

src/cv610_runtime.c

maruko_pipeline.cRun Maruko pipeline safely without initialized output +41/-4

Run Maruko pipeline safely without initialized output

• Treats socket output initialization failure as non-fatal after tearing down partial state. Removes the pipeline precondition requiring an active output, allowing the API-accessible daemon to run and recover through live configuration.

src/maruko_pipeline.c

output_socket.cValidate destinations before mutating socket state +33/-4

Validate destinations before mutating socket state

• Adds the destination usability helper and stages socket destination data locally before opening, closing, or reusing descriptors. Invalid destinations now leave the existing descriptor and destination unchanged.

src/output_socket.c

star6e_pipeline.cKeep Star6E running after output initialization failure +19/-5

Keep Star6E running after output initialization failure

• Makes socket output startup failure non-fatal while explicitly tearing down output state. The remaining pipeline stays operational so outgoing.server can be corrected through the API.

src/star6e_pipeline.c

Tests (1) +97 / -0
test_star6e_output.cCover atomic socket configuration and usability checks +97/-0

Cover atomic socket configuration and usability checks

• Adds shared regression coverage for fresh opens, same-transport descriptor reuse, transport changes, rejected destinations, and untouched destination state. Also verifies helper behavior for valid, invalid, null, and ring destinations.

tests/test_star6e_output.c

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 6, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (2) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Type retarget reopens live descriptor 📎 Requirement gap ☼ Reliability
Description
The new test explicitly preserves in-place close-and-reopen behavior for UDP↔Unix retargets.
Producers can consequently send through a closed or newly reused descriptor while completing an
in-progress frame.
Code

tests/test_star6e_output.c[R2063-2065]

+	/* Type change still closes and reopens. */
+	CHECK("cfg_type_change", output_socket_configure(&handle, &dst, &dst_len,
+		&transport, &unix_uri, 1, 0, &connected) == 0);
Evidence
Rule 1 prohibits replacing the active descriptor during a live transport-type change. The added test
explicitly requires that type changes continue through output_socket_configure(), whose mismatch
branch closes and opens the shared descriptor, while producer paths snapshot and subsequently use
that descriptor without holding the writer lock.

Handle Live Transport-Type Changes Without Swapping a Producer's File Descriptor
tests/test_star6e_output.c[2063-2067]
src/output_socket.c[225-231]
src/cv610_runtime.c[1464-1513]
src/star6e_output.c[731-779]
src/maruko_output.c[405-449]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Live UDP↔Unix retargets close and reopen a descriptor that producer threads may still be using.
## Issue Context
Commit the requested configuration and report reinitialization as pending, but defer descriptor replacement to the backend restart/reinitialization path. Apply the same behavior across all three backends.
## Fix Focus Areas
- src/output_socket.c[225-253]
- src/cv610_runtime.c[1270-1290]
- src/star6e_output.c[1163-1199]
- src/maruko_output.c[225-263]
- tests/test_star6e_output.c[2063-2067]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Connected UDP can split frames 📎 Requirement gap ☼ Reliability
Description
The retarget path commits a new destination and then calls connect() on the existing connected-UDP
socket without synchronizing with an access-unit send. Datagrams from one access unit can therefore
be divided between the old and new receivers.
Code

src/output_socket.c[R233-234]

+	*dst = new_dst;
+	*dst_len = new_len;
Evidence
Rule 3 requires access-unit destination consistency. Same-type retargeting reuses the descriptor and
invokes connect() on it, while connected sends set msg_name to NULL and therefore use mutable
kernel socket state rather than the snapshotted userspace destination.

Prevent Connected-UDP Retargets From Splitting an Access Unit
src/output_socket.c[225-253]
src/output_socket.c[293-300]
src/cv610_runtime.c[1464-1513]
src/star6e_output.c[731-871]
src/maruko_output.c[276-449]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Connected-UDP retargeting changes the kernel destination while an access unit may still be in progress.
## Issue Context
Ensure every datagram of an access unit uses one destination, either by moving destination selection into the per-frame snapshot or by avoiding connected UDP for retargetable outputs.
## Fix Focus Areas
- src/output_socket.c[233-253]
- src/output_socket.c[293-300]
- src/cv610_runtime.c[1464-1513]
- src/star6e_output.c[731-871]
- src/maruko_output.c[276-449]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Startup recovery contract undocumented ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
Invalid startup destinations now leave the HTTP API running with an inactive output that can be
recovered through a live configuration update. This observable API behavior is not documented in
HTTP_API_CONTRACT.md.
Code

src/cv610_runtime.c[R2117-2120]

+		fprintf(stderr, "ERROR: outgoing.server %s could not be brought up; "
+			"starting with NO video output so the craft stays reachable -- "
+			"set outgoing.server over the API to recover\n",
+			ctx->config.outgoing.server);
Evidence
Rule 27 requires every HTTP API behavior change to be reflected in the canonical contract. The
changed startup paths now return success and advertise API-based recovery, while the contract only
defines the generic inactive status shape and does not describe this startup/recovery guarantee.

AGENTS.md: Document HTTP API Behavior Changes: AGENTS.md: Document HTTP API Behavior Changes: AGENTS.md: Document HTTP API Behavior Changes: AGENTS.md: Document HTTP API Behavior Changes
src/cv610_runtime.c[2112-2126]
src/star6e_pipeline.c[2259-2280]
src/maruko_pipeline.c[2863-2886]
documentation/HTTP_API_CONTRACT.md[3-18]
documentation/HTTP_API_CONTRACT.md[30-45]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The HTTP contract does not describe the new non-fatal output startup failure or live recovery behavior.
## Issue Context
Document the inactive transport status, retained configured server, and recovery semantics consistently for CV610, Star6E, and Maruko. Update the contract version if required by the repository's contract-version policy.
## Fix Focus Areas
- documentation/HTTP_API_CONTRACT.md[1-45]
- documentation/HTTP_API_CONTRACT.md[1507-1511]
- documentation/HTTP_API_CONTRACT.md[1575-1578]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View action required (1)
4. Release metadata was not updated ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
This integration-ready behavior change leaves VERSION at 0.81.0 and has no matching new
HISTORY.md entry. The delivered fix therefore lacks the unique semantic version and history entry
required by the checklist.
Code

src/star6e_pipeline.c[R2277-2279]

+		fprintf(stderr, "ERROR: outgoing.server could not be brought up; "
+			"starting with NO video output so the craft stays reachable -- "
+			"set outgoing.server over the API to recover\n");
Evidence
Rule 28 requires exactly one semantic-version bump and one matching history entry for an integration
change. The branch still reports 0.81.0, whose existing history entry predates this PR and
describes unrelated PQTools functionality.

AGENTS.md: Maintain Matching Semantic Version and Changelog Updates: AGENTS.md: Maintain Matching Semantic Version and Changelog Updates: AGENTS.md: Maintain Matching Semantic Version and Changelog Updates: AGENTS.md: Maintain Matching Semantic Version and Changelog Updates
VERSION[1-1]
HISTORY.md[1-10]
src/star6e_pipeline.c[2277-2279]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR changes delivered runtime behavior without a corresponding semantic version bump and matching changelog entry.
## Issue Context
Choose the next appropriate semantic version, update `VERSION` exactly once, and add one matching dated `HISTORY.md` entry summarizing the output-retarget and startup-recovery changes.
## Fix Focus Areas
- VERSION[1-1]
- HISTORY.md[1-10]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Recovery drops Unix stall policy ✓ Resolved 🐞 Bug ≡ Correctness
Description
After a Maruko socket startup failure, the new nonfatal branch calls maruko_output_teardown(),
which clears allow_unix_encoder_stall; a later API recovery uses that cleared field. Recovering to
a Unix destination therefore silently disables the configured encoder-stall compatibility behavior.
Code

src/maruko_pipeline.c[2882]

+			maruko_output_teardown(&ctx->output);
Evidence
Maruko initialization stores the configured stall option, but the new continuation path invokes
teardown and teardown resets it to zero. The API recovery function passes that zeroed member into
socket configuration, so the recovered Unix socket cannot honor the original setting.

src/maruko_output.c[27-63]
src/maruko_pipeline.c[2863-2885]
src/maruko_output.c[225-263]
src/maruko_output.c[535-559]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Maruko's new inert-startup branch tears down the failed output, clearing the Unix encoder-stall option required by live recovery. Preserve this configuration field while resetting only active transport resources.
## Issue Context
`maruko_output_apply_server()` passes the option stored in `MarukoOutput` to `output_socket_configure()`. Add a failed-startup recovery test that verifies a Unix destination retains the configured stall policy.
## Fix Focus Areas
- src/maruko_pipeline.c[2863-2885]
- src/maruko_output.c[27-63]
- src/maruko_output.c[225-263]
- src/maruko_output.c[535-559]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +2063 to +2065
/* Type change still closes and reopens. */
CHECK("cfg_type_change", output_socket_configure(&handle, &dst, &dst_len,
&transport, &unix_uri, 1, 0, &connected) == 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Type retarget reopens live descriptor 📎 Requirement gap ☼ Reliability

The new test explicitly preserves in-place close-and-reopen behavior for UDP↔Unix retargets.
Producers can consequently send through a closed or newly reused descriptor while completing an
in-progress frame.
Agent Prompt
## Issue description
Live UDP↔Unix retargets close and reopen a descriptor that producer threads may still be using.

## Issue Context
Commit the requested configuration and report reinitialization as pending, but defer descriptor replacement to the backend restart/reinitialization path. Apply the same behavior across all three backends.

## Fix Focus Areas
- src/output_socket.c[225-253]
- src/cv610_runtime.c[1270-1290]
- src/star6e_output.c[1163-1199]
- src/maruko_output.c[225-263]
- tests/test_star6e_output.c[2063-2067]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/output_socket.c
Comment on lines +233 to +234
*dst = new_dst;
*dst_len = new_len;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Connected udp can split frames 📎 Requirement gap ☼ Reliability

The retarget path commits a new destination and then calls connect() on the existing connected-UDP
socket without synchronizing with an access-unit send. Datagrams from one access unit can therefore
be divided between the old and new receivers.
Agent Prompt
## Issue description
Connected-UDP retargeting changes the kernel destination while an access unit may still be in progress.

## Issue Context
Ensure every datagram of an access unit uses one destination, either by moving destination selection into the per-frame snapshot or by avoiding connected UDP for retargetable outputs.

## Fix Focus Areas
- src/output_socket.c[233-253]
- src/output_socket.c[293-300]
- src/cv610_runtime.c[1464-1513]
- src/star6e_output.c[731-871]
- src/maruko_output.c[276-449]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/cv610_runtime.c
Comment thread src/star6e_pipeline.c
Comment thread src/maruko_pipeline.c
The review caught a real one in the boot-path change, and it is worse on
Star6E than reported.

Both teardowns reset transport POLICY along with transport state -- Maruko
clears allow_unix_encoder_stall, Star6E clears that AND
requested_connected_udp -- and the live retarget reads those fields back
out of the output.  So a craft that started inert and was then recovered
by the operator would silently come up with a DIFFERENT policy than the
config asks for: unconnected UDP on Star6E, and no Unix stall allowance on
either.  Nothing would have reported it.  Both are now re-seeded from the
same source init reads.

Also from the review: the new startup behaviour was undocumented, and the
release metadata was not bumped.  VERSION 0.82.0, HISTORY entry, and the
contract now describes what a craft does with a destination it cannot
bring up -- non-fatal at boot with the output inert and active:false,
refused on a live change, with the asymmetry explained.  contract_version
stays 0.29.0: no field, endpoint or payload changed, only behaviour.

Not taken, and already tracked in OpenIPC#121: the type-retarget
descriptor reopen and the connected-UDP frame split.  Both are bounded to
one dropped or split access unit during an operator-initiated retarget,
and the fix for the first changes observable API behaviour, so doing it on
one backend diverges it from the other two.


Claude-Session: https://claude.ai/code/session_01GwG8Jmv3daUQgZhMYyz3wB

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@snokvist
snokvist merged commit 2f2a394 into OpenIPC:master Sep 6, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant