commit 1be924c14828056a5333e90258ea5705f63fdb11
parent 6ae4e02db72e79b6047ef9c6e574b640a90302a6
Author: triesap <tyson@radroots.org>
Date: Sat, 11 Apr 2026 18:31:18 +0000
hyf: harden cli business request path
Diffstat:
3 files changed, 105 insertions(+), 23 deletions(-)
diff --git a/src/runtime/find.rs b/src/runtime/find.rs
@@ -163,7 +163,7 @@ fn attempt_query_rewrite(
return None;
}
- let client = hyf::resolve_ready_runtime_client(config).ok()?;
+ let client = hyf::resolve_runtime_client(config).ok()?;
let response = client
.query_rewrite(
FIND_HYF_QUERY_REWRITE_REQUEST_ID,
diff --git a/src/runtime/hyf.rs b/src/runtime/hyf.rs
@@ -14,8 +14,9 @@ use crate::runtime::config::{
CapabilityBindingTargetKind, HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, RuntimeConfig,
};
-const HYF_STATUS_TIMEOUT: Duration = Duration::from_secs(1);
-const HYF_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(10);
+const HYF_CONTROL_TIMEOUT: Duration = Duration::from_secs(2);
+const HYF_BUSINESS_TIMEOUT: Duration = Duration::from_secs(4);
+const HYF_TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(10);
const HYF_STATUS_REQUEST_ID: &str = "cli-doctor-hyf-status";
const HYF_CAPABILITIES_REQUEST_ID: &str = "cli-runtime-hyf-capabilities";
const HYF_SOURCE: &str = "hyf status control request ยท local first";
@@ -53,6 +54,7 @@ impl HyfClient {
"sys.status",
None,
&HyfEmptyInput::default(),
+ HYF_CONTROL_TIMEOUT,
)
}
@@ -63,6 +65,7 @@ impl HyfClient {
"sys.capabilities",
None,
&HyfEmptyInput::default(),
+ HYF_CONTROL_TIMEOUT,
)
}
@@ -79,6 +82,7 @@ impl HyfClient {
"query_rewrite",
Some(context),
request,
+ HYF_BUSINESS_TIMEOUT,
)
}
@@ -95,6 +99,7 @@ impl HyfClient {
"semantic_rank",
Some(context),
request,
+ HYF_BUSINESS_TIMEOUT,
)
}
@@ -111,6 +116,7 @@ impl HyfClient {
"explain_result",
Some(context),
request,
+ HYF_BUSINESS_TIMEOUT,
)
}
@@ -121,6 +127,7 @@ impl HyfClient {
capability: &str,
context: Option<&HyfRequestContext>,
input: &TRequest,
+ timeout: Duration,
) -> Result<HyfSuccess<TResponse>, HyfClientError>
where
TRequest: Serialize,
@@ -129,7 +136,7 @@ impl HyfClient {
let request = serialize_request(request_id, trace_id, capability, context, input)
.map_err(HyfClientError::SerializeRequest)?;
- let output = self.run_request(request.as_str())?;
+ let output = self.run_request(request.as_str(), timeout)?;
let stdout = String::from_utf8(output.stdout).map_err(HyfClientError::InvalidUtf8)?;
let response: HyfWireResponse<TResponse> =
serde_json::from_str(stdout.as_str()).map_err(HyfClientError::InvalidJson)?;
@@ -159,7 +166,7 @@ impl HyfClient {
})
}
- fn run_request(&self, request: &str) -> Result<Output, HyfClientError> {
+ fn run_request(&self, request: &str, timeout: Duration) -> Result<Output, HyfClientError> {
let mut child = Command::new(&self.executable)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -174,7 +181,7 @@ impl HyfClient {
writeln!(stdin, "{request}").map_err(HyfClientError::Write)?;
}
- let output = collect_output_with_timeout(child)?;
+ let output = collect_output_with_timeout(child, timeout)?;
if !output.status.success() {
return Err(HyfClientError::NonZeroExit {
status: output.status.code(),
@@ -510,16 +517,6 @@ pub fn resolve_runtime_client(config: &RuntimeConfig) -> Result<HyfClient, HyfSt
}
}
-pub fn resolve_ready_runtime_client(config: &RuntimeConfig) -> Result<HyfClient, HyfStatusView> {
- let client = resolve_runtime_client(config)?;
- let status = resolve_status_for_client(&client);
- if status.state == "ready" {
- Ok(client)
- } else {
- Err(status)
- }
-}
-
pub fn resolve_runtime_status(config: &RuntimeConfig) -> HyfStatusView {
match resolve_runtime_client(config) {
Ok(client) => resolve_status_for_client(&client),
@@ -700,18 +697,18 @@ fn resolve_status_for_client(client: &HyfClient) -> HyfStatusView {
}
}
-fn collect_output_with_timeout(mut child: Child) -> Result<Output, HyfClientError> {
+fn collect_output_with_timeout(mut child: Child, timeout: Duration) -> Result<Output, HyfClientError> {
let started_at = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => return collect_output(child, status),
Ok(None) => {
- if started_at.elapsed() >= HYF_STATUS_TIMEOUT {
+ if started_at.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
- return Err(HyfClientError::Timeout(HYF_STATUS_TIMEOUT.as_millis()));
+ return Err(HyfClientError::Timeout(timeout.as_millis()));
}
- thread::sleep(HYF_STATUS_POLL_INTERVAL);
+ thread::sleep(HYF_TIMEOUT_POLL_INTERVAL);
}
Err(error) => {
let _ = child.kill();
@@ -785,9 +782,9 @@ fn format_nonzero_exit(request_label: &str, status: Option<i32>, stderr: &str) -
#[cfg(test)]
mod tests {
use super::{
- HYF_PROTOCOL_VERSION, HyfClient, HyfEmptyInput, HyfExplainResultRequest,
- HyfQueryRewriteRequest, HyfRequestContext, HyfSemanticCandidate, HyfSemanticRankRequest,
- resolve_status,
+ HYF_PROTOCOL_VERSION, HyfClient, HyfClientError, HyfEmptyInput,
+ HyfExplainResultRequest, HyfQueryRewriteRequest, HyfRequestContext,
+ HyfSemanticCandidate, HyfSemanticRankRequest, resolve_status,
};
use crate::runtime::config::HyfConfig;
use serde::Serialize;
@@ -931,6 +928,30 @@ mod tests {
}
#[test]
+ fn business_requests_use_a_longer_timeout_than_control_requests() {
+ let _guard = hyf_test_lock().lock().expect("hyf test lock");
+ let dir = tempdir().expect("tempdir");
+ let executable = write_script(
+ dir.path(),
+ "#!/bin/sh\nread -r request || exit 64\ncase \"$request\" in\n *'\"capability\":\"sys.status\"'*)\n sleep 3\n cat <<'JSON'\n{\"version\":1,\"request_id\":\"cli-doctor-hyf-status\",\"trace_id\":\"cli-doctor-hyf-status\",\"ok\":true,\"output\":{\"build_identity\":{\"protocol_version\":1},\"enabled_execution_modes\":{\"deterministic\":true}}}\nJSON\n ;;\n *'\"capability\":\"query_rewrite\"'*)\n sleep 3\n cat <<'JSON'\n{\"version\":1,\"request_id\":\"rewrite-timeout-test\",\"trace_id\":\"rewrite-timeout-test\",\"ok\":true,\"output\":{\"original_text\":\"henhouse\",\"normalized_text\":\"henhouse\",\"rewritten_text\":\"eggs\",\"query_terms\":[\"eggs\"],\"normalization_signals\":[\"query_rewrite\"],\"ranking_hints\":[\"local_first\"],\"extracted_filters\":{\"local_intent\":false,\"fulfillment\":\"any\",\"time_window\":\"any\"}}}\nJSON\n ;;\n *)\n exit 65\n ;;\nesac\n",
+ );
+ let client = HyfClient::new(executable);
+
+ let status = client.status().expect_err("status should time out");
+ assert!(matches!(status, HyfClientError::Timeout(2000)));
+
+ let rewrite = client
+ .query_rewrite(
+ "rewrite-timeout-test",
+ Some("rewrite-timeout-test"),
+ &HyfRequestContext::deterministic_cli(),
+ &HyfQueryRewriteRequest::new("henhouse"),
+ )
+ .expect("query rewrite should use longer timeout");
+ assert_eq!(rewrite.output.rewritten_text, "eggs");
+ }
+
+ #[test]
fn semantic_rank_request_round_trips_typed_output() {
let _guard = hyf_test_lock().lock().expect("hyf test lock");
let dir = tempdir().expect("tempdir");
diff --git a/tests/find.rs b/tests/find.rs
@@ -250,6 +250,48 @@ fn find_uses_hyf_query_rewrite_when_available() {
}
#[test]
+fn find_uses_hyf_query_rewrite_without_status_preflight() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ seed_trade_product(
+ dir.path(),
+ "00000000-0000-0000-0000-000000000106",
+ "fresh-eggs",
+ "protein",
+ "Fresh Eggs",
+ "Pasture-raised eggs",
+ 36,
+ 24,
+ Some("Marshall"),
+ );
+
+ let hyfd = write_fake_hyfd_with_failing_status(
+ dir.path(),
+ r#"{"version":1,"request_id":"cli-find-query-rewrite","trace_id":"cli-find-query-rewrite","ok":true,"output":{"original_text":"henhouse","normalized_text":"henhouse","rewritten_text":"eggs","query_terms":["eggs"],"normalization_signals":["query_rewrite"],"ranking_hints":["local_first"],"extracted_filters":{"local_intent":false,"fulfillment":"any","time_window":"any"}}}"#,
+ );
+
+ let output = cli_command_in(dir.path())
+ .env("RADROOTS_HYF_ENABLED", "true")
+ .env("RADROOTS_HYF_EXECUTABLE", &hyfd)
+ .args(["--json", "find", "henhouse"])
+ .output()
+ .expect("run hyf json find");
+
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "ready");
+ assert_eq!(json["count"], 1);
+ assert_eq!(json["hyf"]["state"], "query_rewrite_applied");
+ assert_eq!(json["hyf"]["rewritten_query"], "eggs");
+ assert_eq!(json["results"][0]["title"], "Fresh Eggs");
+}
+
+#[test]
fn find_falls_back_cleanly_when_hyf_is_unavailable() {
let dir = tempdir().expect("tempdir");
let init = cli_command_in(dir.path())
@@ -391,3 +433,22 @@ fn write_fake_hyfd(
}
path
}
+
+fn write_fake_hyfd_with_failing_status(
+ workdir: &Path,
+ rewrite_response: &str,
+) -> std::path::PathBuf {
+ let path = workdir.join("fake-hyfd");
+ let script = format!(
+ "#!/bin/sh\nread -r request || exit 64\ncase \"$request\" in\n *'\"capability\":\"sys.status\"'*)\n echo \"status should not be called\" >&2\n exit 23\n ;;\n *'\"capability\":\"query_rewrite\"'*)\n cat <<'JSON'\n{rewrite_response}\nJSON\n ;;\n *)\n cat <<'JSON'\n{{\"version\":1,\"request_id\":\"unexpected\",\"ok\":false,\"error\":{{\"code\":\"unsupported_capability\",\"message\":\"unexpected request\"}}}}\nJSON\n ;;\nesac\n"
+ );
+ std::fs::write(&path, script).expect("write fake hyfd");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut permissions = std::fs::metadata(&path).expect("metadata").permissions();
+ permissions.set_mode(0o755);
+ std::fs::set_permissions(&path, permissions).expect("chmod fake hyfd");
+ }
+ path
+}