Skip to content

fix(api): stop discarding links written with a bare element id - #725

Open
wagenet wants to merge 1 commit into
Eyevinn:mainfrom
wagenet:fix-bare-pad-links
Open

fix(api): stop discarding links written with a bare element id#725
wagenet wants to merge 1 commit into
Eyevinn:mainfrom
wagenet:fix-bare-pad-links

Conversation

@wagenet

@wagenet wagenet commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Problem

POST /api/flows answered 201 Created and stored the flow with "links": [] when the links
used the bare element form that the Link doc comment documents as valid:

{"from": "src0", "to": "caps0"}

is_pad_valid split the pad reference on : and returned false for anything with fewer than
two parts; prepare_flow then dropped those links with flow.links.retain(...). The caller
learned nothing until POST /api/flows/{id}/start failed with a GStreamer "not-linked" error.
POST /api/flows/{id} (update) discarded them the same way.

Decision: support the bare form (option a)

The code was wrong, not the doc:

  • ElementPadRef::from_string already parses a bare id into pad_name: None, and
    to_string_format already round-trips it as "id::".
  • try_link_elements_refs already implements element-level linking (src.link(sink)), and the
    "id::" spelling already passed validation. Only the bare spelling was rejected.
  • Element-level linking is what gst-launch does (videotestsrc ! x264enc): GStreamer picks
    compatible pads, request pads included.

A bare reference to an element is now accepted and stored exactly as sent. A bare reference
to a block cannot be left alone — block expansion resolves external pads by name — so it is
resolved to the block's only output pad (from) or only input pad (to) and stored in the
explicit block_id:pad_name form. A block with several pads on that side is ambiguous and is
rejected with 400 naming the candidates.

No more silent drops

prepare_flow now returns Err with a caller-facing description of every link it cannot use,
and both create_flow and update_flow answer 400 with that detail instead of storing a
trimmed flow. The offending link is named in the message, e.g.

Flow contains link(s) that cannot be resolved
'caps0:src' -> 'ghost:sink': 'ghost:sink' does not name an element or block in this flow

Pruning is kept, but only where it is legitimate

A block's external pads follow its own properties: lower a compositor's num_inputs and
b0:video_in_2 stops existing. The frontend does not prune those links itself, and an exported
flow from before such a change can be imported later, so this drift is not a caller mistake.
That one case — the node exists, is a block, has computed pads, and does not have the named pad
— is still pruned (logged as Pruning stale link ...) and the request succeeds. Everything else
(unknown node, unresolvable bare reference) is a 400. The two paths are distinct enum variants,
EndpointProblem::StaleBlockPad vs Rejected, so they cannot be conflated again.

Other paths checked

  • update_flow: runs the same prepare_flow, so it gets the same fix (covered by tests).
  • gst-launch import (POST /api/gst-launch/parse): always emits id:pad for both ends, so it
    never produced bare links itself. It hands elements and links to the frontend, which creates
    the flow through create_flow — covered by the fix.
  • Flow load from storage and start_flow recompute external pads but never prune links, so
    nothing was being discarded there.
  • Not fixed here: the frontend's parse_pad_ref returns None for a bare reference, so such a
    link is not drawn in the graph editor. It is preserved across load/save (links are stored
    verbatim), so this is a rendering gap, not data loss.

Also fixed

try_link_elements_refs ignored the destination pad when the source was element-level:
{"from": "src0", "to": "mux0:sink_0"} fell into the src.link(sink) branch and let GStreamer
pick both ends. Now that the bare source form is reachable through the API, that combination
links with link_pads(None, sink, Some(pad)).

Tests

Ran locally on this machine, against this branch as rebased onto main (1c06c37):

  • cargo test --test bare_link_pads_test - new file, 7 tests, all pass. They call
    create_flow / update_flow directly rather than reimplementing validation inline.
  • Revert check: temporarily restoring the old "a reference without a colon is invalid, drop
    the link" behaviour in resolve_link_endpoint turns 5 of the 7 red
    (create_flow_keeps_links_written_with_bare_element_ids,
    update_flow_keeps_links_written_with_bare_element_ids,
    create_flow_resolves_a_bare_block_reference_to_its_only_pad,
    create_flow_rejects_an_ambiguous_bare_block_reference,
    update_flow_rejects_a_link_to_an_unknown_node). The temporary patch was reverted afterwards
    and is not part of this branch.
  • cargo test --lib api::flows - 11 tests pass. The old is_pad_valid unit tests were rewritten
    against resolve_link_endpoint, keeping the gst-launch-imported-id cases they were written for
    and adding the bare-form, ambiguity and staleness cases.
  • cargo test --test openapi_test - failed first, diff reviewed, openapi.json updated
    intentionally (create_flow now documents its 400; the Link field descriptions changed),
    then re-run and passing.
  • cargo fmt --all and the repo's pre-commit clippy run pass.

