Severity
P2 / Major — a validation bypass: a pipeline containing a genuine (non-bidirectional) circular dependency can pass compile() and reach the engine, where Reliable backpressure on the cycle can deadlock the pipeline instead of producing a clear authoring error. Trigger is topology/ordering-dependent (lower confidence on severity — could be argued P3), but it is a real correctness hole.
Environment
- Build:
main @ 2f77388, Linux x86_64
- Component:
crates/api/src/yaml/compiler.rs (detect_cycles)
Summary
Cycle detection deliberately exempts cycles that involve a bidirectional node (transport::moq::peer). But the DFS returns on the first cycle it finds, and the top-level loop merely continues past an exempt cycle without re-exploring the edges it abandoned. Nodes on the exempt path are already in visited, so a second, genuine cycle reachable only via those unexplored edges is treated as a cross-edge and never detected. Net: a real circular dependency slips through whenever a bidirectional node is discovered adjacent to it first.
Steps to reproduce
Compile this DAG via streamkit_api::yaml::{parse_yaml, compile} (names chosen so the bidirectional branch is visited first):
mode: dynamic
nodes:
aaa:
kind: test_node
needs: [bbb, ccc]
bbb:
kind: transport::moq::peer
needs: aaa
ccc:
kind: test_node
needs: aaa
Here aaa <-> ccc (both non-bidirectional) is a genuine cycle; bbb <-> aaa is the exempt bidirectional cycle.
Expected
compile() returns Err("Circular dependency detected: ... aaa ... ccc ..."). (Control: remove bbb and the bare aaa <-> ccc cycle is correctly rejected.)
Actual
compile() returns Ok(...); the resulting Pipeline contains both ccc -> aaa and aaa -> ccc, i.e. a real cycle, with no error.
User/business impact
An invalid pipeline with an accidental circular dependency passes YAML/API validation whenever a transport::moq::peer (heavily used in this repo's stream samples) sits adjacent to the cycle, defeating the purpose of cycle validation and risking a runtime deadlock/hang rather than a clear compile-time error.
Evidence
crates/api/src/yaml/compiler.rs:65-148 (detect_cycles): inner dfs returns Some on the first cycle (:82-86); the outer loop applies the bidirectional exemption and continues (:137-145) without re-exploring abandoned sibling edges, while nodes on the exempt path remain in visited, so the second cycle's back-edge fails the rec_stack check (:87).
- Verified via an executed throwaway integration test against the public
yaml::compile API (since removed; nothing committed): the masked-cycle case returned Ok with both aaa->ccc and ccc->aaa present; the control (no peer) returned Err("Circular dependency detected: ...").
Suspected fix
Don't abandon exploration on the first cycle: either enumerate all cycles and apply the bidirectional exemption per-cycle without leaving sibling edges unexplored, or exclude only bidirectional-node edges from the graph before running standard cycle detection on the remainder.
Dedupe notes
No existing issue. The lower-confidence param-validation gap found alongside this (non-object params only validated for audio::mixer) is already tracked by open #524, so it is not filed here.
Severity
P2 / Major — a validation bypass: a pipeline containing a genuine (non-bidirectional) circular dependency can pass
compile()and reach the engine, where Reliable backpressure on the cycle can deadlock the pipeline instead of producing a clear authoring error. Trigger is topology/ordering-dependent (lower confidence on severity — could be argued P3), but it is a real correctness hole.Environment
main@2f77388, Linux x86_64crates/api/src/yaml/compiler.rs(detect_cycles)Summary
Cycle detection deliberately exempts cycles that involve a bidirectional node (
transport::moq::peer). But the DFS returns on the first cycle it finds, and the top-level loop merelycontinues past an exempt cycle without re-exploring the edges it abandoned. Nodes on the exempt path are already invisited, so a second, genuine cycle reachable only via those unexplored edges is treated as a cross-edge and never detected. Net: a real circular dependency slips through whenever a bidirectional node is discovered adjacent to it first.Steps to reproduce
Compile this DAG via
streamkit_api::yaml::{parse_yaml, compile}(names chosen so the bidirectional branch is visited first):Here
aaa <-> ccc(both non-bidirectional) is a genuine cycle;bbb <-> aaais the exempt bidirectional cycle.Expected
compile()returnsErr("Circular dependency detected: ... aaa ... ccc ..."). (Control: removebbband the bareaaa <-> ccccycle is correctly rejected.)Actual
compile()returnsOk(...); the resultingPipelinecontains bothccc -> aaaandaaa -> ccc, i.e. a real cycle, with no error.User/business impact
An invalid pipeline with an accidental circular dependency passes YAML/API validation whenever a
transport::moq::peer(heavily used in this repo's stream samples) sits adjacent to the cycle, defeating the purpose of cycle validation and risking a runtime deadlock/hang rather than a clear compile-time error.Evidence
crates/api/src/yaml/compiler.rs:65-148(detect_cycles): innerdfsreturnsSomeon the first cycle (:82-86); the outer loop applies the bidirectional exemption andcontinues (:137-145) without re-exploring abandoned sibling edges, while nodes on the exempt path remain invisited, so the second cycle's back-edge fails therec_stackcheck (:87).yaml::compileAPI (since removed; nothing committed): the masked-cycle case returnedOkwith bothaaa->cccandccc->aaapresent; the control (no peer) returnedErr("Circular dependency detected: ...").Suspected fix
Don't abandon exploration on the first cycle: either enumerate all cycles and apply the bidirectional exemption per-cycle without leaving sibling edges unexplored, or exclude only bidirectional-node edges from the graph before running standard cycle detection on the remainder.
Dedupe notes
No existing issue. The lower-confidence param-validation gap found alongside this (non-object params only validated for
audio::mixer) is already tracked by open #524, so it is not filed here.