Skip to content

Commit e386c09

Browse files
authored
fix(detection): contain unidiff panic on orphaned +++ target line (headroomlabs-ai#1548)
## Description `headroom._core.detect_content_type()` panics with `pyo3_runtime.PanicException: called Option::unwrap() on a None value` on any text containing a `+++ ` target line with no preceding `--- ` source line — e.g. `set -x` xtrace output or a partial `git diff` quoted out of context. The panic originates in the bundled `unidiff` 0.4.0 parser (`lib.rs:665`): on a target-file header it does `source_file.clone().unwrap()`, but `source_file` is still `None` when no source header was seen. The crate's only guard there checks `current_file`, not `source_file`, so it falls through and unwraps `None` instead of returning `Err`. Because detection runs inside a `ThreadPoolExecutor` worker on the Python side, the native panic surfaces as an uncaught `PanicException`, bypasses the compression error handling, and returns **HTTP 500** for the whole request. The failure is deterministic on payload content, so client retries fail until the offending text leaves the context window. `is_diff()` in `unidiff_detector.rs` is the single entry point that drives `PatchSet::parse`, so the fix is contained there: wrap the parse in `catch_unwind` and treat an unparseable fragment as "not a diff". This matches the workspace's deliberate no-`panic = "abort"` policy (Cargo.toml) of surviving bad input rather than taking the long-lived proxy down. Closes headroomlabs-ai#1547 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `crates/headroom-core/src/transforms/unidiff_detector.rs`: contain any `unidiff` parser panic inside `is_diff()` via `catch_unwind`, returning `false` (not a diff) on panic. Added regression test `orphaned_target_line_does_not_panic`. - `CHANGELOG.md`: note under Unreleased → Fixed. ## Testing - [x] Unit tests pass (`cargo test -p headroom-core`) - [x] Linting passes (`cargo fmt --check`, `cargo clippy`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output Before the fix (regression test reproduces the exact panic): ```text running 1 test test transforms::unidiff_detector::tests::orphaned_target_line_does_not_panic ... FAILED ---- transforms::unidiff_detector::tests::orphaned_target_line_does_not_panic stdout ---- thread '...' panicked at unidiff-0.4.0/src/lib.rs:665:54: called `Option::unwrap()` on a `None` value test result: FAILED. 0 passed; 1 failed; ... ``` After the fix: ```text running 15 tests test transforms::unidiff_detector::tests::orphaned_target_line_does_not_panic ... ok test transforms::unidiff_detector::tests::standard_git_diff_detected ... ok ... test result: ok. 15 passed; 0 failed; 0 ignored # whole transforms suite test result: ok. 700 passed; 0 failed; 0 ignored ``` ## Real Behavior Proof - Environment: macOS (arm64), Rust stable, `cargo test -p headroom-core`. - Exact command / steps: `cargo test -p headroom-core --lib unidiff_detector` then `cargo test -p headroom-core`. (1) Added a test calling `is_diff("+++ x")` / `detect_diff("+++ x")` and ran it → reproduced the panic at `unidiff-0.4.0/src/lib.rs:665:54` (output above), confirming the same crash path as the report. (2) Applied the `catch_unwind` containment in `is_diff()`. (3) Re-ran the test and the full transforms suite → all green (output above). - Observed result: the orphaned-`+++ ` input is now classified as "not a diff" (plain text) and returns normally instead of panicking. Real diffs (`standard_git_diff_detected`, `naked_hunk_without_git_header_detected`, multi-file, added/removed-only) still detect correctly, so the containment does not weaken detection. - Not tested: I exercised the Rust layer directly (the sole `unidiff` caller, which the `headroom._core.detect_content_type` binding routes through) rather than rebuilding the Python wheel; I did not run the live proxy against a real provider. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md
1 parent 715ed7d commit e386c09

2 files changed

Lines changed: 40 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
## Unreleased
1010

1111
### Fixed
12+
- Content detection no longer crashes the proxy on text containing an
13+
orphaned `+++ ` target line with no preceding `--- ` source line (common in
14+
`set -x` xtrace output and partial diffs). The bundled `unidiff` 0.4.0 parser
15+
panics on that input instead of returning an error; the Rust diff detector now
16+
contains the panic and treats the fragment as plain text, so the request is
17+
compressed and forwarded normally instead of returning HTTP 500
18+
([#1547](https://github.com/headroomlabs-ai/headroom/issues/1547)).
1219
- Proactive expansion blocks injected into user turns are now wrapped in
1320
`<headroom_proactive_expansion>` XML tags, giving downstream consumers
1421
(LLMs, loggers, attribution parsers) a machine-readable provenance

crates/headroom-core/src/transforms/unidiff_detector.rs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,28 @@ pub fn is_diff(content: &str) -> bool {
6161
return false;
6262
}
6363

64-
let mut patch = PatchSet::new();
65-
if patch.parse(content).is_err() {
66-
return false;
67-
}
64+
// `unidiff` 0.4.0 does not return `Err` on every malformed input — a
65+
// `+++ ` target line with no preceding `--- ` source line makes it
66+
// `unwrap()` a `None` and panic (lib.rs:665). Inputs of that shape are
67+
// common (`set -x` xtrace, partial diffs quoted out of context). This
68+
// detector runs inside a thread-pool worker on the Python side, where a
69+
// native panic surfaces as an uncaught `PanicException` and 500s the whole
70+
// request. Contain any parser panic here and treat the fragment as "not a
71+
// diff" — consistent with the workspace's no-`panic = "abort"` policy of
72+
// surviving bad input rather than taking the process down.
73+
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
74+
let mut patch = PatchSet::new();
75+
if patch.parse(content).is_err() {
76+
return false;
77+
}
6878

69-
// `PatchSet::is_empty()` covers "found zero files"; the inner
70-
// loop covers "found a file but with zero hunks" (e.g. mode-only
71-
// changes). For diff-compressor routing we want at least one
72-
// hunk — that's where the actual line-level change content lives.
73-
!patch.is_empty() && patch.files().iter().any(|f| !f.is_empty())
79+
// `PatchSet::is_empty()` covers "found zero files"; the inner
80+
// loop covers "found a file but with zero hunks" (e.g. mode-only
81+
// changes). For diff-compressor routing we want at least one
82+
// hunk — that's where the actual line-level change content lives.
83+
!patch.is_empty() && patch.files().iter().any(|f| !f.is_empty())
84+
}))
85+
.unwrap_or(false)
7486
}
7587

7688
/// [`ContentType`]-typed wrapper. Returns `Some(ContentType::GitDiff)`
@@ -229,4 +241,16 @@ mod tests {
229241
assert_eq!(detect_diff("{}"), None);
230242
assert_eq!(detect_diff(""), None);
231243
}
244+
245+
#[test]
246+
fn orphaned_target_line_does_not_panic() {
247+
// `unidiff` 0.4.0 panics (unwrap on `None`) when it meets a
248+
// `+++ ` target line with no preceding `--- ` source line.
249+
// That shape is common in `set -x` xtrace output and partial
250+
// diffs quoted out of context. It must degrade to "not a diff",
251+
// never abort the caller.
252+
assert!(!is_diff("+++ x"));
253+
assert_eq!(detect_diff("+++ x"), None);
254+
assert!(!is_diff("some prose\n+++ target without a source\nmore"));
255+
}
232256
}

0 commit comments

Comments
 (0)