can1357/oh-my-pi · error · anyhow::Error

Replacement text is not valid UTF-8: {err}

Error message

Replacement text is not valid UTF-8: {err}

What it means

The stdout reader loop expects every non-empty line from the RPC server to be a JSON frame. A line that fails json.loads is wrapped in RpcError including the line number and a snippet (truncated to 240 chars) so you can see what the server actually printed.

Source

Thrown at crates/pi-ast/src/ops.rs:331

	let mut prev_end = 0usize;
	for edit in &sorted {
		if edit.position < prev_end {
			return Err(anyhow!(
				"Overlapping replacements detected; refine pattern to avoid ambiguous edits"
			));
		}
		prev_end = edit.position.saturating_add(edit.deleted_length);
	}

	let mut output = content.to_string();
	for edit in sorted.into_iter().rev() {
		let start = edit.position;
		let end = edit.position.saturating_add(edit.deleted_length);
		if end > output.len() || start > end {
			return Err(anyhow!("Computed edit range is out of bounds"));
		}
		let replacement = std::str::from_utf8(&edit.inserted_text)
			.map_err(|err| anyhow!("Replacement text is not valid UTF-8: {err}"))?;
		output.replace_range(start..end, replacement);
	}
	Ok(output)
}

pub fn collect_matched_files(
	cwd: &Path,
	patterns: &[String],
) -> Result<Vec<MatchedFile>, std::io::Error> {
	let globset = build_globset(patterns)?;
	let mut builder = WalkBuilder::new(cwd);
	builder
		.hidden(false)
		.git_ignore(true)
		.git_global(true)
		.git_exclude(true);
	let mut files = Vec::new();
	for entry in builder.build() {

View on GitHub (pinned to 9690622007)

Solutions

  1. Look at the Frame snippet in the message to see the offending line — usually a log line or banner.
  2. Configure the server to send all logs/diagnostics to stderr, keeping stdout JSON-only.
  3. Remove shell wrappers/redirects (2>&1) that merge stderr into stdout.
  4. Ensure server and client protocol versions match (line-delimited JSON framing).
  5. Catch RpcError around client usage and log the snippet for diagnosis before retrying.

Example fix

// before (spawning with merged output)
proc = subprocess.Popen(cmd, stderr=subprocess.STDOUT)
// after
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
Defensive patterns

Strategy: try-catch

Validate before calling

# sanity-check the server binary emits line-delimited JSON before wiring the client
test_proc = subprocess.run([server_cmd, "--version"], capture_output=True)
assert not looks_like_plain_text(test_proc.stdout), "server prints non-JSON to stdout"

Type guard

def is_json_line(line: str) -> bool:
    try:
        json.loads(line); return True
    except json.JSONDecodeError:
        return False

Try / catch

try:
    result = await client.request("prompt", payload)
except RpcError as exc:
    if "Failed to decode RPC output" in str(exc):
        logger.error("server stdout contaminated with non-JSON; check the Frame snippet: %s", exc)
    raise

Prevention

When it happens

Trigger: Server printing human-readable text, warnings, debug logs, or tracebacks to stdout instead of only JSON-RPC frames; stderr leaking into stdout via shell redirection; output corruption or mixed encodings.

Common situations: Wrapping the server with a shell wrapper that echoes text, an env var enabling verbose logging that writes to stdout, an incompatible server version emitting a different framing (e.g. Content-Length headers instead of line-delimited JSON).

Understand the failure class

Background: "Invalid JSON response" and "Failed to parse response" errors: when an API answers 200 but the body isn't the JSON your library expected — this error's family across 28 libraries.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/7b3f5053a48484e4. Report an issue: GitHub.