Source commit: fc8380260013119c2535fa0ec860c9e0581c5896 Public tree identity: sha256:2edc3fda481ae8b3b5f4949811e496ec787aa436aa26bd070e14ac410ba75adc
2572 lines
87 KiB
Rust
2572 lines
87 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::fs;
|
|
use std::io::{self, BufRead, BufReader, Write};
|
|
use std::net::TcpStream;
|
|
use std::path::Path;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{anyhow, Result};
|
|
use disasmer_core::{DebugRuntimeState, Digest, ProcessId, TaskId};
|
|
use disasmer_node::WasmtimeTaskRuntime;
|
|
use serde_json::{json, Value};
|
|
|
|
const MAIN_THREAD: i64 = 1;
|
|
const LINUX_THREAD: i64 = 2;
|
|
const WINDOWS_THREAD: i64 = 3;
|
|
const PACKAGE_THREAD: i64 = 4;
|
|
|
|
#[derive(Clone)]
|
|
struct DapWriter {
|
|
seq: i64,
|
|
}
|
|
|
|
impl DapWriter {
|
|
fn new() -> Self {
|
|
Self { seq: 1 }
|
|
}
|
|
|
|
fn response(&mut self, request: &Value, success: bool, body: Value) -> io::Result<()> {
|
|
let command = request
|
|
.get("command")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("<unknown>");
|
|
let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0);
|
|
let seq = self.next_seq();
|
|
self.write(json!({
|
|
"seq": seq,
|
|
"type": "response",
|
|
"request_seq": request_seq,
|
|
"success": success,
|
|
"command": command,
|
|
"body": body,
|
|
}))
|
|
}
|
|
|
|
fn error_response(&mut self, request: &Value, message: impl Into<String>) -> io::Result<()> {
|
|
let command = request
|
|
.get("command")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("<unknown>");
|
|
let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0);
|
|
let seq = self.next_seq();
|
|
self.write(json!({
|
|
"seq": seq,
|
|
"type": "response",
|
|
"request_seq": request_seq,
|
|
"success": false,
|
|
"command": command,
|
|
"message": message.into(),
|
|
}))
|
|
}
|
|
|
|
fn event(&mut self, event: &str, body: Value) -> io::Result<()> {
|
|
let seq = self.next_seq();
|
|
self.write(json!({
|
|
"seq": seq,
|
|
"type": "event",
|
|
"event": event,
|
|
"body": body,
|
|
}))
|
|
}
|
|
|
|
fn output(&mut self, category: &str, output: impl Into<String>) -> io::Result<()> {
|
|
self.event(
|
|
"output",
|
|
json!({
|
|
"category": category,
|
|
"output": output.into(),
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn next_seq(&mut self) -> i64 {
|
|
let seq = self.seq;
|
|
self.seq += 1;
|
|
seq
|
|
}
|
|
|
|
fn write(&mut self, message: Value) -> io::Result<()> {
|
|
let payload = serde_json::to_vec(&message)?;
|
|
let mut out = io::stdout().lock();
|
|
write!(out, "Content-Length: {}\r\n\r\n", payload.len())?;
|
|
out.write_all(&payload)?;
|
|
out.flush()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct VirtualThread {
|
|
id: i64,
|
|
frame_id: i64,
|
|
locals_ref: i64,
|
|
wasm_locals_ref: i64,
|
|
args_ref: i64,
|
|
runtime_ref: i64,
|
|
output_ref: i64,
|
|
target_ref: i64,
|
|
vfs_ref: i64,
|
|
command_ref: i64,
|
|
task: TaskId,
|
|
name: String,
|
|
line: i64,
|
|
state: DebugRuntimeState,
|
|
freeze_supported: bool,
|
|
recent_output: Vec<String>,
|
|
stdout_bytes: u64,
|
|
stderr_bytes: u64,
|
|
stdout_tail: String,
|
|
stderr_tail: String,
|
|
stdout_truncated: bool,
|
|
stderr_truncated: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct AdapterState {
|
|
process: ProcessId,
|
|
epoch: u64,
|
|
entry: String,
|
|
project: String,
|
|
source_path: String,
|
|
runtime_backend: RuntimeBackend,
|
|
operator_endpoint: String,
|
|
tenant: String,
|
|
project_id: String,
|
|
actor_user: String,
|
|
session_mode: DapSessionMode,
|
|
command_status: String,
|
|
last_task_failed: bool,
|
|
runtime_artifact_path: Option<String>,
|
|
runtime_event_count: usize,
|
|
breakpoints: Vec<i64>,
|
|
threads: BTreeMap<i64, VirtualThread>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
enum RuntimeBackend {
|
|
Simulated,
|
|
LocalServices,
|
|
LiveServices,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
enum DapSessionMode {
|
|
Launch,
|
|
Attach,
|
|
}
|
|
|
|
impl RuntimeBackend {
|
|
fn description(&self) -> &'static str {
|
|
match self {
|
|
RuntimeBackend::Simulated => "simulated debug adapter state",
|
|
RuntimeBackend::LocalServices => "local services",
|
|
RuntimeBackend::LiveServices => "live services",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for AdapterState {
|
|
fn default() -> Self {
|
|
let entry = "build".to_owned();
|
|
let project = ".".to_owned();
|
|
Self {
|
|
process: process_id(&project, &entry),
|
|
epoch: 0,
|
|
threads: launch_threads(&entry),
|
|
entry,
|
|
project,
|
|
source_path: "src/build.rs".to_owned(),
|
|
runtime_backend: RuntimeBackend::Simulated,
|
|
operator_endpoint: "disasmer.michelpaulissen.com:9443".to_owned(),
|
|
tenant: "tenant".to_owned(),
|
|
project_id: "project".to_owned(),
|
|
actor_user: "dap".to_owned(),
|
|
session_mode: DapSessionMode::Launch,
|
|
command_status: "waiting for launch".to_owned(),
|
|
last_task_failed: false,
|
|
runtime_artifact_path: None,
|
|
runtime_event_count: 0,
|
|
breakpoints: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AdapterState {
|
|
fn configure_launch(
|
|
&mut self,
|
|
project: String,
|
|
entry: String,
|
|
runtime_backend: RuntimeBackend,
|
|
operator_endpoint: String,
|
|
tenant: String,
|
|
project_id: String,
|
|
actor_user: String,
|
|
source_path: Option<String>,
|
|
) {
|
|
self.process = process_id(&project, &entry);
|
|
self.entry = entry;
|
|
self.source_path = source_path.unwrap_or_else(|| infer_source_path(&project));
|
|
self.project = project;
|
|
self.runtime_backend = runtime_backend;
|
|
self.operator_endpoint = normalize_operator_endpoint(&operator_endpoint);
|
|
self.tenant = tenant;
|
|
self.project_id = project_id;
|
|
self.actor_user = actor_user;
|
|
self.session_mode = DapSessionMode::Launch;
|
|
self.command_status = "waiting for configurationDone".to_owned();
|
|
self.last_task_failed = false;
|
|
self.runtime_artifact_path = None;
|
|
self.runtime_event_count = 0;
|
|
self.epoch = 0;
|
|
self.breakpoints.clear();
|
|
self.threads = launch_threads(&self.entry);
|
|
}
|
|
|
|
fn apply_runtime_record(&mut self, record: RuntimeLaunchRecord) {
|
|
if !record.placed_task_launched {
|
|
self.command_status = format!(
|
|
"coordinator-side virtual process started through {}; no capability task launched yet",
|
|
self.runtime_backend.description()
|
|
);
|
|
self.last_task_failed = false;
|
|
self.runtime_artifact_path = None;
|
|
self.runtime_event_count = record.event_count;
|
|
if let Some(thread) = self.threads.get_mut(&MAIN_THREAD) {
|
|
thread.recent_output.push(format!(
|
|
"coordinator-side process started through {}",
|
|
self.runtime_backend.description()
|
|
));
|
|
thread
|
|
.recent_output
|
|
.push("a Command-capability virtual task will need a capable node when the process reaches it".to_owned());
|
|
}
|
|
let _ = write_view_state(self, &record);
|
|
return;
|
|
}
|
|
|
|
let task_failed = record.status_code != Some(0);
|
|
self.command_status = format!(
|
|
"{} through {} with status {:?}",
|
|
if task_failed { "failed" } else { "completed" },
|
|
self.runtime_backend.description(),
|
|
record.status_code
|
|
);
|
|
self.last_task_failed = task_failed;
|
|
self.runtime_artifact_path = record.artifact_path.clone();
|
|
self.runtime_event_count = record.event_count;
|
|
if let Some(thread) = self.threads.get_mut(&LINUX_THREAD) {
|
|
thread.stdout_bytes = record.stdout_bytes;
|
|
thread.stderr_bytes = record.stderr_bytes;
|
|
thread.stdout_tail = record.stdout_tail.clone();
|
|
thread.stderr_tail = record.stderr_tail.clone();
|
|
thread.stdout_truncated = record.stdout_truncated;
|
|
thread.stderr_truncated = record.stderr_truncated;
|
|
thread.recent_output.push(format!(
|
|
"attached node {} task with {} stdout bytes and {} stderr bytes",
|
|
if task_failed { "failed" } else { "completed" },
|
|
record.stdout_bytes,
|
|
record.stderr_bytes
|
|
));
|
|
thread.recent_output.push(format!(
|
|
"coordinator recorded {} task event(s)",
|
|
record.event_count
|
|
));
|
|
if task_failed {
|
|
thread.state = DebugRuntimeState::Failed(format!(
|
|
"local services task exited with status {:?}",
|
|
record.status_code
|
|
));
|
|
}
|
|
}
|
|
let _ = write_view_state(self, &record);
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct RuntimeLaunchRecord {
|
|
coordinator: String,
|
|
node: String,
|
|
node_report: Value,
|
|
task_events: Value,
|
|
placed_task_launched: bool,
|
|
status_code: Option<i32>,
|
|
stdout_bytes: u64,
|
|
stderr_bytes: u64,
|
|
stdout_tail: String,
|
|
stderr_tail: String,
|
|
stdout_truncated: bool,
|
|
stderr_truncated: bool,
|
|
artifact_path: Option<String>,
|
|
event_count: usize,
|
|
}
|
|
|
|
fn launch_threads(entry: &str) -> BTreeMap<i64, VirtualThread> {
|
|
[
|
|
thread(MAIN_THREAD, "main", &format!("{entry} virtual process"), 12),
|
|
thread(LINUX_THREAD, "compile-linux", "compile linux", 42),
|
|
thread(WINDOWS_THREAD, "compile-windows", "compile windows", 52),
|
|
thread(PACKAGE_THREAD, "package", "package artifacts", 64),
|
|
]
|
|
.into_iter()
|
|
.map(|thread| (thread.id, thread))
|
|
.collect()
|
|
}
|
|
|
|
fn process_id(project: &str, entry: &str) -> ProcessId {
|
|
let digest = Digest::from_parts([
|
|
b"dap-process:v1".as_slice(),
|
|
project.as_bytes(),
|
|
entry.as_bytes(),
|
|
]);
|
|
let suffix = digest
|
|
.as_str()
|
|
.trim_start_matches("sha256:")
|
|
.chars()
|
|
.take(12)
|
|
.collect::<String>();
|
|
ProcessId::new(format!("vp-{suffix}"))
|
|
}
|
|
|
|
fn thread(id: i64, task: &str, name: &str, line: i64) -> VirtualThread {
|
|
VirtualThread {
|
|
id,
|
|
frame_id: 1000 + id,
|
|
locals_ref: 5000 + id,
|
|
wasm_locals_ref: 9000 + id,
|
|
args_ref: 2000 + id,
|
|
runtime_ref: 3000 + id,
|
|
output_ref: 4000 + id,
|
|
target_ref: 6000 + id,
|
|
vfs_ref: 7000 + id,
|
|
command_ref: 8000 + id,
|
|
task: TaskId::from(task),
|
|
name: name.to_owned(),
|
|
line,
|
|
state: DebugRuntimeState::Running,
|
|
freeze_supported: true,
|
|
recent_output: vec![format!("{name}: waiting")],
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 0,
|
|
stdout_tail: String::new(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
}
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
let mut writer = DapWriter::new();
|
|
let mut reader = BufReader::new(io::stdin());
|
|
let mut state = AdapterState::default();
|
|
|
|
while let Some(request) = read_message(&mut reader)? {
|
|
let command = request
|
|
.get("command")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default();
|
|
match command {
|
|
"initialize" => writer.response(&request, true, initialize_capabilities())?,
|
|
"launch" | "attach" => {
|
|
let args = request
|
|
.get("arguments")
|
|
.cloned()
|
|
.unwrap_or_else(|| json!({}));
|
|
state.entry = args
|
|
.get("entry")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("build")
|
|
.to_owned();
|
|
let project = args
|
|
.get("project")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(".")
|
|
.to_owned();
|
|
let runtime_backend = match args.get("runtimeBackend").and_then(Value::as_str) {
|
|
Some("local-services") => RuntimeBackend::LocalServices,
|
|
Some("live-services") => RuntimeBackend::LiveServices,
|
|
_ => RuntimeBackend::Simulated,
|
|
};
|
|
let operator_endpoint = args
|
|
.get("operatorEndpoint")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("disasmer.michelpaulissen.com:9443")
|
|
.to_owned();
|
|
let tenant = args
|
|
.get("tenant")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("tenant")
|
|
.to_owned();
|
|
let project_id = args
|
|
.get("projectId")
|
|
.or_else(|| args.get("project_id"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("project")
|
|
.to_owned();
|
|
let actor_user = args
|
|
.get("actorUser")
|
|
.or_else(|| args.get("actor_user"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("dap")
|
|
.to_owned();
|
|
let source_path = args
|
|
.get("sourcePath")
|
|
.or_else(|| args.get("source_path"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned);
|
|
state.configure_launch(
|
|
project,
|
|
state.entry.clone(),
|
|
runtime_backend,
|
|
operator_endpoint,
|
|
tenant,
|
|
project_id,
|
|
actor_user,
|
|
source_path,
|
|
);
|
|
if command == "attach" {
|
|
state.session_mode = DapSessionMode::Attach;
|
|
state.command_status =
|
|
format!("attached to existing virtual process {}", state.process);
|
|
}
|
|
writer.response(&request, true, json!({}))?;
|
|
writer.event("initialized", json!({}))?;
|
|
}
|
|
"setBreakpoints" => {
|
|
state.breakpoints = request
|
|
.get("arguments")
|
|
.and_then(|value| value.get("breakpoints"))
|
|
.and_then(Value::as_array)
|
|
.map(|items| {
|
|
items
|
|
.iter()
|
|
.filter_map(|item| item.get("line").and_then(Value::as_i64))
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
let response = state
|
|
.breakpoints
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, line)| {
|
|
json!({
|
|
"id": index + 1,
|
|
"verified": true,
|
|
"line": line,
|
|
"message": "Mapped to Disasmer virtual source location",
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
writer.response(&request, true, json!({ "breakpoints": response }))?;
|
|
}
|
|
"setExceptionBreakpoints" => {
|
|
writer.response(&request, true, json!({ "breakpoints": [] }))?;
|
|
}
|
|
"configurationDone" => {
|
|
if state.session_mode == DapSessionMode::Attach {
|
|
state.command_status =
|
|
format!("attached to existing virtual process {}", state.process);
|
|
} else {
|
|
match state.runtime_backend {
|
|
RuntimeBackend::LocalServices => match run_local_services_runtime(&state) {
|
|
Ok(record) => {
|
|
state.apply_runtime_record(record);
|
|
}
|
|
Err(err) => {
|
|
writer.error_response(
|
|
&request,
|
|
format!("local services runtime launch failed: {err:#}"),
|
|
)?;
|
|
continue;
|
|
}
|
|
},
|
|
RuntimeBackend::LiveServices => match run_live_services_runtime(&state) {
|
|
Ok(record) => {
|
|
state.apply_runtime_record(record);
|
|
}
|
|
Err(err) => {
|
|
writer.error_response(
|
|
&request,
|
|
format!("live services runtime launch failed: {err:#}"),
|
|
)?;
|
|
continue;
|
|
}
|
|
},
|
|
RuntimeBackend::Simulated => {
|
|
state.command_status =
|
|
"running under simulated debug adapter state".to_owned();
|
|
}
|
|
}
|
|
}
|
|
writer.response(&request, true, json!({}))?;
|
|
let session_message = if state.session_mode == DapSessionMode::Attach {
|
|
format!(
|
|
"Attached to Disasmer virtual process {} with {:?} runtime\n",
|
|
state.process, state.runtime_backend
|
|
)
|
|
} else {
|
|
format!(
|
|
"Disasmer bundle refreshed for entry `{}`; starting virtual process {} with {:?} runtime\n",
|
|
state.entry, state.process, state.runtime_backend
|
|
)
|
|
};
|
|
writer.output("console", session_message)?;
|
|
if !state.breakpoints.is_empty() {
|
|
let stopped_thread = stopped_thread_for_breakpoint(&state);
|
|
match freeze_all(&mut state, stopped_thread, None) {
|
|
Ok(()) => {
|
|
writer.event(
|
|
"stopped",
|
|
json!({
|
|
"reason": "breakpoint",
|
|
"description": "Disasmer Debug Epoch all-stop",
|
|
"threadId": stopped_thread,
|
|
"allThreadsStopped": true,
|
|
}),
|
|
)?;
|
|
}
|
|
Err(failure) => {
|
|
let message = failure.message();
|
|
writer.output("stderr", format!("{message}\n"))?;
|
|
writer.error_response(&request, message)?;
|
|
}
|
|
}
|
|
} else {
|
|
writer.event("terminated", json!({}))?;
|
|
}
|
|
}
|
|
"threads" => {
|
|
let threads = state
|
|
.threads
|
|
.values()
|
|
.map(|thread| {
|
|
json!({
|
|
"id": thread.id,
|
|
"name": format!("{}/{}", state.process, thread.name),
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
writer.response(&request, true, json!({ "threads": threads }))?;
|
|
}
|
|
"stackTrace" => {
|
|
let thread = request_thread(&request, &state);
|
|
let source_path = stack_source_path(&state);
|
|
writer.response(
|
|
&request,
|
|
true,
|
|
json!({
|
|
"stackFrames": [{
|
|
"id": thread.frame_id,
|
|
"name": format!("{}::run", thread.name),
|
|
"line": thread.line,
|
|
"column": 1,
|
|
"source": {
|
|
"name": source_name(&source_path),
|
|
"path": source_path,
|
|
"presentationHint": "normal"
|
|
}
|
|
}],
|
|
"totalFrames": 1
|
|
}),
|
|
)?;
|
|
}
|
|
"source" => match source_response(&state, &request) {
|
|
Ok(body) => writer.response(&request, true, body)?,
|
|
Err(err) => writer.error_response(&request, err.to_string())?,
|
|
},
|
|
"scopes" => {
|
|
let frame_id = request
|
|
.get("arguments")
|
|
.and_then(|value| value.get("frameId"))
|
|
.and_then(Value::as_i64)
|
|
.unwrap_or(1000 + LINUX_THREAD);
|
|
let thread = state
|
|
.threads
|
|
.values()
|
|
.find(|thread| thread.frame_id == frame_id)
|
|
.cloned()
|
|
.unwrap_or_else(|| state.threads[&LINUX_THREAD].clone());
|
|
writer.response(
|
|
&request,
|
|
true,
|
|
json!({
|
|
"scopes": [
|
|
{
|
|
"name": "Source Locals",
|
|
"variablesReference": thread.locals_ref,
|
|
"expensive": false,
|
|
"presentationHint": "locals"
|
|
},
|
|
{
|
|
"name": "Wasm Frame Locals",
|
|
"variablesReference": thread.wasm_locals_ref,
|
|
"expensive": false,
|
|
"presentationHint": "locals"
|
|
},
|
|
{
|
|
"name": "Task Args and Handles",
|
|
"variablesReference": thread.args_ref,
|
|
"expensive": false,
|
|
"presentationHint": "arguments"
|
|
},
|
|
{
|
|
"name": "Disasmer Runtime",
|
|
"variablesReference": thread.runtime_ref,
|
|
"expensive": false
|
|
},
|
|
{
|
|
"name": "Recent Output",
|
|
"variablesReference": thread.output_ref,
|
|
"expensive": false
|
|
}
|
|
]
|
|
}),
|
|
)?;
|
|
}
|
|
"variables" => {
|
|
let reference = request
|
|
.get("arguments")
|
|
.and_then(|value| value.get("variablesReference"))
|
|
.and_then(Value::as_i64)
|
|
.unwrap_or(0);
|
|
writer.response(&request, true, variables_response(&state, reference))?;
|
|
}
|
|
"evaluate" => {
|
|
let expression = request
|
|
.get("arguments")
|
|
.and_then(|value| value.get("expression"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default();
|
|
let result = match expression {
|
|
"virtual_process_id" => state.process.to_string(),
|
|
"debug_epoch" => state.epoch.to_string(),
|
|
"command_status" => state.command_status.clone(),
|
|
_ => "not available".to_owned(),
|
|
};
|
|
writer.response(
|
|
&request,
|
|
true,
|
|
json!({ "result": result, "variablesReference": 0 }),
|
|
)?;
|
|
}
|
|
"pause" => {
|
|
match freeze_all(
|
|
&mut state,
|
|
LINUX_THREAD,
|
|
simulated_freeze_failure_thread(&request),
|
|
) {
|
|
Ok(()) => {
|
|
writer.response(&request, true, json!({}))?;
|
|
writer.event(
|
|
"stopped",
|
|
json!({
|
|
"reason": "pause",
|
|
"description": "Disasmer Debug Epoch pause",
|
|
"threadId": LINUX_THREAD,
|
|
"allThreadsStopped": true,
|
|
}),
|
|
)?;
|
|
}
|
|
Err(failure) => {
|
|
let message = failure.message();
|
|
writer.output("stderr", format!("{message}\n"))?;
|
|
writer.error_response(&request, message)?;
|
|
}
|
|
}
|
|
}
|
|
"continue" => {
|
|
let thread_id = request_thread_id(&request).unwrap_or(LINUX_THREAD);
|
|
let next_breakpoint = next_breakpoint_after(&state, thread_id);
|
|
for thread in state.threads.values_mut() {
|
|
if thread.state == DebugRuntimeState::Frozen {
|
|
thread.state = DebugRuntimeState::Running;
|
|
}
|
|
}
|
|
writer.response(&request, true, json!({ "allThreadsContinued": true }))?;
|
|
writer.event(
|
|
"continued",
|
|
json!({
|
|
"threadId": thread_id,
|
|
"allThreadsContinued": true,
|
|
}),
|
|
)?;
|
|
if let Some((stopped_thread, line)) = next_breakpoint {
|
|
if state.runtime_backend == RuntimeBackend::LiveServices
|
|
&& stopped_thread == LINUX_THREAD
|
|
&& state.runtime_event_count == 0
|
|
{
|
|
match launch_live_placed_task(&state) {
|
|
Ok(record) => state.apply_runtime_record(record),
|
|
Err(err) => {
|
|
let message = format!(
|
|
"Command-capability virtual task placement failed after coordinator-side process start: {err:#}"
|
|
);
|
|
state.command_status = message.clone();
|
|
state.last_task_failed = true;
|
|
if let Some(thread) = state.threads.get_mut(&LINUX_THREAD) {
|
|
thread.state = DebugRuntimeState::Failed(message.clone());
|
|
thread.recent_output.push(message.clone());
|
|
}
|
|
writer.output("stderr", format!("{message}\n"))?;
|
|
writer.event(
|
|
"stopped",
|
|
json!({
|
|
"reason": "exception",
|
|
"description": "Disasmer capability task unavailable",
|
|
"threadId": LINUX_THREAD,
|
|
"allThreadsStopped": true,
|
|
}),
|
|
)?;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
match freeze_all_at_line(&mut state, stopped_thread, line, None) {
|
|
Ok(()) => {
|
|
writer.event(
|
|
"stopped",
|
|
json!({
|
|
"reason": "breakpoint",
|
|
"description": "Disasmer Debug Epoch all-stop",
|
|
"threadId": stopped_thread,
|
|
"allThreadsStopped": true,
|
|
}),
|
|
)?;
|
|
}
|
|
Err(failure) => {
|
|
let message = failure.message();
|
|
writer.output("stderr", format!("{message}\n"))?;
|
|
writer.error_response(&request, message)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"next" | "stepIn" | "stepOut" => {
|
|
let thread_id = request_thread_id(&request).unwrap_or(LINUX_THREAD);
|
|
let description = match command {
|
|
"next" => "step over",
|
|
"stepIn" => "step in",
|
|
"stepOut" => "step out",
|
|
_ => "step",
|
|
};
|
|
step_thread(&mut state, thread_id, description);
|
|
writer.response(&request, true, json!({}))?;
|
|
writer.event(
|
|
"stopped",
|
|
json!({
|
|
"reason": "step",
|
|
"description": format!("Disasmer Debug Epoch {description}"),
|
|
"threadId": thread_id,
|
|
"allThreadsStopped": true,
|
|
}),
|
|
)?;
|
|
}
|
|
"restart" | "restartFrame" => {
|
|
let thread_id = request_thread_id(&request).unwrap_or(LINUX_THREAD);
|
|
if restart_requires_whole_process(&request) {
|
|
let message = format!(
|
|
"Incompatible source edit requires whole virtual-process restart for {}",
|
|
state.process
|
|
);
|
|
writer.output("stderr", format!("{message}\n"))?;
|
|
writer.error_response(&request, message)?;
|
|
continue;
|
|
}
|
|
let restarting_failed_task = state
|
|
.threads
|
|
.get(&thread_id)
|
|
.is_some_and(|thread| matches!(thread.state, DebugRuntimeState::Failed(_)));
|
|
if let Some(thread) = state.threads.get_mut(&thread_id) {
|
|
thread.state = DebugRuntimeState::Running;
|
|
thread
|
|
.recent_output
|
|
.push("task restarted from VFS checkpoint".to_owned());
|
|
}
|
|
if restarting_failed_task {
|
|
state.last_task_failed = false;
|
|
}
|
|
state.command_status = format!(
|
|
"{} task restarted from compatible VFS checkpoint",
|
|
if restarting_failed_task {
|
|
"failed"
|
|
} else {
|
|
"selected"
|
|
}
|
|
);
|
|
writer.response(&request, true, json!({}))?;
|
|
writer.output(
|
|
"console",
|
|
format!(
|
|
"Restarted {} task from compatible VFS checkpoint on thread {thread_id}\n",
|
|
if restarting_failed_task {
|
|
"failed"
|
|
} else {
|
|
"selected"
|
|
}
|
|
),
|
|
)?;
|
|
}
|
|
"disconnect" | "terminate" => {
|
|
writer.response(&request, true, json!({}))?;
|
|
writer.event("terminated", json!({}))?;
|
|
break;
|
|
}
|
|
_ => writer.error_response(&request, format!("unsupported DAP command: {command}"))?,
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn request_thread<'a>(request: &Value, state: &'a AdapterState) -> &'a VirtualThread {
|
|
let thread_id = request_thread_id(request).unwrap_or(LINUX_THREAD);
|
|
state
|
|
.threads
|
|
.get(&thread_id)
|
|
.unwrap_or_else(|| &state.threads[&LINUX_THREAD])
|
|
}
|
|
|
|
fn request_thread_id(request: &Value) -> Option<i64> {
|
|
request
|
|
.get("arguments")
|
|
.and_then(|value| value.get("threadId"))
|
|
.and_then(Value::as_i64)
|
|
}
|
|
|
|
fn restart_requires_whole_process(request: &Value) -> bool {
|
|
let Some(arguments) = request.get("arguments") else {
|
|
return false;
|
|
};
|
|
|
|
arguments
|
|
.get("requiresWholeProcessRestart")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
|| [
|
|
"compatibility",
|
|
"sourceCompatibility",
|
|
"sourceEditCompatibility",
|
|
"taskCompatibility",
|
|
]
|
|
.iter()
|
|
.filter_map(|field| arguments.get(field).and_then(Value::as_str))
|
|
.any(is_incompatible_restart)
|
|
|| arguments
|
|
.get("sourceEdit")
|
|
.and_then(|value| value.get("compatibility"))
|
|
.and_then(Value::as_str)
|
|
.is_some_and(is_incompatible_restart)
|
|
}
|
|
|
|
fn is_incompatible_restart(value: &str) -> bool {
|
|
value.eq_ignore_ascii_case("incompatible")
|
|
|| value.eq_ignore_ascii_case("whole-process")
|
|
|| value.eq_ignore_ascii_case("whole_process")
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct FreezeFailure {
|
|
thread_id: i64,
|
|
task: TaskId,
|
|
}
|
|
|
|
impl FreezeFailure {
|
|
fn message(&self) -> String {
|
|
format!(
|
|
"debug all-stop failed: participant `{}` on thread {} could not freeze",
|
|
self.task, self.thread_id
|
|
)
|
|
}
|
|
}
|
|
|
|
fn simulated_freeze_failure_thread(request: &Value) -> Option<i64> {
|
|
request
|
|
.get("arguments")
|
|
.and_then(|arguments| arguments.get("simulateFreezeFailure"))
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
.then_some(PACKAGE_THREAD)
|
|
}
|
|
|
|
fn freeze_all(
|
|
state: &mut AdapterState,
|
|
stopped_thread: i64,
|
|
forced_failure_thread: Option<i64>,
|
|
) -> Result<(), FreezeFailure> {
|
|
let line = state
|
|
.breakpoints
|
|
.iter()
|
|
.copied()
|
|
.find(|line| thread_for_source_line(state, *line) == stopped_thread)
|
|
.or_else(|| state.breakpoints.first().copied())
|
|
.unwrap_or_else(|| state.threads[&stopped_thread].line);
|
|
freeze_all_at_line(state, stopped_thread, line, forced_failure_thread)
|
|
}
|
|
|
|
fn freeze_all_at_line(
|
|
state: &mut AdapterState,
|
|
stopped_thread: i64,
|
|
line: i64,
|
|
forced_failure_thread: Option<i64>,
|
|
) -> Result<(), FreezeFailure> {
|
|
if let Some(failure) = state.threads.values().find(|thread| {
|
|
thread.state == DebugRuntimeState::Running
|
|
&& (!thread.freeze_supported || forced_failure_thread == Some(thread.id))
|
|
}) {
|
|
return Err(FreezeFailure {
|
|
thread_id: failure.id,
|
|
task: failure.task.clone(),
|
|
});
|
|
}
|
|
|
|
state.epoch += 1;
|
|
for thread in state.threads.values_mut() {
|
|
if thread.state == DebugRuntimeState::Running {
|
|
thread.state = DebugRuntimeState::Frozen;
|
|
}
|
|
}
|
|
if let Some(thread) = state.threads.get_mut(&stopped_thread) {
|
|
thread.line = line;
|
|
thread
|
|
.recent_output
|
|
.push(format!("debug epoch {} all-stop", state.epoch));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn stopped_thread_for_breakpoint(state: &AdapterState) -> i64 {
|
|
state
|
|
.breakpoints
|
|
.first()
|
|
.map(|line| thread_for_source_line(state, *line))
|
|
.unwrap_or(LINUX_THREAD)
|
|
}
|
|
|
|
fn next_breakpoint_after(state: &AdapterState, thread_id: i64) -> Option<(i64, i64)> {
|
|
let current_line = state.threads.get(&thread_id)?.line;
|
|
state
|
|
.breakpoints
|
|
.iter()
|
|
.copied()
|
|
.filter(|line| *line > current_line)
|
|
.min()
|
|
.map(|line| (thread_for_source_line(state, line), line))
|
|
}
|
|
|
|
fn thread_for_source_line(state: &AdapterState, line: i64) -> i64 {
|
|
if let Some(function_name) = source_function_name_at_line(state, line) {
|
|
let lower = function_name.to_ascii_lowercase();
|
|
if lower.contains("linux") {
|
|
return LINUX_THREAD;
|
|
}
|
|
if lower.contains("windows") {
|
|
return WINDOWS_THREAD;
|
|
}
|
|
if lower.contains("package") {
|
|
return PACKAGE_THREAD;
|
|
}
|
|
return MAIN_THREAD;
|
|
}
|
|
|
|
state
|
|
.threads
|
|
.values()
|
|
.find(|thread| thread.line == line)
|
|
.map(|thread| thread.id)
|
|
.unwrap_or(MAIN_THREAD)
|
|
}
|
|
|
|
fn source_function_name_at_line(state: &AdapterState, line: i64) -> Option<String> {
|
|
let source =
|
|
fs::read_to_string(resolve_source_path(&state.project, &state.source_path)).ok()?;
|
|
let lines = source.lines().collect::<Vec<_>>();
|
|
let mut index = usize::try_from(line).ok()?.saturating_sub(1);
|
|
if index >= lines.len() {
|
|
index = lines.len().saturating_sub(1);
|
|
}
|
|
lines[..=index]
|
|
.iter()
|
|
.rev()
|
|
.find_map(|line| parse_rust_function_name(line))
|
|
}
|
|
|
|
fn parse_rust_function_name(line: &str) -> Option<String> {
|
|
let start = line.find("fn ")? + 3;
|
|
let rest = &line[start..];
|
|
let name = rest
|
|
.chars()
|
|
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
|
|
.collect::<String>();
|
|
(!name.is_empty()).then_some(name)
|
|
}
|
|
|
|
fn step_thread(state: &mut AdapterState, thread_id: i64, description: &str) {
|
|
state.epoch += 1;
|
|
let resolved_thread = if state.threads.contains_key(&thread_id) {
|
|
thread_id
|
|
} else {
|
|
LINUX_THREAD
|
|
};
|
|
if let Some(thread) = state.threads.get_mut(&resolved_thread) {
|
|
thread.state = DebugRuntimeState::Frozen;
|
|
thread.line += 1;
|
|
thread
|
|
.recent_output
|
|
.push(format!("debug epoch {} {description}", state.epoch));
|
|
}
|
|
}
|
|
|
|
fn variables_response(state: &AdapterState, reference: i64) -> Value {
|
|
let Some(thread) = state.threads.values().find(|thread| {
|
|
thread.locals_ref == reference
|
|
|| thread.wasm_locals_ref == reference
|
|
|| thread.args_ref == reference
|
|
|| thread.runtime_ref == reference
|
|
|| thread.output_ref == reference
|
|
|| thread.target_ref == reference
|
|
|| thread.vfs_ref == reference
|
|
|| thread.command_ref == reference
|
|
}) else {
|
|
return json!({ "variables": [] });
|
|
};
|
|
|
|
if thread.locals_ref == reference {
|
|
return json!({ "variables": source_locals_variables(state, thread) });
|
|
}
|
|
|
|
if thread.wasm_locals_ref == reference {
|
|
return json!({ "variables": wasm_frame_local_variables(state, thread) });
|
|
}
|
|
|
|
if thread.args_ref == reference {
|
|
return json!({
|
|
"variables": [
|
|
{
|
|
"name": "target",
|
|
"value": format!("Target {{ task = \"{}\", env = \"{}\" }}", thread.task, task_environment(thread)),
|
|
"variablesReference": thread.target_ref
|
|
},
|
|
{
|
|
"name": "task_arguments",
|
|
"value": task_arguments_value(thread),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "return_value",
|
|
"value": return_value_for_thread(state, thread),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "artifact",
|
|
"value": format!("Artifact {{ id = \"{}\" }}", artifact_handle_value(state, thread)),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "source_snapshot",
|
|
"value": format!("SourceSnapshot {{ digest = \"source://local-checkout/{}\" }}", project_snapshot_suffix(&state.project)),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "blob",
|
|
"value": format!("Blob {{ digest = \"{}\" }}", blob_digest(state, thread)),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "vfs_mounts",
|
|
"value": "{ /vfs/artifacts, /vfs/sources, /vfs/blobs }",
|
|
"variablesReference": thread.vfs_ref
|
|
},
|
|
{
|
|
"name": "unavailable-local-diagnostic",
|
|
"value": "source-level locals that cannot be inspected keep task args and handles visible",
|
|
"variablesReference": 0
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
if thread.target_ref == reference {
|
|
return json!({
|
|
"variables": [
|
|
{
|
|
"name": "task",
|
|
"value": thread.task.to_string(),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "environment",
|
|
"value": task_environment(thread),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "kind",
|
|
"value": if thread.id == MAIN_THREAD { "coordinator-side wasm entrypoint" } else { "capability task" },
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "required_capability",
|
|
"value": if thread.id == MAIN_THREAD { "none" } else { "Command" },
|
|
"variablesReference": 0
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
if thread.vfs_ref == reference {
|
|
return json!({
|
|
"variables": [
|
|
{
|
|
"name": "/vfs/artifacts",
|
|
"value": artifact_handle_value(state, thread),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "/vfs/sources",
|
|
"value": format!("source://local-checkout/{}", project_snapshot_suffix(&state.project)),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "/vfs/blobs",
|
|
"value": blob_digest(state, thread),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "durability",
|
|
"value": "best-effort until explicitly exported or stored",
|
|
"variablesReference": 0
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
if thread.runtime_ref == reference {
|
|
return json!({
|
|
"variables": [
|
|
{
|
|
"name": "virtual_process_id",
|
|
"value": state.process.to_string(),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "virtual_thread",
|
|
"value": thread.name,
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "debug_epoch",
|
|
"value": state.epoch,
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "state",
|
|
"value": format!("{:?}", thread.state),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "runtime_backend",
|
|
"value": format!("{:?}", state.runtime_backend),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "coordinator_task_events",
|
|
"value": state.runtime_event_count,
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "command_status",
|
|
"value": state.command_status,
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "command_spec",
|
|
"value": command_spec_summary(state, thread),
|
|
"variablesReference": thread.command_ref
|
|
},
|
|
{
|
|
"name": "stdout_tail",
|
|
"value": output_tail_display(&thread.stdout_tail, thread.stdout_bytes, thread.stdout_truncated, "stdout"),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "stderr_tail",
|
|
"value": output_tail_display(&thread.stderr_tail, thread.stderr_bytes, thread.stderr_truncated, "stderr"),
|
|
"variablesReference": 0
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
if thread.command_ref == reference {
|
|
return json!({
|
|
"variables": [
|
|
{
|
|
"name": "program",
|
|
"value": command_program(thread),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "args",
|
|
"value": command_args_summary(state, thread),
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "required_capability",
|
|
"value": if thread.id == MAIN_THREAD { "none" } else { "Command" },
|
|
"variablesReference": 0
|
|
},
|
|
{
|
|
"name": "status",
|
|
"value": state.command_status,
|
|
"variablesReference": 0
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
let mut variables = vec![
|
|
json!({
|
|
"name": "stdout_tail",
|
|
"value": output_tail_display(&thread.stdout_tail, thread.stdout_bytes, thread.stdout_truncated, "stdout"),
|
|
"variablesReference": 0
|
|
}),
|
|
json!({
|
|
"name": "stderr_tail",
|
|
"value": output_tail_display(&thread.stderr_tail, thread.stderr_bytes, thread.stderr_truncated, "stderr"),
|
|
"variablesReference": 0
|
|
}),
|
|
];
|
|
variables.extend(
|
|
thread
|
|
.recent_output
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, output)| {
|
|
json!({
|
|
"name": format!("line_{index}"),
|
|
"value": output,
|
|
"variablesReference": 0
|
|
})
|
|
}),
|
|
);
|
|
|
|
json!({ "variables": variables })
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct SourceLocal {
|
|
name: String,
|
|
line: i64,
|
|
value: Option<String>,
|
|
}
|
|
|
|
fn source_locals_variables(state: &AdapterState, thread: &VirtualThread) -> Vec<Value> {
|
|
let (locals, diagnostic) = source_locals_for_thread(state, thread);
|
|
let mut variables = locals
|
|
.into_iter()
|
|
.map(|local| {
|
|
let has_value = local.value.is_some();
|
|
let value = local.value.unwrap_or_else(|| {
|
|
format!(
|
|
"cannot be inspected: source-level local from line {} is known, but DWARF/runtime value snapshot is unavailable",
|
|
local.line
|
|
)
|
|
});
|
|
json!({
|
|
"name": local.name,
|
|
"value": value,
|
|
"type": if has_value { "disasmer-source-local" } else { "unavailable-local" },
|
|
"variablesReference": 0
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
variables.push(json!({
|
|
"name": "unavailable-local-diagnostic",
|
|
"value": diagnostic,
|
|
"type": "unavailable-local",
|
|
"variablesReference": 0
|
|
}));
|
|
variables
|
|
}
|
|
|
|
const TASK_ADD_ONE_WAT: &str = r#"
|
|
(module
|
|
(func (export "task_add_one") (param i32) (result i32)
|
|
local.get 0
|
|
i32.const 1
|
|
i32.add))
|
|
"#;
|
|
|
|
fn wasm_frame_local_variables(state: &AdapterState, thread: &VirtualThread) -> Vec<Value> {
|
|
let function_name = source_function_name_at_line(state, thread.line);
|
|
if function_name.as_deref() != Some("task_add_one") {
|
|
return vec![json!({
|
|
"name": "wasm-local-diagnostic",
|
|
"value": "no Wasm frame-local snapshot is available at this source location",
|
|
"type": "unavailable-local",
|
|
"variablesReference": 0
|
|
})];
|
|
}
|
|
|
|
let probe = WasmtimeTaskRuntime::new().and_then(|runtime| {
|
|
runtime.freeze_resume_i32_export_probe(TASK_ADD_ONE_WAT, "task_add_one", 41)
|
|
});
|
|
match probe {
|
|
Ok(probe) => {
|
|
let mut variables = probe
|
|
.local_values
|
|
.into_iter()
|
|
.map(|(name, value)| {
|
|
json!({
|
|
"name": name,
|
|
"value": value,
|
|
"type": "wasm-frame-local",
|
|
"variablesReference": 0
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
variables.push(json!({
|
|
"name": "wasm-local-diagnostic",
|
|
"value": "captured from Wasmtime guest-debug frame state in the product node runtime",
|
|
"type": "diagnostic",
|
|
"variablesReference": 0
|
|
}));
|
|
variables
|
|
}
|
|
Err(err) => vec![json!({
|
|
"name": "wasm-local-diagnostic",
|
|
"value": format!("cannot inspect Wasm frame locals: {err:#}"),
|
|
"type": "unavailable-local",
|
|
"variablesReference": 0
|
|
})],
|
|
}
|
|
}
|
|
|
|
fn source_locals_for_thread(
|
|
state: &AdapterState,
|
|
thread: &VirtualThread,
|
|
) -> (Vec<SourceLocal>, String) {
|
|
let source_path = resolve_source_path(&state.project, &state.source_path);
|
|
let Ok(source) = fs::read_to_string(&source_path) else {
|
|
return (
|
|
Vec::new(),
|
|
format!(
|
|
"cannot be inspected: source file `{}` is unavailable; task args and handles remain visible",
|
|
source_path.display()
|
|
),
|
|
);
|
|
};
|
|
let lines = source.lines().collect::<Vec<_>>();
|
|
if lines.is_empty() {
|
|
return (
|
|
Vec::new(),
|
|
"cannot be inspected: source file is empty; task args and handles remain visible"
|
|
.to_owned(),
|
|
);
|
|
}
|
|
let current = usize::try_from(thread.line)
|
|
.ok()
|
|
.and_then(|line| line.checked_sub(1))
|
|
.unwrap_or(0)
|
|
.min(lines.len() - 1);
|
|
let function_start = lines[..=current]
|
|
.iter()
|
|
.rposition(|line| parse_rust_function_name(line).is_some())
|
|
.unwrap_or(0);
|
|
let mut locals = Vec::new();
|
|
let mut runtime_values = BTreeMap::new();
|
|
let mut index = function_start;
|
|
while index <= current {
|
|
let Some(name) = parse_let_binding_name(lines[index]) else {
|
|
index += 1;
|
|
continue;
|
|
};
|
|
|
|
let line = (index + 1) as i64;
|
|
let mut statement = lines[index].to_owned();
|
|
while !statement.contains(';') && index < current {
|
|
index += 1;
|
|
statement.push('\n');
|
|
statement.push_str(lines[index]);
|
|
}
|
|
|
|
let runtime_value = infer_disasmer_source_local_value(state, &statement, &runtime_values);
|
|
if let Some(value) = runtime_value.clone() {
|
|
runtime_values.insert(name.clone(), value);
|
|
}
|
|
locals.push(SourceLocal {
|
|
name,
|
|
line,
|
|
value: runtime_value.map(|value| value.display(state)),
|
|
});
|
|
index += 1;
|
|
}
|
|
let diagnostic = if locals.is_empty() {
|
|
"cannot be inspected: no source-level `let` locals are in scope for this frame; task args and handles remain visible"
|
|
.to_owned()
|
|
} else if locals.iter().any(|local| local.value.is_some()) {
|
|
"selected source-level Disasmer API locals are populated from virtual-thread runtime state; non-Disasmer locals remain best-effort until DWARF value snapshots are available"
|
|
.to_owned()
|
|
} else {
|
|
"cannot be inspected: selected source-level local names are best-effort until DWARF/runtime value snapshots are available"
|
|
.to_owned()
|
|
};
|
|
(locals, diagnostic)
|
|
}
|
|
|
|
fn parse_let_binding_name(line: &str) -> Option<String> {
|
|
let rest = line.trim_start().strip_prefix("let ")?;
|
|
let rest = rest
|
|
.trim_start()
|
|
.strip_prefix("mut ")
|
|
.unwrap_or(rest)
|
|
.trim_start();
|
|
let name = rest
|
|
.chars()
|
|
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
|
|
.collect::<String>();
|
|
(!name.is_empty()).then_some(name)
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
enum SourceLocalRuntimeValue {
|
|
TaskHandle {
|
|
task: TaskId,
|
|
thread_id: i64,
|
|
env: String,
|
|
state: String,
|
|
},
|
|
ThreadId(i64),
|
|
Artifact(String),
|
|
}
|
|
|
|
impl SourceLocalRuntimeValue {
|
|
fn display(&self, _state: &AdapterState) -> String {
|
|
match self {
|
|
SourceLocalRuntimeValue::TaskHandle {
|
|
task,
|
|
thread_id,
|
|
env,
|
|
state,
|
|
} => format!(
|
|
"TaskHandle {{ task = \"{task}\", virtual_thread_id = {thread_id}, env = \"{env}\", state = {state} }}"
|
|
),
|
|
SourceLocalRuntimeValue::ThreadId(thread_id) => thread_id.to_string(),
|
|
SourceLocalRuntimeValue::Artifact(artifact) => {
|
|
format!("Artifact {{ id = \"{artifact}\" }}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn infer_disasmer_source_local_value(
|
|
state: &AdapterState,
|
|
statement: &str,
|
|
runtime_values: &BTreeMap<String, SourceLocalRuntimeValue>,
|
|
) -> Option<SourceLocalRuntimeValue> {
|
|
if let Some(task_function) = extract_call_argument(statement, "disasmer::spawn::task(") {
|
|
let task_name = extract_string_argument(statement, ".name(");
|
|
let spawned_thread =
|
|
thread_for_spawn_statement(state, &task_function, task_name.as_deref())?;
|
|
return Some(SourceLocalRuntimeValue::TaskHandle {
|
|
task: spawned_thread.task.clone(),
|
|
thread_id: spawned_thread.id,
|
|
env: extract_env_name(statement)
|
|
.unwrap_or_else(|| task_environment(spawned_thread).to_owned()),
|
|
state: format!("{:?}", spawned_thread.state),
|
|
});
|
|
}
|
|
|
|
if let Some(receiver) = receiver_before_method(statement, ".virtual_thread_id()") {
|
|
if let Some(SourceLocalRuntimeValue::TaskHandle { thread_id, .. }) =
|
|
runtime_values.get(&receiver)
|
|
{
|
|
return Some(SourceLocalRuntimeValue::ThreadId(*thread_id));
|
|
}
|
|
}
|
|
|
|
if let Some(receiver) = receiver_before_method(statement, ".join()") {
|
|
if let Some(SourceLocalRuntimeValue::TaskHandle { thread_id, .. }) =
|
|
runtime_values.get(&receiver)
|
|
{
|
|
if let Some(thread) = state.threads.get(thread_id) {
|
|
return Some(SourceLocalRuntimeValue::Artifact(artifact_handle_value(
|
|
state, thread,
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn extract_call_argument(statement: &str, marker: &str) -> Option<String> {
|
|
let start = statement.find(marker)? + marker.len();
|
|
let rest = &statement[start..];
|
|
let end = rest.find(')')?;
|
|
let value = rest[..end].trim();
|
|
(!value.is_empty()).then_some(value.to_owned())
|
|
}
|
|
|
|
fn extract_string_argument(statement: &str, marker: &str) -> Option<String> {
|
|
let start = statement.find(marker)? + marker.len();
|
|
let rest = &statement[start..];
|
|
let quoted = rest.strip_prefix('"')?;
|
|
let end = quoted.find('"')?;
|
|
Some(quoted[..end].to_owned())
|
|
}
|
|
|
|
fn extract_env_name(statement: &str) -> Option<String> {
|
|
if statement.contains("windows_env") || statement.contains("env!(\"windows\")") {
|
|
return Some("windows".to_owned());
|
|
}
|
|
if statement.contains("linux_env") || statement.contains("env!(\"linux\")") {
|
|
return Some("linux".to_owned());
|
|
}
|
|
None
|
|
}
|
|
|
|
fn receiver_before_method(statement: &str, method: &str) -> Option<String> {
|
|
let before = statement.split(method).next()?.trim_end();
|
|
let receiver = before
|
|
.chars()
|
|
.rev()
|
|
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
|
|
.collect::<String>()
|
|
.chars()
|
|
.rev()
|
|
.collect::<String>();
|
|
(!receiver.is_empty()).then_some(receiver)
|
|
}
|
|
|
|
fn thread_for_spawn_statement<'a>(
|
|
state: &'a AdapterState,
|
|
task_function: &str,
|
|
task_name: Option<&str>,
|
|
) -> Option<&'a VirtualThread> {
|
|
if let Some(task_name) = task_name {
|
|
if let Some(thread) = state
|
|
.threads
|
|
.values()
|
|
.find(|thread| thread.name == task_name)
|
|
{
|
|
return Some(thread);
|
|
}
|
|
}
|
|
let normalized = task_function.replace('_', "-");
|
|
state
|
|
.threads
|
|
.values()
|
|
.find(|thread| thread.task.as_str() == normalized)
|
|
}
|
|
|
|
fn task_environment(thread: &VirtualThread) -> &'static str {
|
|
match thread.id {
|
|
WINDOWS_THREAD => "windows",
|
|
MAIN_THREAD => "coordinator",
|
|
_ => "linux",
|
|
}
|
|
}
|
|
|
|
fn task_arguments_value(thread: &VirtualThread) -> String {
|
|
if thread.task.as_str() == "task_add_one" {
|
|
"[input: i32]".to_owned()
|
|
} else {
|
|
"[]".to_owned()
|
|
}
|
|
}
|
|
|
|
fn return_value_for_thread(state: &AdapterState, thread: &VirtualThread) -> String {
|
|
match thread.id {
|
|
LINUX_THREAD | PACKAGE_THREAD => {
|
|
format!(
|
|
"Artifact {{ id = \"{}\" }}",
|
|
artifact_handle_value(state, thread)
|
|
)
|
|
}
|
|
MAIN_THREAD => "not returned yet: coordinator-side workflow still running".to_owned(),
|
|
WINDOWS_THREAD => {
|
|
"not returned yet: Windows capability task waits for a capable node".to_owned()
|
|
}
|
|
_ => "not returned yet".to_owned(),
|
|
}
|
|
}
|
|
|
|
fn artifact_handle_value(state: &AdapterState, thread: &VirtualThread) -> String {
|
|
if thread.id == LINUX_THREAD {
|
|
return state.runtime_artifact_path.clone().unwrap_or_else(|| {
|
|
format!("artifact://{}/{}/app.tar.zst", state.process, thread.task)
|
|
});
|
|
}
|
|
if thread.id == PACKAGE_THREAD {
|
|
return "artifact://package/release.tar.zst".to_owned();
|
|
}
|
|
format!("artifact://{}/{}/app.tar.zst", state.process, thread.task)
|
|
}
|
|
|
|
fn blob_digest(state: &AdapterState, thread: &VirtualThread) -> String {
|
|
Digest::from_parts([
|
|
b"blob:v1".as_slice(),
|
|
state.project.as_bytes(),
|
|
thread.task.as_str().as_bytes(),
|
|
])
|
|
.as_str()
|
|
.to_owned()
|
|
}
|
|
|
|
fn command_spec_summary(state: &AdapterState, thread: &VirtualThread) -> String {
|
|
format!(
|
|
"{} {} ({})",
|
|
command_program(thread),
|
|
command_args_summary(state, thread),
|
|
state.command_status
|
|
)
|
|
}
|
|
|
|
fn command_program(thread: &VirtualThread) -> &'static str {
|
|
if thread.id == MAIN_THREAD {
|
|
"coordinator-side wasm"
|
|
} else {
|
|
"cargo"
|
|
}
|
|
}
|
|
|
|
fn command_args_summary(state: &AdapterState, thread: &VirtualThread) -> String {
|
|
if thread.id == MAIN_THREAD {
|
|
return format!("entry={}", state.entry);
|
|
}
|
|
format!("test --quiet --manifest-path {}/Cargo.toml", state.project)
|
|
}
|
|
|
|
fn output_tail_display(tail: &str, bytes: u64, truncated: bool, stream: &str) -> String {
|
|
if tail.is_empty() {
|
|
return format!("no {stream} captured ({bytes} bytes)");
|
|
}
|
|
if truncated {
|
|
format!("{tail}\n... truncated")
|
|
} else {
|
|
tail.to_owned()
|
|
}
|
|
}
|
|
|
|
fn run_local_services_runtime(state: &AdapterState) -> Result<RuntimeLaunchRecord> {
|
|
let repo = std::env::current_dir()?;
|
|
let mut coordinator = Command::new("cargo")
|
|
.args([
|
|
"run",
|
|
"-q",
|
|
"-p",
|
|
"disasmer-coordinator",
|
|
"--bin",
|
|
"disasmer-coordinator",
|
|
"--",
|
|
"--listen",
|
|
"127.0.0.1:0",
|
|
])
|
|
.current_dir(&repo)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()?;
|
|
|
|
let result = run_with_coordinator(state, &repo, &mut coordinator);
|
|
let _ = coordinator.kill();
|
|
let _ = coordinator.wait();
|
|
result
|
|
}
|
|
|
|
fn run_live_services_runtime(state: &AdapterState) -> Result<RuntimeLaunchRecord> {
|
|
let coordinator = normalize_operator_endpoint(&state.operator_endpoint);
|
|
let started = coordinator_request(
|
|
&coordinator,
|
|
json!({
|
|
"type": "start_process",
|
|
"tenant": state.tenant,
|
|
"project": state.project_id,
|
|
"process": state.process.as_str(),
|
|
}),
|
|
)?;
|
|
let epoch = started
|
|
.get("epoch")
|
|
.and_then(Value::as_u64)
|
|
.ok_or_else(|| anyhow!("coordinator did not report a process epoch"))?;
|
|
|
|
Ok(RuntimeLaunchRecord {
|
|
coordinator,
|
|
node: "coordinator-side-process".to_owned(),
|
|
node_report: json!({
|
|
"coordinator_process": started,
|
|
"coordinator_epoch": epoch,
|
|
"capability_task": "not_launched",
|
|
}),
|
|
task_events: json!({ "events": [] }),
|
|
placed_task_launched: false,
|
|
status_code: None,
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 0,
|
|
stdout_tail: String::new(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: None,
|
|
event_count: 0,
|
|
})
|
|
}
|
|
|
|
fn launch_live_placed_task(state: &AdapterState) -> Result<RuntimeLaunchRecord> {
|
|
let coordinator = normalize_operator_endpoint(&state.operator_endpoint);
|
|
let launch = coordinator_request(
|
|
&coordinator,
|
|
json!({
|
|
"type": "launch_task",
|
|
"tenant": state.tenant,
|
|
"project": state.project_id,
|
|
"actor_user": state.actor_user,
|
|
"process": state.process.as_str(),
|
|
"task": "compile-linux",
|
|
"environment": null,
|
|
"environment_digest": null,
|
|
"required_capabilities": ["Command"],
|
|
"dependency_cache": null,
|
|
"source_snapshot": null,
|
|
"required_artifacts": [],
|
|
"quota_available": true,
|
|
"policy_allowed": true,
|
|
"command": "cargo",
|
|
"command_args": [
|
|
"test",
|
|
"--quiet",
|
|
"--manifest-path",
|
|
Path::new(&state.project)
|
|
.join("Cargo.toml")
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
],
|
|
"artifact_path": "/vfs/artifacts/dap-output.txt",
|
|
}),
|
|
)
|
|
.map_err(|err| {
|
|
anyhow!(
|
|
"coordinator-side process could not place virtual task with Command capability: {err:#}. Attach a capable node, for example `disasmer-node --worker --coordinator {} --tenant {} --project-id {} --node <node-id>`.",
|
|
coordinator,
|
|
state.tenant,
|
|
state.project_id,
|
|
)
|
|
})?;
|
|
let node = launch
|
|
.get("placement")
|
|
.and_then(|placement| placement.get("node"))
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| anyhow!("task launch did not report a worker node placement"))?
|
|
.to_owned();
|
|
let events = wait_for_placed_task_event(&coordinator, state, "compile-linux", &node)?;
|
|
let event_count = events
|
|
.get("events")
|
|
.and_then(Value::as_array)
|
|
.map(Vec::len)
|
|
.unwrap_or(0);
|
|
let event = events
|
|
.get("events")
|
|
.and_then(Value::as_array)
|
|
.and_then(|items| {
|
|
items.iter().find(|event| {
|
|
event.get("task").and_then(Value::as_str) == Some("compile-linux")
|
|
&& event.get("node").and_then(Value::as_str) == Some(node.as_str())
|
|
})
|
|
})
|
|
.ok_or_else(|| anyhow!("worker node {node} did not record compile-linux completion"))?;
|
|
let status_code = event
|
|
.get("status_code")
|
|
.and_then(Value::as_i64)
|
|
.map(|value| value as i32);
|
|
let stdout_bytes = event
|
|
.get("stdout_bytes")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0);
|
|
let stderr_bytes = event
|
|
.get("stderr_bytes")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0);
|
|
let artifact_path = event
|
|
.get("artifact_path")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned);
|
|
let stdout_tail = event
|
|
.get("stdout_tail")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_owned();
|
|
let stderr_tail = event
|
|
.get("stderr_tail")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_owned();
|
|
let stdout_truncated = event
|
|
.get("stdout_truncated")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
let stderr_truncated = event
|
|
.get("stderr_truncated")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
|
|
Ok(RuntimeLaunchRecord {
|
|
coordinator,
|
|
node,
|
|
node_report: json!({
|
|
"task_launch": launch,
|
|
}),
|
|
task_events: events,
|
|
placed_task_launched: true,
|
|
status_code,
|
|
stdout_bytes,
|
|
stderr_bytes,
|
|
stdout_tail,
|
|
stderr_tail,
|
|
stdout_truncated,
|
|
stderr_truncated,
|
|
artifact_path,
|
|
event_count,
|
|
})
|
|
}
|
|
|
|
fn run_with_coordinator(
|
|
state: &AdapterState,
|
|
repo: &std::path::Path,
|
|
coordinator: &mut Child,
|
|
) -> Result<RuntimeLaunchRecord> {
|
|
let stdout = coordinator
|
|
.stdout
|
|
.take()
|
|
.ok_or_else(|| anyhow!("coordinator stdout was not captured"))?;
|
|
let mut ready_line = String::new();
|
|
BufReader::new(stdout).read_line(&mut ready_line)?;
|
|
let ready: Value = serde_json::from_str(&ready_line)?;
|
|
let listen = ready
|
|
.get("listen")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| anyhow!("coordinator did not report a listen address"))?;
|
|
|
|
run_node_against_coordinator(
|
|
state,
|
|
repo,
|
|
"cargo",
|
|
&[
|
|
"run",
|
|
"-q",
|
|
"-p",
|
|
"disasmer-node",
|
|
"--bin",
|
|
"disasmer-node",
|
|
"--",
|
|
],
|
|
listen,
|
|
"dap-node",
|
|
)
|
|
}
|
|
|
|
fn run_node_against_coordinator(
|
|
state: &AdapterState,
|
|
cwd: &Path,
|
|
command: &str,
|
|
prefix_args: &[&str],
|
|
coordinator: &str,
|
|
node: &str,
|
|
) -> Result<RuntimeLaunchRecord> {
|
|
let mut args = prefix_args
|
|
.iter()
|
|
.map(|arg| (*arg).to_owned())
|
|
.collect::<Vec<_>>();
|
|
args.extend([
|
|
"--coordinator".to_owned(),
|
|
coordinator.to_owned(),
|
|
"--tenant".to_owned(),
|
|
state.tenant.clone(),
|
|
"--project-id".to_owned(),
|
|
state.project_id.clone(),
|
|
"--node".to_owned(),
|
|
node.to_owned(),
|
|
"--process".to_owned(),
|
|
state.process.as_str().to_owned(),
|
|
"--task".to_owned(),
|
|
"compile-linux".to_owned(),
|
|
"--project".to_owned(),
|
|
state.project.clone(),
|
|
"--artifact".to_owned(),
|
|
"/vfs/artifacts/dap-output.txt".to_owned(),
|
|
]);
|
|
|
|
let node_output = Command::new(command)
|
|
.args(&args)
|
|
.current_dir(cwd)
|
|
.output()?;
|
|
if !node_output.status.success() {
|
|
return Err(anyhow!(
|
|
"node runtime failed: {}",
|
|
String::from_utf8_lossy(&node_output.stderr)
|
|
));
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&node_output.stdout);
|
|
let report_line = stdout
|
|
.lines()
|
|
.last()
|
|
.ok_or_else(|| anyhow!("node runtime did not emit JSON"))?;
|
|
let report: Value = serde_json::from_str(report_line)?;
|
|
let events = coordinator_request(
|
|
coordinator,
|
|
json!({
|
|
"type": "list_task_events",
|
|
"tenant": state.tenant,
|
|
"project": state.project_id,
|
|
"actor_user": state.actor_user,
|
|
"process": state.process.as_str(),
|
|
}),
|
|
)?;
|
|
let event_count = events
|
|
.get("events")
|
|
.and_then(Value::as_array)
|
|
.map(Vec::len)
|
|
.unwrap_or(0);
|
|
|
|
Ok(RuntimeLaunchRecord {
|
|
coordinator: coordinator.to_owned(),
|
|
node: node.to_owned(),
|
|
node_report: report.clone(),
|
|
task_events: events,
|
|
placed_task_launched: true,
|
|
status_code: report
|
|
.get("status_code")
|
|
.and_then(Value::as_i64)
|
|
.map(|value| value as i32),
|
|
stdout_bytes: report
|
|
.get("stdout_bytes")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0),
|
|
stderr_bytes: report
|
|
.get("stderr_bytes")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0),
|
|
stdout_tail: report
|
|
.get("stdout_tail")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_owned(),
|
|
stderr_tail: report
|
|
.get("stderr_tail")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_owned(),
|
|
stdout_truncated: report
|
|
.get("stdout_truncated")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false),
|
|
stderr_truncated: report
|
|
.get("stderr_truncated")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false),
|
|
artifact_path: report
|
|
.get("staged_artifact")
|
|
.and_then(|artifact| artifact.get("path"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned),
|
|
event_count,
|
|
})
|
|
}
|
|
|
|
fn coordinator_request(addr: &str, request: Value) -> Result<Value> {
|
|
let mut stream = TcpStream::connect(addr)?;
|
|
serde_json::to_writer(&mut stream, &request)?;
|
|
stream.write_all(b"\n")?;
|
|
stream.flush()?;
|
|
|
|
let mut line = String::new();
|
|
BufReader::new(stream).read_line(&mut line)?;
|
|
let response: Value = serde_json::from_str(&line)?;
|
|
if response.get("type").and_then(Value::as_str) == Some("error") {
|
|
return Err(anyhow!(
|
|
"{}",
|
|
response
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("coordinator returned an error")
|
|
));
|
|
}
|
|
Ok(response)
|
|
}
|
|
|
|
fn wait_for_placed_task_event(
|
|
coordinator: &str,
|
|
state: &AdapterState,
|
|
task: &str,
|
|
node: &str,
|
|
) -> Result<Value> {
|
|
let deadline = Instant::now() + Duration::from_secs(120);
|
|
loop {
|
|
let events = coordinator_request(
|
|
coordinator,
|
|
json!({
|
|
"type": "list_task_events",
|
|
"tenant": state.tenant,
|
|
"project": state.project_id,
|
|
"actor_user": state.actor_user,
|
|
"process": state.process.as_str(),
|
|
}),
|
|
)?;
|
|
let completed = events
|
|
.get("events")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|items| {
|
|
items.iter().any(|event| {
|
|
event.get("task").and_then(Value::as_str) == Some(task)
|
|
&& event.get("node").and_then(Value::as_str) == Some(node)
|
|
})
|
|
});
|
|
if completed {
|
|
return Ok(events);
|
|
}
|
|
if Instant::now() >= deadline {
|
|
return Err(anyhow!(
|
|
"node {node} accepted virtual task `{task}` but did not report completion; make sure the explicit `disasmer-node --worker` process is still running"
|
|
));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(250));
|
|
}
|
|
}
|
|
|
|
fn project_snapshot_suffix(project: &str) -> String {
|
|
Digest::from_parts([b"source-snapshot:v1".as_slice(), project.as_bytes()])
|
|
.as_str()
|
|
.trim_start_matches("sha256:")
|
|
.chars()
|
|
.take(12)
|
|
.collect()
|
|
}
|
|
|
|
fn infer_source_path(project: &str) -> String {
|
|
if Path::new(project).join("src/build.rs").is_file() {
|
|
"src/build.rs".to_owned()
|
|
} else {
|
|
"src/main.rs".to_owned()
|
|
}
|
|
}
|
|
|
|
fn source_name(source_path: &str) -> &str {
|
|
Path::new(source_path)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or(source_path)
|
|
}
|
|
|
|
fn stack_source_path(state: &AdapterState) -> String {
|
|
let resolved = resolve_source_path(&state.project, &state.source_path);
|
|
let absolute = if resolved.is_absolute() {
|
|
resolved
|
|
} else {
|
|
std::env::current_dir()
|
|
.unwrap_or_else(|_| Path::new(".").to_path_buf())
|
|
.join(resolved)
|
|
};
|
|
absolute
|
|
.canonicalize()
|
|
.unwrap_or(absolute)
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
}
|
|
|
|
fn source_response(state: &AdapterState, request: &Value) -> Result<Value> {
|
|
let source_path = request
|
|
.get("arguments")
|
|
.and_then(|arguments| arguments.get("source"))
|
|
.and_then(|source| source.get("path"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(&state.source_path);
|
|
let content = fs::read_to_string(resolve_source_path(&state.project, source_path))?;
|
|
Ok(json!({
|
|
"content": content,
|
|
"mimeType": "text/x-rustsrc",
|
|
}))
|
|
}
|
|
|
|
fn resolve_source_path(project: &str, source_path: &str) -> std::path::PathBuf {
|
|
let path = Path::new(source_path);
|
|
if path.is_absolute() {
|
|
path.to_path_buf()
|
|
} else {
|
|
Path::new(project).join(path)
|
|
}
|
|
}
|
|
|
|
fn normalize_operator_endpoint(endpoint: &str) -> String {
|
|
let trimmed = endpoint.trim();
|
|
let without_scheme = trimmed
|
|
.strip_prefix("https://")
|
|
.or_else(|| trimmed.strip_prefix("http://"))
|
|
.unwrap_or(trimmed);
|
|
without_scheme
|
|
.split('/')
|
|
.next()
|
|
.unwrap_or(without_scheme)
|
|
.to_owned()
|
|
}
|
|
|
|
fn write_view_state(state: &AdapterState, record: &RuntimeLaunchRecord) -> Result<()> {
|
|
let dir = Path::new(&state.project).join(".disasmer");
|
|
fs::create_dir_all(&dir)?;
|
|
let node_status = if record.placed_task_launched {
|
|
if record.status_code == Some(0) {
|
|
"completed"
|
|
} else {
|
|
"failed"
|
|
}
|
|
} else {
|
|
"not-required-yet"
|
|
};
|
|
let process_status = if record.placed_task_launched {
|
|
if record.status_code == Some(0) {
|
|
"completed"
|
|
} else {
|
|
"failed"
|
|
}
|
|
} else {
|
|
"running"
|
|
};
|
|
let log_message = if record.placed_task_launched {
|
|
"node task output was staged as a VFS artifact"
|
|
} else {
|
|
"coordinator-side virtual process started; capability task not launched"
|
|
};
|
|
let artifact_status = if record.placed_task_launched {
|
|
"best-effort retained on the attached node"
|
|
} else {
|
|
"not produced until a capability task is placed"
|
|
};
|
|
let view_state = json!({
|
|
"nodes": [{
|
|
"id": record.node,
|
|
"status": node_status,
|
|
"capabilities": format!("connected to {}", record.coordinator),
|
|
}],
|
|
"processes": [{
|
|
"id": state.process.as_str(),
|
|
"status": process_status,
|
|
"entry": state.entry,
|
|
}],
|
|
"logs": [{
|
|
"task": "compile-linux",
|
|
"message": log_message,
|
|
"bytes": format!("stdout={} stderr={}", record.stdout_bytes, record.stderr_bytes),
|
|
}],
|
|
"artifacts": [{
|
|
"path": record.artifact_path.clone().unwrap_or_else(|| "/vfs/artifacts/dap-output.txt".to_owned()),
|
|
"status": artifact_status,
|
|
"size": "reported by node",
|
|
}],
|
|
"inspector": [
|
|
{
|
|
"label": "Runtime backend",
|
|
"value": state.runtime_backend.description(),
|
|
},
|
|
{
|
|
"label": "Coordinator",
|
|
"value": record.coordinator,
|
|
},
|
|
{
|
|
"label": "Node report",
|
|
"value": record.node_report.to_string(),
|
|
},
|
|
{
|
|
"label": "Task events",
|
|
"value": record.task_events.to_string(),
|
|
}
|
|
]
|
|
});
|
|
fs::write(
|
|
dir.join("views.json"),
|
|
serde_json::to_string_pretty(&view_state)?,
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn initialize_capabilities() -> Value {
|
|
json!({
|
|
"supportsConfigurationDoneRequest": true,
|
|
"supportsTerminateRequest": true,
|
|
"supportsRestartRequest": true,
|
|
"supportsRestartFrame": true,
|
|
"supportsEvaluateForHovers": true,
|
|
"supportsStepBack": false,
|
|
})
|
|
}
|
|
|
|
fn read_message<R: BufRead>(reader: &mut R) -> Result<Option<Value>> {
|
|
let mut content_length = None;
|
|
|
|
loop {
|
|
let mut line = String::new();
|
|
let bytes = reader.read_line(&mut line)?;
|
|
if bytes == 0 {
|
|
return Ok(None);
|
|
}
|
|
|
|
let line = line.trim_end_matches(['\r', '\n']);
|
|
if line.is_empty() {
|
|
break;
|
|
}
|
|
|
|
if let Some(value) = line.strip_prefix("Content-Length:") {
|
|
content_length = Some(value.trim().parse::<usize>()?);
|
|
}
|
|
}
|
|
|
|
let length = content_length.ok_or_else(|| anyhow!("DAP message missing Content-Length"))?;
|
|
let mut payload = vec![0; length];
|
|
reader.read_exact(&mut payload)?;
|
|
Ok(Some(serde_json::from_slice(&payload)?))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::io::Cursor;
|
|
|
|
use serde_json::json;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn reads_standard_dap_content_length_frame_from_generic_client() {
|
|
let payload = json!({
|
|
"seq": 7,
|
|
"type": "request",
|
|
"command": "threads"
|
|
})
|
|
.to_string();
|
|
let frame = format!("Content-Length: {}\r\n\r\n{payload}", payload.len());
|
|
let mut reader = Cursor::new(frame.into_bytes());
|
|
|
|
let message = read_message(&mut reader)
|
|
.unwrap()
|
|
.expect("DAP frame should contain a request");
|
|
|
|
assert_eq!(message["seq"], 7);
|
|
assert_eq!(message["type"], "request");
|
|
assert_eq!(message["command"], "threads");
|
|
}
|
|
|
|
#[test]
|
|
fn initialize_capabilities_use_standard_dap_flags() {
|
|
let capabilities = initialize_capabilities();
|
|
let capabilities = capabilities
|
|
.as_object()
|
|
.expect("initialize capabilities should be an object");
|
|
|
|
assert_eq!(
|
|
capabilities.get("supportsConfigurationDoneRequest"),
|
|
Some(&json!(true))
|
|
);
|
|
assert_eq!(capabilities.get("supportsRestartFrame"), Some(&json!(true)));
|
|
assert_eq!(capabilities.get("supportsStepBack"), Some(&json!(false)));
|
|
assert!(capabilities
|
|
.keys()
|
|
.all(|key| key.starts_with("supports") && !key.contains("disasmer")));
|
|
}
|
|
|
|
#[test]
|
|
fn compatible_restart_frame_does_not_require_whole_process_restart() {
|
|
let request = json!({
|
|
"command": "restartFrame",
|
|
"arguments": {
|
|
"frameId": 1,
|
|
"sourceEdit": {
|
|
"compatibility": "compatible"
|
|
}
|
|
}
|
|
});
|
|
|
|
assert!(!restart_requires_whole_process(&request));
|
|
}
|
|
|
|
#[test]
|
|
fn incompatible_source_edit_requires_whole_process_restart() {
|
|
for arguments in [
|
|
json!({ "sourceCompatibility": "incompatible" }),
|
|
json!({ "sourceEdit": { "compatibility": "whole-process" } }),
|
|
json!({ "requiresWholeProcessRestart": true }),
|
|
] {
|
|
let request = json!({
|
|
"command": "restartFrame",
|
|
"arguments": arguments,
|
|
});
|
|
|
|
assert!(restart_requires_whole_process(&request));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nonzero_local_runtime_record_marks_linux_task_failed() {
|
|
let mut state = AdapterState::default();
|
|
state.runtime_backend = RuntimeBackend::LocalServices;
|
|
|
|
state.apply_runtime_record(RuntimeLaunchRecord {
|
|
coordinator: "127.0.0.1:7999".to_owned(),
|
|
node: "node".to_owned(),
|
|
node_report: json!({}),
|
|
task_events: json!({ "events": [] }),
|
|
placed_task_launched: true,
|
|
status_code: Some(1),
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 12,
|
|
stdout_tail: String::new(),
|
|
stderr_tail: "failed".to_owned(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: None,
|
|
event_count: 1,
|
|
});
|
|
|
|
let linux = state
|
|
.threads
|
|
.get(&LINUX_THREAD)
|
|
.expect("linux task should exist");
|
|
|
|
assert!(state.last_task_failed);
|
|
assert!(state
|
|
.command_status
|
|
.contains("failed through local services"));
|
|
assert!(matches!(linux.state, DebugRuntimeState::Failed(_)));
|
|
assert!(linux
|
|
.recent_output
|
|
.iter()
|
|
.any(|line| line.contains("attached node failed task")));
|
|
}
|
|
|
|
#[test]
|
|
fn freeze_all_reports_failure_without_claiming_all_stop() {
|
|
let mut state = AdapterState::default();
|
|
state
|
|
.threads
|
|
.get_mut(&PACKAGE_THREAD)
|
|
.expect("package thread should exist")
|
|
.freeze_supported = false;
|
|
|
|
let error = freeze_all(&mut state, LINUX_THREAD, None).unwrap_err();
|
|
|
|
assert_eq!(error.thread_id, PACKAGE_THREAD);
|
|
assert_eq!(error.task, TaskId::from("package"));
|
|
assert!(error.message().contains("could not freeze"));
|
|
assert_eq!(state.epoch, 0);
|
|
assert!(state
|
|
.threads
|
|
.values()
|
|
.all(|thread| thread.state == DebugRuntimeState::Running));
|
|
}
|
|
|
|
#[test]
|
|
fn freeze_all_success_freezes_every_running_participant() {
|
|
let mut state = AdapterState::default();
|
|
|
|
freeze_all(&mut state, LINUX_THREAD, None).unwrap();
|
|
|
|
assert_eq!(state.epoch, 1);
|
|
assert!(state
|
|
.threads
|
|
.values()
|
|
.all(|thread| thread.state == DebugRuntimeState::Frozen));
|
|
}
|
|
|
|
#[test]
|
|
fn breakpoint_line_selects_main_or_spawned_virtual_thread() {
|
|
let mut state = AdapterState::default();
|
|
|
|
state.breakpoints = vec![12];
|
|
assert_eq!(stopped_thread_for_breakpoint(&state), MAIN_THREAD);
|
|
|
|
state.breakpoints = vec![42];
|
|
assert_eq!(stopped_thread_for_breakpoint(&state), LINUX_THREAD);
|
|
}
|
|
|
|
#[test]
|
|
fn launch_threads_include_windows_task_in_same_virtual_process() {
|
|
let state = AdapterState::default();
|
|
let windows = state
|
|
.threads
|
|
.get(&WINDOWS_THREAD)
|
|
.expect("windows task should be debugger-visible");
|
|
|
|
assert_eq!(state.threads.len(), 4);
|
|
assert_eq!(windows.id, WINDOWS_THREAD);
|
|
assert_eq!(windows.task, TaskId::from("compile-windows"));
|
|
assert_eq!(windows.name, "compile windows");
|
|
assert_eq!(windows.line, 52);
|
|
assert_eq!(process_id(&state.project, &state.entry), state.process);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_thread_runtime_variables_share_virtual_process() {
|
|
let state = AdapterState::default();
|
|
let windows = state
|
|
.threads
|
|
.get(&WINDOWS_THREAD)
|
|
.expect("windows task should be debugger-visible");
|
|
|
|
let variables = variables_response(&state, windows.runtime_ref);
|
|
let variables = variables["variables"]
|
|
.as_array()
|
|
.expect("variables response should be an array");
|
|
|
|
assert!(variables.iter().any(|variable| {
|
|
variable["name"] == "virtual_process_id"
|
|
&& variable["value"] == state.process.to_string()
|
|
}));
|
|
assert!(variables.iter().any(|variable| {
|
|
variable["name"] == "virtual_thread" && variable["value"] == "compile windows"
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn variables_expose_mvp_runtime_handles_and_output_tails() {
|
|
let mut state = AdapterState::default();
|
|
state.runtime_backend = RuntimeBackend::LocalServices;
|
|
state.apply_runtime_record(RuntimeLaunchRecord {
|
|
coordinator: "127.0.0.1:7999".to_owned(),
|
|
node: "node".to_owned(),
|
|
node_report: json!({}),
|
|
task_events: json!({ "events": [] }),
|
|
placed_task_launched: true,
|
|
status_code: Some(0),
|
|
stdout_bytes: 11,
|
|
stderr_bytes: 7,
|
|
stdout_tail: "hello tail\n".to_owned(),
|
|
stderr_tail: "warn\n".to_owned(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: Some("/vfs/artifacts/dap-output.txt".to_owned()),
|
|
event_count: 1,
|
|
});
|
|
let linux = state
|
|
.threads
|
|
.get(&LINUX_THREAD)
|
|
.expect("linux task should exist");
|
|
|
|
let args = variables_response(&state, linux.args_ref);
|
|
let args = args["variables"]
|
|
.as_array()
|
|
.expect("variables response should be an array");
|
|
assert!(args.iter().any(|variable| {
|
|
variable["name"] == "return_value"
|
|
&& variable["value"]
|
|
.as_str()
|
|
.is_some_and(|value| value.contains("Artifact"))
|
|
}));
|
|
assert!(args.iter().any(|variable| variable["name"] == "blob"));
|
|
let target_ref = args
|
|
.iter()
|
|
.find(|variable| variable["name"] == "target")
|
|
.and_then(|variable| variable["variablesReference"].as_i64())
|
|
.expect("target should have child variables");
|
|
let vfs_ref = args
|
|
.iter()
|
|
.find(|variable| variable["name"] == "vfs_mounts")
|
|
.and_then(|variable| variable["variablesReference"].as_i64())
|
|
.expect("vfs mounts should have child variables");
|
|
|
|
let target = variables_response(&state, target_ref);
|
|
assert!(target["variables"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|variable| variable["name"] == "environment" && variable["value"] == "linux"));
|
|
|
|
let vfs = variables_response(&state, vfs_ref);
|
|
assert!(vfs["variables"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|variable| variable["name"] == "/vfs/artifacts"));
|
|
|
|
let runtime = variables_response(&state, linux.runtime_ref);
|
|
let runtime = runtime["variables"].as_array().unwrap();
|
|
let command_ref = runtime
|
|
.iter()
|
|
.find(|variable| variable["name"] == "command_spec")
|
|
.and_then(|variable| variable["variablesReference"].as_i64())
|
|
.expect("command spec should have child variables");
|
|
assert!(runtime.iter().any(
|
|
|variable| variable["name"] == "stdout_tail" && variable["value"] == "hello tail\n"
|
|
));
|
|
assert!(runtime
|
|
.iter()
|
|
.any(|variable| variable["name"] == "stderr_tail" && variable["value"] == "warn\n"));
|
|
|
|
let command = variables_response(&state, command_ref);
|
|
assert!(command["variables"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|variable| variable["name"] == "required_capability"
|
|
&& variable["value"] == "Command"));
|
|
}
|
|
|
|
#[test]
|
|
fn source_locals_infer_disasmer_api_values_from_runtime_state() {
|
|
let mut state = AdapterState::default();
|
|
state.project = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../examples/launch-build-demo")
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
state.source_path = "src/build.rs".to_owned();
|
|
state.threads.get_mut(&MAIN_THREAD).unwrap().line = 60;
|
|
let thread = state.threads[&MAIN_THREAD].clone();
|
|
|
|
let locals = variables_response(&state, thread.locals_ref);
|
|
let locals = locals["variables"].as_array().unwrap();
|
|
|
|
assert!(locals.iter().any(|variable| {
|
|
variable["name"] == "linux"
|
|
&& variable["value"].as_str().is_some_and(|value| {
|
|
value.contains("TaskHandle")
|
|
&& value.contains("compile-linux")
|
|
&& value.contains("virtual_thread_id = 2")
|
|
})
|
|
}));
|
|
assert!(locals
|
|
.iter()
|
|
.any(|variable| { variable["name"] == "linux_thread" && variable["value"] == "2" }));
|
|
assert!(locals.iter().any(|variable| {
|
|
variable["name"] == "linux_artifact"
|
|
&& variable["value"]
|
|
.as_str()
|
|
.is_some_and(|value| value.contains("Artifact"))
|
|
}));
|
|
assert!(locals.iter().any(|variable| {
|
|
variable["name"] == "unavailable-local-diagnostic"
|
|
&& variable["type"] == "unavailable-local"
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn wasm_frame_locals_expose_wasmtime_runtime_values() {
|
|
let mut state = AdapterState::default();
|
|
state.project = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../examples/launch-build-demo")
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
state.source_path = "src/build.rs".to_owned();
|
|
state.threads.get_mut(&MAIN_THREAD).unwrap().line = 31;
|
|
let thread = state.threads[&MAIN_THREAD].clone();
|
|
|
|
let locals = variables_response(&state, thread.wasm_locals_ref);
|
|
let locals = locals["variables"].as_array().unwrap();
|
|
|
|
assert!(locals.iter().any(|variable| {
|
|
variable["name"] == "wasm_local_0"
|
|
&& variable["type"] == "wasm-frame-local"
|
|
&& variable["value"]
|
|
.as_str()
|
|
.is_some_and(|value| value.contains("41"))
|
|
}));
|
|
assert!(locals.iter().any(|variable| {
|
|
variable["name"] == "wasm-local-diagnostic"
|
|
&& variable["value"]
|
|
.as_str()
|
|
.is_some_and(|value| value.contains("product node runtime"))
|
|
}));
|
|
}
|
|
}
|