{"record":{"id":"bb37700aa46c30e1","repo":"headroomlabs-ai/headroom","slug":"items-json-must-be-json-e","errorCode":null,"errorMessage":"items_json must be JSON: {e}","messagePattern":"items_json must be JSON: (.+?)","errorType":"panic","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/headroom-py/src/lib.rs","lineNumber":821,"sourceCode":"    /// runtime can reach the CCR hash directly (rather than parsing it\n    /// out of the prompt marker).\n    #[pyo3(signature = (items_json, query = \"\", bias = 1.0))]\n    fn crush_array_json<'py>(\n        &self,\n        py: Python<'py>,\n        items_json: &str,\n        query: &str,\n        bias: f64,\n    ) -> Bound<'py, PyDict> {\n        // GIL-release pattern: own the inputs, do all heavy compute\n        // (JSON parse, crush, re-serialize) without the GIL, then\n        // re-acquire to build the PyDict from the owned outputs.\n        let items_json = items_json.to_string();\n        let query = query.to_string();\n        let (kept_json, ccr_hash, dropped_summary, strategy_info, compacted, compaction_kind) = py\n            .detach(|| {\n                let parsed: serde_json::Value = serde_json::from_str(&items_json)\n                    .unwrap_or_else(|e| panic!(\"items_json must be JSON: {e}\"));\n                let items = match parsed {\n                    serde_json::Value::Array(a) => a,\n                    other => panic!(\"items_json must be a JSON array, got {}\", type_name(&other)),\n                };\n                let result = self.inner.crush_array(&items, &query, bias);\n                let kept_json = serde_json::to_string(&serde_json::Value::Array(result.items))\n                    .expect(\"serialize kept items\");\n                (\n                    kept_json,\n                    result.ccr_hash,\n                    result.dropped_summary,\n                    result.strategy_info,\n                    result.compacted,\n                    result.compaction_kind,\n                )\n            });\n        build_crush_array_dict(\n            py,","sourceCodeStart":803,"sourceCodeEnd":839,"githubUrl":"https://github.com/headroomlabs-ai/headroom/blob/322425c43bffde1ed0b64fecf3cf5951565dd82b/crates/headroom-py/src/lib.rs#L803-L839","documentation":"Python-binding panic (not an exception): crush_array_json received an items_json string that serde_json could not parse. The Rust side deliberately panics inside the GIL-released closure because pyo3 panics cross the FFI boundary as Python RuntimeError — it treats malformed JSON as a caller contract violation.","triggerScenarios":"Calling headroom.crush_array_json(items_json, query, bias) with a non-JSON string: Python dict/list passed without json.dumps, a truncated string, single-quoted Python-repr JSON, a JSONDecodeError-causing payload, or an empty string.","commonSituations":"Passing repr(items) or str(items) instead of json.dumps(items); reading JSON from a file/socket that was truncated; encoding/decoding mismatches (e.g. BOM, latin-1) making the string unparseable; empty input '' on startup.","solutions":["Serialize before calling: pass json.dumps(items), never str/repr of a Python object.","Validate on the Python side with json.loads(items_json) first if the string's provenance is uncertain.","Handle empty input before the call (return early or pass '[]').","Wrap the call in try/except RuntimeError to convert the panic into a normal Python error path for defense in depth."],"exampleFix":"# before\nresult = hr.crush_array_json(str(items), query, 0.5)  # str() of a list is not JSON\n\n# after\nimport json\nresult = hr.crush_array_json(json.dumps(items), query, 0.5)","handlingStrategy":"validation","validationCode":"# Validate before calling\nimport json\ndef safe_crush(hr, items_json, query, bias):\n    if not isinstance(items_json, str) or not items_json.strip():\n        raise ValueError(\"items_json must be a non-empty JSON string\")\n    json.loads(items_json)  # raises JSONDecodeError with position, better than a Rust panic\n    return hr.crush_array_json(items_json, query, bias)","typeGuard":"def is_json_string(s: str) -> bool:\n    try:\n        json.loads(s)\n        return True\n    except (json.JSONDecodeError, TypeError):\n        return False","tryCatchPattern":"try:\n    result = hr.crush_array_json(json.dumps(items), query, bias)\nexcept RuntimeError as e:  # pyo3 panic surfaces as RuntimeError\n    log.error(\"crush failed: %s\", e)\n    raise","preventionTips":["Always json.dumps Python objects before passing — never str()/repr().","json.loads-validate strings from untrusted sources before the FFI call.","Handle empty input before calling (pass '[]')."],"tags":["python","json","pyo3","ffi","validation"],"backgroundTag":null,"analyzedSha":"322425c43bffde1ed0b64fecf3cf5951565dd82b","analyzedAt":"2026-08-15T01:03:05.481Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}