From c65b88a366ce78a6cc7c44cc2aa9c9c468fe6f36 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Mon, 25 May 2026 14:34:33 +0000 Subject: [PATCH 1/3] fix(api): reject non-object params for audio::mixer in compile_dag When audio::mixer params is present but not a JSON object, compile_dag now returns an error instead of silently skipping num_inputs injection. Closes #472 Signed-off-by: streamkit-devin --- crates/api/src/yaml/compiler.rs | 36 +++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/api/src/yaml/compiler.rs b/crates/api/src/yaml/compiler.rs index fc085616..b553f1e4 100644 --- a/crates/api/src/yaml/compiler.rs +++ b/crates/api/src/yaml/compiler.rs @@ -221,6 +221,15 @@ fn compile_dag( let mut params = def.params; if def.kind == "audio::mixer" && mode != EngineMode::Dynamic { + if let Some(ref p) = params { + if !p.is_object() { + return Err(format!( + "audio::mixer params must be an object, got {}", + value_type_name(p), + )); + } + } + if let Some(count) = incoming_counts.get(&name) { if *count > 1 { if let Some(serde_json::Value::Object(ref mut map)) = params { @@ -246,9 +255,9 @@ fn compile_dag( } } - (name, Node { kind: def.kind, params, state: None }) + Ok((name, Node { kind: def.kind, params, state: None })) }) - .collect(); + .collect::, _>>()?; Ok(Pipeline { name, @@ -262,6 +271,17 @@ fn compile_dag( }) } +fn value_type_name(v: &serde_json::Value) -> &'static str { + match v { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } +} + #[cfg(test)] mod tests { use super::*; @@ -335,7 +355,7 @@ mod tests { } #[test] - fn compile_dag_oneshot_audio_mixer_skips_inject_when_params_is_non_object() { + fn compile_dag_oneshot_audio_mixer_rejects_non_object_params() { let mixer = UserNode { kind: "audio::mixer".to_string(), params: Some(serde_json::Value::String("scalar".into())), @@ -346,8 +366,12 @@ mod tests { ("b", user_node("core::source", Needs::None)), ("mixer", mixer), ]); - let compiled = compile(dag_pipeline(nodes, EngineMode::OneShot)).expect("compile"); - let mixer = compiled.nodes.get("mixer").expect("mixer present"); - assert_eq!(mixer.params.as_ref().and_then(serde_json::Value::as_str), Some("scalar")); + let err = compile(dag_pipeline(nodes, EngineMode::OneShot)) + .expect_err("non-object params should be rejected"); + assert!( + err.contains("audio::mixer params must be an object"), + "error should mention the requirement: {err}", + ); + assert!(err.contains("string"), "error should mention the actual type: {err}"); } } From 63abf4808ed33cfe99c066654249521855b6825f Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Tue, 26 May 2026 19:22:45 +0000 Subject: [PATCH 2/3] fix(api): hoist mixer params type check and include node name - Apply the non-object params rejection in all engine modes, not just non-dynamic. Dynamic pipelines now also fail at compile time instead of deferring to the node factory. - Include the node name in the error message for easier debugging with multiple mixer nodes. Signed-off-by: streamkit-devin --- crates/api/src/yaml/compiler.rs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/api/src/yaml/compiler.rs b/crates/api/src/yaml/compiler.rs index b553f1e4..41297bde 100644 --- a/crates/api/src/yaml/compiler.rs +++ b/crates/api/src/yaml/compiler.rs @@ -220,16 +220,18 @@ fn compile_dag( .map(|(name, def)| { let mut params = def.params; - if def.kind == "audio::mixer" && mode != EngineMode::Dynamic { + if def.kind == "audio::mixer" { if let Some(ref p) = params { if !p.is_object() { return Err(format!( - "audio::mixer params must be an object, got {}", + "audio::mixer node '{name}' params must be an object, got {}", value_type_name(p), )); } } + } + if def.kind == "audio::mixer" && mode != EngineMode::Dynamic { if let Some(count) = incoming_counts.get(&name) { if *count > 1 { if let Some(serde_json::Value::Object(ref mut map)) = params { @@ -355,7 +357,7 @@ mod tests { } #[test] - fn compile_dag_oneshot_audio_mixer_rejects_non_object_params() { + fn compile_dag_audio_mixer_rejects_non_object_params() { let mixer = UserNode { kind: "audio::mixer".to_string(), params: Some(serde_json::Value::String("scalar".into())), @@ -369,9 +371,26 @@ mod tests { let err = compile(dag_pipeline(nodes, EngineMode::OneShot)) .expect_err("non-object params should be rejected"); assert!( - err.contains("audio::mixer params must be an object"), - "error should mention the requirement: {err}", + err.contains("audio::mixer node 'mixer' params must be an object"), + "error should mention the node name and requirement: {err}", ); assert!(err.contains("string"), "error should mention the actual type: {err}"); } + + #[test] + fn compile_dag_dynamic_audio_mixer_also_rejects_non_object_params() { + let mixer = UserNode { + kind: "audio::mixer".to_string(), + params: Some(serde_json::Value::Number(42.into())), + needs: mixer_needs(), + }; + let nodes = dag_nodes(vec![ + ("a", user_node("core::source", Needs::None)), + ("b", user_node("core::source", Needs::None)), + ("mixer", mixer), + ]); + let err = compile(dag_pipeline(nodes, EngineMode::Dynamic)) + .expect_err("non-object params should be rejected even in dynamic mode"); + assert!(err.contains("number"), "error should mention the actual type: {err}"); + } } From 839ad23c35fecc068d76ca86dcee67fbd013a724 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Wed, 27 May 2026 21:12:18 +0000 Subject: [PATCH 3/3] refactor(api): fold mixer blocks and add null-params test - Merge the two adjacent audio::mixer if-blocks into a single branch with the mode-gated injection nested inside. - Add a test pinning the fail-fast behavior for Some(Value::Null) params. Signed-off-by: streamkit-devin --- crates/api/src/yaml/compiler.rs | 51 ++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/crates/api/src/yaml/compiler.rs b/crates/api/src/yaml/compiler.rs index 41297bde..85046a9c 100644 --- a/crates/api/src/yaml/compiler.rs +++ b/crates/api/src/yaml/compiler.rs @@ -229,29 +229,29 @@ fn compile_dag( )); } } - } - if def.kind == "audio::mixer" && mode != EngineMode::Dynamic { - if let Some(count) = incoming_counts.get(&name) { - if *count > 1 { - if let Some(serde_json::Value::Object(ref mut map)) = params { - let should_inject = matches!( - map.get("num_inputs"), - Some(serde_json::Value::Null) | None - ); - if should_inject { + if mode != EngineMode::Dynamic { + if let Some(count) = incoming_counts.get(&name) { + if *count > 1 { + if let Some(serde_json::Value::Object(ref mut map)) = params { + let should_inject = matches!( + map.get("num_inputs"), + Some(serde_json::Value::Null) | None + ); + if should_inject { + map.insert( + "num_inputs".to_string(), + serde_json::Value::Number((*count).into()), + ); + } + } else if params.is_none() { + let mut map = serde_json::Map::new(); map.insert( "num_inputs".to_string(), serde_json::Value::Number((*count).into()), ); + params = Some(serde_json::Value::Object(map)); } - } else if params.is_none() { - let mut map = serde_json::Map::new(); - map.insert( - "num_inputs".to_string(), - serde_json::Value::Number((*count).into()), - ); - params = Some(serde_json::Value::Object(map)); } } } @@ -393,4 +393,21 @@ mod tests { .expect_err("non-object params should be rejected even in dynamic mode"); assert!(err.contains("number"), "error should mention the actual type: {err}"); } + + #[test] + fn compile_dag_audio_mixer_rejects_null_params() { + let mixer = UserNode { + kind: "audio::mixer".to_string(), + params: Some(serde_json::Value::Null), + needs: mixer_needs(), + }; + let nodes = dag_nodes(vec![ + ("a", user_node("core::source", Needs::None)), + ("b", user_node("core::source", Needs::None)), + ("mixer", mixer), + ]); + let err = compile(dag_pipeline(nodes, EngineMode::OneShot)) + .expect_err("null params should be rejected"); + assert!(err.contains("got null"), "error should mention null type: {err}"); + } }