Файли
WangTongchaoтаchaoes 6373bce476 feat(plugins): renew the background budget while a task reports (#2055)
Closes #2053

A call that begins a background task gets the longer `background` budget, but it was armed once, so a task that kept calling `report` was still killed at the first deadline. Each `report` and `is-cancelled` now renews it (the deadline only moves forward; silent work still times out), up to a 30-minute ceiling per task. User Cancel, disable and unload still interrupt the task, teardown interrupts a running task promptly, and cancellation is counted once. WIT change is documentation only. Verified on macOS with a 75 s task.

Co-authored-by: chaoes <[email protected]>
2026-11-08 04:46:58 +08:00

487 рядки
19 KiB
Rust

//! Test fixture for the ide-plugin sandbox. Never shipped.
// `plugin` with syntax and editor providers.
wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/[email protected];
export openidea:plugin/[email protected];
export openidea:plugin/[email protected];
}
",
path: "../../../../../wit",
world: "fixture:misbehave/misbehave",
generate_all,
});
use exports::openidea::plugin::providers::{
self, CodeAction, CompletionItem, Document, FileEdit, InlineSuggestion, LineMarker,
};
use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token};
use exports::openidea::plugin::{commands, event_handler, lifecycle};
use openidea::plugin::{invocation::Invocation, process, types};
struct Misbehave;
thread_local! {
static CHILDREN: std::cell::RefCell<Vec<process::Child>> = const { std::cell::RefCell::new(Vec::new()) };
}
fn failed(message: &str) -> types::Error {
types::Error::Failed(message.into())
}
impl lifecycle::Guest for Misbehave {
fn activate(ctx: types::Context) -> Result<(), types::Error> {
// A deliberately broken build (rollback tests): `version = "…-broken"`.
if ctx.plugin_version.ends_with("-broken") {
return Err(failed("this build is broken"));
}
Ok(())
}
fn deactivate() {}
}
impl commands::Guest for Misbehave {
fn run(id: String, ctx: &Invocation, arg: Option<String>) -> Result<Option<String>, types::Error> {
match id.as_str() {
"echo" => Ok(arg),
"panic" => panic!("boom"),
"spin" => {
let mut x: u64 = 0;
loop {
x = std::hint::black_box(x.wrapping_add(1));
}
}
"alloc" => {
let mut chunks: Vec<Vec<u8>> = Vec::new();
loop {
chunks.push(vec![1u8; 16 << 20]);
std::hint::black_box(&chunks);
}
}
"read" => {
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
project.read_file(&arg.unwrap_or_default()).map(Some)
}
"root" => ctx.project().map(|p| p.root()).transpose(),
// The `fs.read` grants: the text of `arg`, or the refusal naming what it needs.
"fsread" => openidea::plugin::fs::read_file(&arg.unwrap_or_default()).map(Some),
// The `fs.read-dir` listing: "name kind" lines.
"read-dir" => {
use openidea::plugin::workspace::FileKind;
let entries = openidea::plugin::fs::read_dir(&arg.unwrap_or_default())?;
let lines: Vec<String> = entries
.iter()
.map(|e| {
let kind = match e.kind {
FileKind::File => "file",
FileKind::Directory => "dir",
FileKind::Symlink => "link",
FileKind::Other => "other",
};
format!("{kind} {}", e.name)
})
.collect();
Ok(Some(lines.join("\n")))
}
// The `fs.write` grant: replaces the file at `arg`.
"fswrite" => {
openidea::plugin::fs::write_file(&arg.unwrap_or_default(), "written by the plugin\n").map(|()| None)
}
// The `project.write` grant: writes `arg` inside the project.
"projwrite" => {
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
project.write_file(&arg.unwrap_or_default(), "written inside the project\n").map(|()| None)
}
// API 1.3 `process.start`: a program kept running behind a handle.
"child" => {
let cmd = process::Command {
program: "git".into(),
args: vec!["hash-object".into(), "--stdin".into()],
env: Vec::new(),
};
let child = process::start(&cmd, None)?;
child.write("hello\n")?;
child.close_stdin()?;
let chunk = child.wait(30_000)?;
child.kill()?;
Ok(Some(format!("{} {:?}", chunk.stdout.trim(), chunk.status)))
}
"childkeep" | "childtrap" => {
let cmd = process::Command {
program: "git".into(),
args: vec![
"hash-object".into(),
"--stdin".into(),
format!("--path={}", arg.unwrap_or_else(|| "openidea-process-2027".into())),
],
env: Vec::new(),
};
let child = process::start(&cmd, None)?;
CHILDREN.with(|children| children.borrow_mut().push(child));
if id == "childtrap" {
panic!("trap with a live child");
}
Ok(Some("running".into()))
}
"childpoll" => CHILDREN.with(|children| {
let children = children.borrow();
let child = children.last().ok_or_else(|| failed("no child"))?;
Ok(Some(format!("{:?}", child.poll(100)?.status)))
}),
"childdrop" => {
CHILDREN.with(|children| children.borrow_mut().clear());
Ok(None)
}
"envrun" | "envstart" | "envdeny-run" | "envdeny-start" => {
if arg.as_deref() == Some("legacy") {
return Ok(Some(process::run("git", &["--version".into()], None, None)?.stdout));
}
let env = if id.starts_with("envdeny") {
vec![(arg.unwrap_or_else(|| "PATH".into()), "replacement".into())]
} else {
vec![("OPENIDEA_PROCESS_TEST_VALUE".into(), "added by the plugin".into())]
};
let cmd = process::Command {
program: "git".into(),
args: vec![
"--config-env=openidea.test=OPENIDEA_PROCESS_TEST_VALUE".into(),
"config".into(),
"--get".into(),
"openidea.test".into(),
],
env,
};
if id.ends_with("start") {
let child = process::start(&cmd, None)?;
Ok(Some(child.wait(30_000)?.stdout))
} else {
Ok(Some(process::run_command(&cmd, None, None)?.stdout))
}
}
// A task that keeps reporting is still working: the call may run
// past the background budget it began with.
"patient" => {
let task = openidea::plugin::tasks::begin("waiting", false);
let cmd = process::Command {
program: "git".into(),
args: vec!["hash-object".into(), "--stdin".into()],
env: Vec::new(),
};
let child = process::start(&cmd, None)?;
for step in 0..25 {
task.report(step, Some(25), Some("waiting"));
// Waiting blocks in the host for 100 ms: wall time passes
// while the plugin itself is not running.
let _ = child.wait(100);
}
// Let it finish on its own: no kill, no slow teardown.
child.close_stdin()?;
let _ = child.wait(5_000);
task.report(25, Some(25), Some("done"));
Ok(Some("still here".into()))
}
// `editor.write` is not granted: an error, not a trap.
"insert" => {
let editor = ctx.editor().ok_or_else(|| failed("no editor"))?;
editor.apply_edits(0, &[]).map(|_| None)
}
// The user gave up: not the plugin failing.
"cancel" => Err(types::Error::Cancelled),
// Event kinds are names: one this IDE doesn't know is refused.
"subscribe" => openidea::plugin::events::subscribe(&arg.unwrap_or_default()).map(|()| None),
// Tagged problems from another tool on the line 0 of `arg`.
"diagnose" => {
use openidea::plugin::diagnostics::{self, Diagnostic, Severity};
let start = types::Position { line: 0, character: 0 };
let d = Diagnostic {
range: types::Range { start, end: start },
severity: Severity::Warning,
message: "unused".into(),
code: None,
source: Some("lint".into()),
tags: vec!["unnecessary".into(), "glowing".into()],
related: Vec::new(),
};
diagnostics::publish(&arg.unwrap_or_default(), &[d]).map(|()| None)
}
other => Err(types::Error::NotFound(format!("command {other}"))),
}
}
}
impl event_handler::Guest for Misbehave {
fn handle(_events: Vec<openidea::plugin::events::Event>, _ctx: &Invocation) {}
}
/// Edit-path budgets: the text says how to behave. `sleep:<ms>` busy-waits
/// that long, `spin` never returns, `panic` traps.
fn behave(text: &str) {
if let Some(ms) = text.strip_prefix("sleep:").and_then(|n| n.trim().parse::<u64>().ok()) {
let until = std::time::Instant::now() + std::time::Duration::from_millis(ms);
while std::time::Instant::now() < until {
std::hint::black_box(0);
}
} else if text == "spin" {
loop {
std::hint::black_box(0);
}
} else if text == "panic" {
panic!("boom");
}
}
/// One keyword token over the whole text, after [`behave`].
impl syntax_provider::Guest for Misbehave {
fn highlights(_language: String, text: String) -> Vec<Token> {
behave(&text);
vec![Token { start: 0, end: text.len() as u32, kind: "keyword".into() }]
}
fn outline(_language: String, _text: String) -> Vec<Symbol> {
Vec::new()
}
fn folding(_language: String, _text: String) -> Vec<Fold> {
Vec::new()
}
}
fn action_answer(kind: &str) -> Result<String, types::Error> {
use openidea::plugin::{ui, views};
let items = vec![
ui::PickItem { key: "a".into(), label: "Alpha".into(), description: None },
ui::PickItem { key: "b".into(), label: "Beta".into(), description: None },
];
let answer = match kind {
"pick" => ui::pick("Choose translation file", &items)?,
"pick-many" => ui::pick_many("Choose services", &items)?.map(|keys| keys.join(",")),
"input" => ui::input("Service name", Some("Service"))?,
"confirm" => ui::confirm("Rewrite", "Apply this rewrite?")?.then(|| "confirmed".into()),
"input-multiline" => ui::input_multiline("Not allowed", None)?,
"reveal" => {
views::reveal("missing")?;
None
}
"open-url" => {
ui::open_url("https://example.com")?;
None
}
"clipboard" => {
ui::copy_to_clipboard("not allowed")?;
None
}
"pick-directory" => ui::pick_directory("Not allowed", None)?,
"open-project" => {
openidea::plugin::workspace::open_project("/not-allowed", false)?;
None
}
_ => return Err(types::Error::Invalid("unknown prompt".into())),
};
answer.ok_or(types::Error::Cancelled)
}
/// Completion: one item, labelled with the document's path, after [`behave`].
impl providers::Guest for Misbehave {
fn completion(doc: Document, _at: types::Position, _trigger: Option<String>) -> Vec<CompletionItem> {
if doc.text == "prompt-provider" {
assert!(matches!(action_answer("input"), Err(types::Error::Invalid(_))));
}
behave(&doc.text);
// `rich`: a snippet that also adds an import.
let rich = doc.text == "rich";
let start = types::Position { line: 0, character: 0 };
vec![CompletionItem {
label: doc.path.clone(),
detail: None,
kind: Some("keyword".into()),
insert_text: if rich { "f(${1:x})$0".into() } else { doc.path },
snippet: rich,
replace: None,
sort_text: None,
filter_text: rich.then(|| "fun".into()),
documentation: None,
additional_edits: if rich {
vec![types::TextEdit { range: types::Range { start, end: start }, text: "use f;\n".into() }]
} else {
Vec::new()
},
}]
}
fn inline_completion(_doc: Document, _at: types::Position) -> Option<InlineSuggestion> {
None
}
/// `lazy`: one action whose edits come from `resolve-code-action`.
fn code_actions(doc: Document, _selection: types::Range) -> Vec<CodeAction> {
if doc.text == "prompts" {
return [
"pick",
"pick-many",
"input",
"confirm",
"input-multiline",
"reveal",
"open-url",
"clipboard",
"pick-directory",
"open-project",
]
.into_iter()
.map(|kind| CodeAction {
title: format!("Ask {kind}"),
kind: Some("quickfix".into()),
preferred: false,
edits: Vec::new(),
data: Some(kind.into()),
})
.collect();
}
if doc.text != "lazy" {
return Vec::new();
}
vec![CodeAction {
title: "Fix later".into(),
kind: Some("quickfix".into()),
preferred: false,
edits: Vec::new(),
data: Some("fix".into()),
}]
}
fn resolve_code_action(doc: Document, action: CodeAction) -> Result<CodeAction, types::Error> {
if doc.text == "prompts" {
let kind = action.data.as_deref().unwrap_or_default();
let answer = action_answer(kind)?;
// Longer than the 50 ms provider budget, within the command budget.
behave("sleep:80");
let start = types::Position { line: 0, character: 0 };
let edit = types::TextEdit { range: types::Range { start, end: start }, text: answer };
return Ok(CodeAction {
edits: vec![FileEdit { path: doc.path, edits: vec![edit] }],
data: None,
..action
});
}
if action.data.as_deref() != Some("fix") {
return Err(types::Error::Invalid("not one of mine".into()));
}
let start = types::Position { line: 0, character: 0 };
let edit = types::TextEdit { range: types::Range { start, end: start }, text: "fixed ".into() };
Ok(CodeAction { edits: vec![FileEdit { path: doc.path, edits: vec![edit] }], ..action })
}
fn definition(_doc: Document, _at: types::Position) -> Vec<types::Location> {
Vec::new()
}
fn line_markers(doc: Document) -> Vec<LineMarker> {
if !doc.text.starts_with("marker-icons") {
return Vec::new();
}
["run", "database", "icons/very-long-resource-marker-icon.svg", "overriding"]
.into_iter()
.enumerate()
.map(|(line, icon)| LineMarker {
line: line as u32,
icon: Some(icon.into()),
tooltip: format!("Marker: {icon}"),
target: None,
})
.collect()
}
}
/// `files`: one action that creates, renames and deletes files, then opens
/// one, and one listed with `data` only, whose file `resolve` creates.
impl exports::openidea::plugin::file_actions::Guest for Misbehave {
fn code_actions(
doc: exports::openidea::plugin::file_actions::Document,
_selection: types::Range,
) -> Vec<exports::openidea::plugin::file_actions::FileAction> {
use exports::openidea::plugin::file_actions::{CreateFile, DeleteFile, FileAction, FileOperation, RenameFile};
if doc.text == "prompt-files" {
return vec![FileAction {
title: "Create a service".into(),
kind: Some("quickfix".into()),
preferred: false,
operations: Vec::new(),
edits: Vec::new(),
open: None,
data: Some("input".into()),
}];
}
if doc.text != "files" {
return Vec::new();
}
let dir = doc.path.rsplit_once('/').map_or("", |(dir, _)| dir).to_owned();
vec![
FileAction {
title: "Reorganise".into(),
kind: Some("quickfix".into()),
preferred: false,
operations: vec![
FileOperation::Create(CreateFile {
path: format!("{dir}/created.txt"),
text: "hello".into(),
overwrite: false,
}),
FileOperation::Rename(RenameFile {
path: format!("{dir}/old.txt"),
new_path: format!("{dir}/renamed.txt"),
}),
FileOperation::Delete(DeleteFile { path: format!("{dir}/gone.txt") }),
],
edits: Vec::new(),
open: Some(format!("{dir}/created.txt")),
data: None,
},
FileAction {
title: "Create later".into(),
kind: Some("quickfix".into()),
preferred: false,
operations: Vec::new(),
edits: Vec::new(),
open: None,
data: Some(dir),
},
]
}
fn resolve(
doc: exports::openidea::plugin::file_actions::Document,
action: exports::openidea::plugin::file_actions::FileAction,
) -> Result<exports::openidea::plugin::file_actions::FileAction, types::Error> {
use exports::openidea::plugin::file_actions::{CreateFile, FileOperation};
if doc.text == "prompt-files" {
let name = action_answer("input")?;
let dir = doc.path.rsplit_once('/').map_or("", |(dir, _)| dir);
let path = format!("{dir}/{name}.txt");
return Ok(exports::openidea::plugin::file_actions::FileAction {
operations: vec![FileOperation::Create(CreateFile {
path: path.clone(),
text: name,
overwrite: false,
})],
open: Some(path),
data: None,
..action
});
}
let dir = action.data.clone().ok_or_else(|| types::Error::Invalid("no data".into()))?;
let operations = vec![FileOperation::Create(CreateFile {
path: format!("{dir}/later.txt"),
text: "later".into(),
overwrite: false,
})];
Ok(exports::openidea::plugin::file_actions::FileAction { operations, data: None, ..action })
}
}
export!(Misbehave);