All three suites were re-run after the rebase, so the numbers above are from the exact tree
proposed here.

Not run: the rest of the backend test suite and the pipeline/GStreamer integration tests. This
change does not touch pipeline construction apart from the link_pads branch above, and the
machine was under disk pressure.

🤖 Generated with Claude Code

POST /api/flows answered 201 Created and stored "links": [] when the links
used the bare form the Link doc documents as valid ({"from": "src0", "to":
"caps0"}). is_pad_valid rejected any pad reference without a colon and
prepare_flow then dropped those links, so the caller only found out when
starting the flow failed with a GStreamer "not-linked" error. update_flow
discarded them the same way.

The code was wrong, not the doc: ElementPadRef::from_string already parses a
bare id, the "id::" spelling already passed validation, and the pipeline
already implements element-level linking. A bare element reference is now
accepted and stored as sent; a bare block reference resolves to the block's
only output ("from") or input ("to") pad, since block expansion resolves
external pads by name.

prepare_flow now reports the links it cannot use, and create_flow and
update_flow answer 400 naming the offending link instead of storing a trimmed
flow. Pruning survives for the one case it is meant for: a pad the block's own
properties no longer produce, which is drift rather than a caller mistake.

Also honour the destination pad when the source is element-level - that
combination used to fall into src.link(sink) and let GStreamer pick both ends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wagenet
wagenet marked this pull request as draft August 30, 2026 04:23

@wagenet wagenet left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There's more code here that I have limited context around, but it appears to make sense to me.

@wagenet
wagenet marked this pull request as ready for review August 30, 2026 04:47

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: Approve — correct mechanism, replaces a silent-data-loss failure mode with a rejecting one, and the new tests genuinely exercise create_flow/update_flow rather than reimplementing them.

Claims

Claim Verdict Evidence
resolve_link_endpoint is exhaustive: bare element (kept), bare block (resolved/rejected-if-ambiguous), named pad on computed block (checked), named pad on static block (trusted) CONFIRMED backend/src/api/flows.rs:96-181; four branches on (node_id.is_empty, element_ids.contains, block_pads.get, pad_name) cover every case
New linking.rs branch is reached exactly for (element-level from, named-pad to), doesn't shadow the other three CONFIRMED backend/src/gst/pipeline/linking.rs:46,79,279: if let (Some,None), else if let (Some,Some), else if let Some(to_pad) — mutually exclusive, the new arm is the only remaining combination
New tests call the real endpoint handlers, not a reimplementation CONFIRMED backend/tests/bare_link_pads_test.rs:83,122,153,186,225,257,295 all call create_flow(State(...), JsonBody(...)) / update_flow(...) directly
Frontend's parse_pad_ref gap is rendering-only; links preserved verbatim across load/save CONFIRMED frontend/src/graph/mod.rs:271-278 returns None for a bare ref, used only for pad discovery (data.rs:463-496); GraphState.links is set verbatim from the loaded Vec<Link> at mod.rs:257, not filtered by parse_pad_ref
New tests actually executed in this repo's CI, not just locally CONFIRMED .github/workflows/ci.yml:128-129 cargo test --package strom ... runs in Check (Linux), green at 00ec80fe; API Contract Check (openapi snapshot) also green

Diagnosis — root cause matches the PR's account: is_pad_valid treated "fewer than two colon-separated parts" as invalid regardless of node kind, so prepare_flow's retain() silently dropped the link. The fix surfaces a 400 instead of storing a flow with links missing — closes the reported symptom and the whole class (any bare-element or ambiguous-bare-block link), not just the one form. EndpointProblem::StaleBlockPad keeps the one legitimate auto-prune case (a block property lowered, e.g. num_inputs) distinct from a caller mistake, so loading an older flow doesn't turn into a hard 400.

Radius — SHARED, explicitly argued: types/src/element.rs's Link struct is structurally unchanged (doc comments only), no wire-format break — confirmed by the openapi.json diff being additive only. The behavior change (400 vs. silent drop) is contract-visible on POST /api/flows and POST /api/flows/{id}, and is the intended fix. update_flow's #[utoipa::path] already declared a generic 400 pre-existing (flows.rs:577), so the schema isn't stale, though its text wasn't synced to create_flow's new wording — cosmetic.

Tests & CIBuild (Linux x86_64/ARM64), Check (Linux), Check & Build (WASM), API Contract Check all pass at 00ec80fe. Build (macOS)/Build (Windows) skip per the usual gate, but this diff adds no platform-cfg code, so that skip isn't a coverage gap here.

Confidence: HIGH

@srperens srperens mentioned this pull request Aug 31, 2026
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.

2 participants