Skip to content

fix(stt): bound whisper-server HTTP reads and fail closed on errors - #939

Open
kurosawareiji7007-hub wants to merge 3 commits into
GeniePod:mainfrom
kurosawareiji7007-hub:fix/stt-whisper-server-bounded-http
Open

fix(stt): bound whisper-server HTTP reads and fail closed on errors#939
kurosawareiji7007-hub wants to merge 3 commits into
GeniePod:mainfrom
kurosawareiji7007-hub:fix/stt-whisper-server-bounded-http

Conversation

@kurosawareiji7007-hub

@kurosawareiji7007-hub kurosawareiji7007-hub commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

transcribe_via_server still used a hand-rolled read_line loop with no line/body cap, no connect/request deadlines, no HTTP status check, and fail-opened non-JSON bodies into a spoken transcript. That is the same outbound-HTTP class already closed for LLM/HA clients (#655/#656).

Problem

A hung, slow, or broken whisper-server could OOM the voice loop on unbounded header/body growth, hang forever on connect/read, or have HTML/plain error pages spoken as user speech.

Root cause

The whisper-server path never moved onto the shared read_response / HttpResponseLimits reader, and treated any parse failure as transcript text.

Solution

  • Connect timeout (5s) + full request timeout (60s) around write + response read
  • Shared read_response with a 256 KiB body cap
  • Require 2xx and strict JSON (text field); fail closed otherwise
  • Focused unit tests for accept / non-2xx / non-JSON / oversized header line

Scope boundaries

  • Only transcribe_via_server HTTP path in crates/genie-core/src/voice/stt.rs
  • No change to whisper-cli path or STT public API
  • No prompt / Jetson 4096 contract impact

Tests

  • whisper_server_http_accepts_json_transcript
  • whisper_server_http_rejects_non_2xx
  • whisper_server_http_rejects_non_json_body
  • whisper_server_read_rejects_oversized_header_line

Before/after results

  • Before: non-JSON body could become a fake transcript; oversized header lines grew without bound; no connect/request deadline
  • After: non-2xx / non-JSON fail closed; oversized header lines return HeaderLineTooLong; connect/request deadlines apply

Full cargo test -p genie-core runs in Linux CI (this Windows host cannot link full genie-core due to Unix-gated APIs).

Regression protection

Unit tests fail closed on the previous fail-open and unbounded-read behaviors.

Real Behavior Proof

  • I have built and run the affected code locally (or noted why I could not).
  • I have verified the change end-to-end on Jetson hardware.
  • I have NOT verified on Jetson hardware, and I explain the equivalent verification path or validation gap below.

Tested profile / hardware (check all that apply):

  • jetson
  • raspberry_pi
  • portable_sbc
  • laptop
  • mac
  • CI-only / docs-only
  • Not run locally

What I ran

  • Implemented + reviewed diff against main in crates/genie-core/src/voice/stt.rs + CHANGELOG.md
  • Added offline unit tests exercising transcript_from_whisper_http_response and shared read_response oversize-header rejection
  • Full workspace cargo test cannot link on this Windows host (UnixStream / Unix libc gates); Linux CI runs the real lib tests

What I observed

  • 200 + {text:...} parses to a transcript
  • HTTP 500 / plain-text 200 bodies error instead of becoming spoken text
  • An endless header line fails with HeaderLineTooLong under HttpResponseLimits

Jetson gap: no Jetson/whisper-server instance on this host. Behavior is deterministic HTTP client hardening; CI Linux unit tests cover the fail-closed paths. Hardware validation would be a live /inference round-trip against whisper-server on device.

Fixes

Fixes #936

Summary by CodeRabbit

  • Bug Fixes
    • Improved speech-to-text reliability with connection and request timeouts.
    • Added safeguards against oversized responses that could cause memory issues.
    • Responses must now indicate success and contain valid JSON before being accepted.
    • Invalid, malformed, non-JSON, or unsuccessful server responses are rejected rather than treated as transcripts.
    • Added clearer failure handling for unexpected speech recognition results.
    • Bounded HTTP reads help prevent resource exhaustion during speech recognition.

The STT server path still used unbounded read_line, had no connect/request
deadline or status check, and fail-opened non-JSON bodies into fake
transcripts — the same class of bug fixed for LLM/HA in GeniePod#655/GeniePod#656.
@github-actions github-actions Bot added the bug Something isn't working label Jul 31, 2026
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a7159e67-b07f-4b00-856d-da1bb3e57514

📥 Commits

Reviewing files that changed from the base of the PR and between 71d0ff9 and 0cf12f4.

📒 Files selected for processing (1)
  • crates/genie-core/src/voice/stt.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/genie-core/src/voice/stt.rs

📝 Walkthrough

Walkthrough

The whisper-server STT path now uses bounded HTTP reads, connection and request deadlines, mandatory 2xx responses, and fail-closed JSON decoding. Tests cover valid, invalid, error, and oversized responses.

Changes

Whisper-server STT reliability

Layer / File(s) Summary
HTTP limits and deadlines
crates/genie-core/src/voice/stt.rs
Shared HTTP utilities define connection and request deadlines and a 256 KiB response-body limit.
Bounded request execution
crates/genie-core/src/voice/stt.rs
Whisper-server requests use timed execution, bounded response parsing, explicit status handling, and translated read errors.
Transcript decoding and validation
crates/genie-core/src/voice/stt.rs, CHANGELOG.md
Response decoding rejects non-2xx and non-JSON bodies. Tests cover successful JSON, HTTP errors, invalid bodies, and oversized headers. The changelog records the reliability changes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

  • GeniePod/genie-claw#808 — Both PRs modify crates/genie-core/src/voice/stt.rs for bounded execution and failure handling, but target different operations.

Sequence Diagram(s)

sequenceDiagram
  participant SttEngine
  participant WhisperServer
  participant HttpResponseParser
  SttEngine->>WhisperServer: Send STT request with deadlines
  WhisperServer-->>HttpResponseParser: Return bounded HTTP response
  HttpResponseParser-->>SttEngine: Validate status and decode JSON transcript
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main STT reliability changes: bounded HTTP reads and fail-closed error handling.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
crates/genie-core/src/voice/stt.rs (1)

360-366: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add the address to the connect error.

The connect failure branch returns the raw io::Error without the target address. The timeout branch also omits it. The nearby client in crates/genie-core/src/llm/openai_compat.rs includes the address in both branches, which makes port misconfiguration easier to diagnose.

♻️ Proposed change
-            Ok(Ok(stream)) => stream,
-            Ok(Err(e)) => return Err(e.into()),
-            Err(_) => anyhow::bail!(
-                "whisper-server connect timed out after {}s",
-                WHISPER_SERVER_CONNECT_TIMEOUT.as_secs()
-            ),
+            Ok(Ok(stream)) => stream,
+            Ok(Err(e)) => anyhow::bail!("whisper-server connect failed ({addr}): {e}"),
+            Err(_) => anyhow::bail!(
+                "whisper-server connect timed out after {}s ({addr})",
+                WHISPER_SERVER_CONNECT_TIMEOUT.as_secs()
+            ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/genie-core/src/voice/stt.rs` around lines 360 - 366, Update the
connection error handling around the stream connection match to include the
target address in both the Err(e) failure and timeout branches, matching the
diagnostic format used by the nearby OpenAI-compatible client while preserving
the existing error propagation and timeout duration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/genie-core/src/voice/stt.rs`:
- Around line 613-618: Update the language resolution chain in the surrounding
STT response parsing to reject server values that are blank or equal to "auto"
before applying fallbacks. Preserve valid parsed language values, then fall back
to language_hint and detected_language so unusable server tags do not win.
- Around line 586-596: Update the non-2xx response handling around the status
check to truncate the trimmed body excerpt before interpolating it into the
anyhow error, capping it to a reasonable bounded length rather than the full
WHISPER_SERVER_MAX_BODY_BYTES payload. Preserve the existing whitespace
normalization, empty-body formatting, and HTTP status in the error message.
- Around line 1228-1235: Update the test’s reader construction to use
std::io::Cursor around the existing Vec<u8> response data instead of
tokio::io::Cursor, preserving the BufReader setup and overflow payload. Ensure
the assertion uses HttpReadError::HeaderLineTooLong.

---

Nitpick comments:
In `@crates/genie-core/src/voice/stt.rs`:
- Around line 360-366: Update the connection error handling around the stream
connection match to include the target address in both the Err(e) failure and
timeout branches, matching the diagnostic format used by the nearby
OpenAI-compatible client while preserving the existing error propagation and
timeout duration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a7eabdab-13e2-42be-97e2-b423e879191c

📥 Commits

Reviewing files that changed from the base of the PR and between 02a577d and 6e6a77f.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • crates/genie-core/src/voice/stt.rs

Comment on lines +586 to +596
if !(200..300).contains(&status) {
let trimmed = body.trim().replace('\n', " ");
anyhow::bail!(
"whisper-server HTTP {status}{}",
if trimmed.is_empty() {
String::new()
} else {
format!(": {trimmed}")
}
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Truncate the body before it enters the error message.

The body can be up to WHISPER_SERVER_MAX_BODY_BYTES (256 KiB). A non-2xx response therefore produces an error string of the same size, which is then logged upstream. Cap the excerpt.

🐛 Proposed fix
     if !(200..300).contains(&status) {
-        let trimmed = body.trim().replace('\n', " ");
+        let mut trimmed = body.trim().replace('\n', " ");
+        const MAX_ERR_EXCERPT: usize = 256;
+        if trimmed.len() > MAX_ERR_EXCERPT {
+            let cut = trimmed
+                .char_indices()
+                .take_while(|(i, _)| *i <= MAX_ERR_EXCERPT)
+                .last()
+                .map(|(i, _)| i)
+                .unwrap_or(0);
+            trimmed.truncate(cut);
+            trimmed.push('…');
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if !(200..300).contains(&status) {
let trimmed = body.trim().replace('\n', " ");
anyhow::bail!(
"whisper-server HTTP {status}{}",
if trimmed.is_empty() {
String::new()
} else {
format!(": {trimmed}")
}
);
}
if !(200..300).contains(&status) {
let mut trimmed = body.trim().replace('\n', " ");
const MAX_ERR_EXCERPT: usize = 256;
if trimmed.len() > MAX_ERR_EXCERPT {
let cut = trimmed
.char_indices()
.take_while(|(i, _)| *i <= MAX_ERR_EXCERPT)
.last()
.map(|(i, _)| i)
.unwrap_or(0);
trimmed.truncate(cut);
trimmed.push('…');
}
anyhow::bail!(
"whisper-server HTTP {status}{}",
if trimmed.is_empty() {
String::new()
} else {
format!(": {trimmed}")
}
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/genie-core/src/voice/stt.rs` around lines 586 - 596, Update the
non-2xx response handling around the status check to truncate the trimmed body
excerpt before interpolating it into the anyhow error, capping it to a
reasonable bounded length rather than the full WHISPER_SERVER_MAX_BODY_BYTES
payload. Preserve the existing whitespace normalization, empty-body formatting,
and HTTP status in the error message.

Comment on lines +613 to +618
language: parsed
.get("language")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| language_hint.map(str::to_string))
.or(detected_language),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject blank or auto language values from the server.

parsed["language"].as_str() returns Some("") when whisper-server sends an empty language field, and Some("auto") when detection is disabled. Both values then win over language_hint and over detect_language_from_text, so the transcript carries a useless language tag. Filter the value before the fallback chain.

🐛 Proposed fix
         language: parsed
             .get("language")
             .and_then(|v| v.as_str())
+            .map(str::trim)
+            .filter(|v| !v.is_empty() && !v.eq_ignore_ascii_case("auto"))
             .map(String::from)
             .or_else(|| language_hint.map(str::to_string))
             .or(detected_language),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
language: parsed
.get("language")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| language_hint.map(str::to_string))
.or(detected_language),
language: parsed
.get("language")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty() && !v.eq_ignore_ascii_case("auto"))
.map(String::from)
.or_else(|| language_hint.map(str::to_string))
.or(detected_language),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/genie-core/src/voice/stt.rs` around lines 613 - 618, Update the
language resolution chain in the surrounding STT response parsing to reject
server values that are blank or equal to "auto" before applying fallbacks.
Preserve valid parsed language values, then fall back to language_hint and
detected_language so unusable server tags do not win.

Comment thread crates/genie-core/src/voice/stt.rs Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] stt: whisper-server HTTP path still uses unbounded read_line and fail-opens errors into transcripts

1 participant