From f4ff2feefa34580a82102b07b8e449c895b3fd67 Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Wed, 11 Nov 2026 17:31:52 -0800 Subject: [PATCH] fix(plugins): raise process stdout limit to 16 MiB and surface truncation (#2838) process.run kept at most 4 MiB of stdout and silently dropped the rest while reporting success, so large structured output (kubectl -o json, cargo metadata) reached plugins cut off mid-document. The limit is now 16 MiB, as for file reads, and stdout beyond it fails the call with an explicit error instead of returning truncated output. stderr is still truncated without failing. Co-authored-by: dengliu <5108773+dengliu@users.noreply.github.com> --- crates/ide-host/src/plugins/api.rs | 28 ++++++++++++++++++-------- crates/ide-host/tests/plugins.rs | 32 ++++++++++++++++++++++++++++++ plugins/README.md | 2 +- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 1f04d0bd1..6aed8866a 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -180,7 +180,7 @@ impl SecretStore for FileSecretStore { /// Largest response body a plugin receives. const MAX_RESPONSE: usize = 8 << 20; /// Largest stdout / stderr kept from a process. -const MAX_OUTPUT: usize = 4 << 20; +const MAX_OUTPUT: usize = 16 << 20; /// Most processes one plugin keeps running at once. const MAX_PROCESSES: usize = 8; /// Most output one stream of a handle holds until the plugin reads it. @@ -2443,9 +2443,14 @@ impl HostApi for PluginApi { let read = |pipe: Option>| { std::thread::spawn(move || { let mut out = Vec::new(); + let mut overflow = false; if let Some(p) = pipe { let mut p = p; - let _ = p.by_ref().take(MAX_OUTPUT as u64).read_to_end(&mut out); + let _ = p.by_ref().take((MAX_OUTPUT + 1) as u64).read_to_end(&mut out); + if out.len() > MAX_OUTPUT { + overflow = true; + out.truncate(MAX_OUTPUT); + } let _ = std::io::copy(&mut p, &mut std::io::sink()); } let mut text = String::from_utf8_lossy(&out).into_owned(); @@ -2454,7 +2459,7 @@ impl HostApi for PluginApi { end -= 1; } text.truncate(end); - text + (text, overflow) }) }; let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box)); @@ -2474,11 +2479,18 @@ impl HostApi for PluginApi { } std::thread::sleep(Duration::from_millis(10)); }; - Ok(ProcessOutput { - status: status.code(), - stdout: stdout.join().unwrap_or_default(), - stderr: stderr.join().unwrap_or_default(), - }) + let (stdout, stdout_overflow) = stdout.join().unwrap_or_default(); + let (stderr, _) = stderr.join().unwrap_or_default(); + if stdout_overflow { + tracing::warn!( + plugin = %self.id(), + program = %cmd.program, + limit_mib = MAX_OUTPUT >> 20, + "process stdout exceeded limit" + ); + return Err(HostError::Failed(format!("{} stdout exceeded {} MiB limit", cmd.program, MAX_OUTPUT >> 20))); + } + Ok(ProcessOutput { status: status.code(), stdout, stderr }) } fn subscribe(&self, kind: EventKind) { diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index bac7a2496..842849f8c 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -6683,6 +6683,38 @@ fn process_timeout_returns_guest_error_without_disabling_plugin() { assert_eq!(info.stats.crashes, 0); } +#[cfg(unix)] +#[test] +fn process_stdout_accepts_large_payloads_and_reports_overflow_explicitly() { + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits { timeout: Duration::from_secs(10), ..Limits::default() }); + let package = f._tmp.path().join("api14-large-proc"); + copy_dir(&sample("api14"), &package); + let manifest = package.join("plugin.toml"); + let text = + std::fs::read_to_string(&manifest).unwrap().replace("optional = [", "optional = [\"process:api14-tool\", "); + std::fs::write(manifest, text).unwrap(); + f.install(&package); + + let medium_bytes = 5 * 1024 * 1024; + let medium = f._tmp.path().join("medium-tool"); + std::fs::write(&medium, "#!/bin/sh\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null | tr '\\0' 'x'\n").unwrap(); + std::fs::set_permissions(&medium, std::fs::Permissions::from_mode(0o755)).unwrap(); + let medium = medium.canonicalize().unwrap(); + f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&medium)).unwrap(); + let out = f.run("api14", "process-run", None).unwrap().unwrap(); + assert_eq!(out.len(), medium_bytes); + + let huge = f._tmp.path().join("huge-tool"); + std::fs::write(&huge, "#!/bin/sh\ndd if=/dev/zero bs=1048576 count=17 2>/dev/null | tr '\\0' 'x'\n").unwrap(); + std::fs::set_permissions(&huge, std::fs::Permissions::from_mode(0o755)).unwrap(); + let huge = huge.canonicalize().unwrap(); + f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&huge)).unwrap(); + let err = f.run("api14", "process-run", None).unwrap_err(); + assert!(err.contains("stdout exceeded 16 MiB limit"), "{err}"); + assert_eq!(f.host.info_of("api14").unwrap().stats.crashes, 0); +} + /// A link already in the project is written through, so it is judged by where it points. #[cfg(unix)] #[test] diff --git a/plugins/README.md b/plugins/README.md index dce5b21b1..b85d652c0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -622,7 +622,7 @@ then run `openidea-plugin check`. | `Project::symbols(query, limit)` | `workspace.symbols` | New in 1.3; `project.read`. Fuzzy in-memory search (no LSP): name, camelCase kind, container and UTF-16 location. Queries ≤ 256 UTF-8 bytes; limits capped at 1000. Snapshot notes below. | | `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. | | `host::fetch(request)` → `{status, headers, body}` | `net.fetch` | See _Network_. | -| `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; 4 MiB of output; killed when the call is cancelled or times out. | +| `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; up to 16 MiB of stdout (fails if exceeded); killed when the call is cancelled or times out. | | `host::run_command(cmd, project, stdin)` → `{code, stdout, stderr}` | `process.run-command` | API 1.3: same limits as `run_process`, with append-only environment variables; needs the pinned `process:` grant. | | `host::start(cmd, project)` → `Child` | `process.start` | API 1.3: `poll(max_bytes)`, `write(text)`, `close_stdin()`, `wait(timeout_ms)`, `kill()`; at most 8 handles; dropping one kills its process. | | `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. |