Public dry run dryrun-20b59dc46b72
Source commit: 20b59dc46b72103c2f8a516c692b5cc3d54fab19 Public tree identity: sha256:aaa5ac7b58b2a0d23c6b11e5f76324bf3839ca1f62653a83deb736932adcfbd9
This commit is contained in:
commit
2bef715211
221 changed files with 79792 additions and 0 deletions
21
crates/disasmer-dap/Cargo.toml
Normal file
21
crates/disasmer-dap/Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "disasmer-dap"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "disasmer-debug-dap"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
disasmer-core = { path = "../disasmer-core" }
|
||||
disasmer-control = { path = "../disasmer-control" }
|
||||
disasmer-node = { path = "../disasmer-node" }
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
814
crates/disasmer-dap/src/adapter.rs
Normal file
814
crates/disasmer-dap/src/adapter.rs
Normal file
|
|
@ -0,0 +1,814 @@
|
|||
use std::io::{self, BufReader};
|
||||
|
||||
use anyhow::Result;
|
||||
use disasmer_core::DebugRuntimeState;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::breakpoints::{
|
||||
freeze_all, freeze_all_at_line, next_breakpoint_after, position_confirmed_breakpoint_stop,
|
||||
request_thread, request_thread_id, resolve_breakpoints_for_source,
|
||||
restart_requires_whole_process, simulated_freeze_failure_thread, step_thread,
|
||||
stopped_thread_for_breakpoint,
|
||||
};
|
||||
use crate::dap_protocol::{initialize_capabilities, read_message, DapWriter};
|
||||
use crate::demo_backend::{
|
||||
is_explicit_demo_backend, start_simulated_backend, LINUX_THREAD, MAIN_THREAD,
|
||||
};
|
||||
use crate::runtime_client::{
|
||||
attach_services_runtime, create_debug_epoch, relaunch_services_main_runtime, restart_task,
|
||||
resume_debug_epoch, run_live_services_runtime, run_local_services_runtime,
|
||||
wait_for_debug_epoch_frozen, wait_for_debug_epoch_resumed, wait_for_services_runtime_outcome,
|
||||
RuntimeContinuationOutcome,
|
||||
};
|
||||
use crate::variables::variables_response;
|
||||
use crate::virtual_model::{AdapterState, DapSessionMode, RuntimeBackend};
|
||||
|
||||
pub(crate) fn run_adapter() -> Result<()> {
|
||||
let mut writer = DapWriter::new();
|
||||
let mut reader = BufReader::new(io::stdin());
|
||||
let mut state = AdapterState::default();
|
||||
let mut _local_runtime_session = None;
|
||||
|
||||
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 runtime_backend_from_launch_arg(
|
||||
args.get("runtimeBackend").and_then(Value::as_str),
|
||||
) {
|
||||
Ok(runtime_backend) => runtime_backend,
|
||||
Err(message) => {
|
||||
writer.error_response(&request, message)?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let coordinator_endpoint = args
|
||||
.get("coordinatorEndpoint")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
let source_path = args
|
||||
.get("sourcePath")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
if let Err(err) = state.configure_launch(
|
||||
project,
|
||||
state.entry.clone(),
|
||||
runtime_backend,
|
||||
coordinator_endpoint,
|
||||
source_path,
|
||||
) {
|
||||
writer.error_response(&request, err.to_string())?;
|
||||
continue;
|
||||
}
|
||||
state.restart_existing = args
|
||||
.get("restartExisting")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if let Some(process_id) = args.get("processId").and_then(Value::as_str) {
|
||||
state.process = disasmer_core::ProcessId::new(process_id);
|
||||
}
|
||||
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" => {
|
||||
let requested_source_path = request
|
||||
.get("arguments")
|
||||
.and_then(|value| value.get("source"))
|
||||
.and_then(|value| value.get("path"))
|
||||
.and_then(Value::as_str);
|
||||
let requested_lines = 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 = resolve_breakpoints_for_source(
|
||||
&mut state,
|
||||
requested_source_path,
|
||||
requested_lines,
|
||||
)
|
||||
.iter()
|
||||
.map(|breakpoint| breakpoint.to_dap())
|
||||
.collect::<Vec<_>>();
|
||||
writer.response(&request, true, json!({ "breakpoints": response }))?;
|
||||
}
|
||||
"setExceptionBreakpoints" => {
|
||||
writer.response(&request, true, json!({ "breakpoints": [] }))?;
|
||||
}
|
||||
"configurationDone" => {
|
||||
if state.session_mode == DapSessionMode::Attach {
|
||||
match state.runtime_backend {
|
||||
RuntimeBackend::Simulated => {
|
||||
state.command_status =
|
||||
format!("attached to existing virtual process {}", state.process);
|
||||
}
|
||||
RuntimeBackend::LocalServices | RuntimeBackend::LiveServices => {
|
||||
match attach_services_runtime(&state) {
|
||||
Ok(record) => state.apply_attach_record(record),
|
||||
Err(err) => {
|
||||
writer.error_response(
|
||||
&request,
|
||||
format!("services runtime attach failed: {err:#}"),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match state.runtime_backend {
|
||||
RuntimeBackend::LocalServices => match run_local_services_runtime(&state) {
|
||||
Ok((record, session)) => {
|
||||
state.apply_runtime_record(record);
|
||||
_local_runtime_session = Some(session);
|
||||
}
|
||||
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 => {
|
||||
start_simulated_backend(&mut state);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
if matches!(
|
||||
state.runtime_backend,
|
||||
RuntimeBackend::LocalServices | RuntimeBackend::LiveServices
|
||||
) && state.coordinator_debug_epoch.is_some()
|
||||
{
|
||||
position_confirmed_breakpoint_stop(&mut state, stopped_thread);
|
||||
writer.event(
|
||||
"stopped",
|
||||
json!({
|
||||
"reason": "breakpoint",
|
||||
"description": "Disasmer Debug Epoch all-stop confirmed by every active participant",
|
||||
"threadId": stopped_thread,
|
||||
"allThreadsStopped": true,
|
||||
}),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
match freeze_all(&mut state, stopped_thread, None) {
|
||||
Ok(()) => {
|
||||
if let Err(err) = record_coordinator_debug_epoch(
|
||||
&mut state,
|
||||
stopped_thread,
|
||||
"breakpoint",
|
||||
) {
|
||||
let message = format!("coordinator debug epoch failed: {err:#}");
|
||||
writer.output("stderr", format!("{message}\n"))?;
|
||||
writer.error_response(&request, message)?;
|
||||
continue;
|
||||
}
|
||||
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 = crate::source::stack_source_path(&state);
|
||||
let frame_name = thread
|
||||
.runtime_stack_frames
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("{}::run", thread.name));
|
||||
writer.response(
|
||||
&request,
|
||||
true,
|
||||
json!({
|
||||
"stackFrames": [{
|
||||
"id": thread.frame_id,
|
||||
"name": frame_name,
|
||||
"line": thread.line,
|
||||
"column": 1,
|
||||
"source": {
|
||||
"name": crate::source::source_name(&source_path),
|
||||
"path": source_path,
|
||||
"presentationHint": "normal"
|
||||
}
|
||||
}],
|
||||
"totalFrames": 1
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
"source" => match crate::source::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 + crate::demo_backend::MAIN_THREAD);
|
||||
let thread = state
|
||||
.threads
|
||||
.values()
|
||||
.find(|thread| thread.frame_id == frame_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| state.threads[&crate::demo_backend::MAIN_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" => {
|
||||
let stopped_thread = default_thread_id(&state);
|
||||
match freeze_all(
|
||||
&mut state,
|
||||
stopped_thread,
|
||||
simulated_freeze_failure_thread(&request),
|
||||
) {
|
||||
Ok(()) => {
|
||||
if let Err(err) =
|
||||
record_coordinator_debug_epoch(&mut state, stopped_thread, "pause")
|
||||
{
|
||||
let message = format!("coordinator debug epoch failed: {err:#}");
|
||||
writer.output("stderr", format!("{message}\n"))?;
|
||||
writer.error_response(&request, message)?;
|
||||
continue;
|
||||
}
|
||||
writer.response(&request, true, json!({}))?;
|
||||
writer.event(
|
||||
"stopped",
|
||||
json!({
|
||||
"reason": "pause",
|
||||
"description": "Disasmer Debug Epoch pause",
|
||||
"threadId": stopped_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_else(|| default_thread_id(&state));
|
||||
let next_breakpoint = next_breakpoint_after(&state, thread_id);
|
||||
let previous_debug_epoch = state.coordinator_debug_epoch.unwrap_or(state.epoch);
|
||||
if let Err(err) = resume_coordinator_epoch(&mut state) {
|
||||
let message = format!("coordinator debug epoch resume failed: {err:#}");
|
||||
writer.output("stderr", format!("{message}\n"))?;
|
||||
writer.error_response(&request, message)?;
|
||||
continue;
|
||||
}
|
||||
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 matches!(
|
||||
state.runtime_backend,
|
||||
RuntimeBackend::LocalServices | RuntimeBackend::LiveServices
|
||||
) {
|
||||
match wait_for_services_runtime_outcome(&state, previous_debug_epoch) {
|
||||
Ok(RuntimeContinuationOutcome::Breakpoint(record)) => {
|
||||
state.apply_runtime_record(record);
|
||||
let stopped_thread = stopped_thread_for_breakpoint(&state);
|
||||
position_confirmed_breakpoint_stop(&mut state, stopped_thread);
|
||||
writer.event(
|
||||
"stopped",
|
||||
json!({
|
||||
"reason": "breakpoint",
|
||||
"description": "Disasmer Debug Epoch all-stop confirmed by every active participant",
|
||||
"threadId": stopped_thread,
|
||||
"allThreadsStopped": true,
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
Ok(RuntimeContinuationOutcome::Terminal(record)) => {
|
||||
state.apply_runtime_record(record);
|
||||
if state.last_task_failed {
|
||||
writer.output("stderr", format!("{}\n", state.command_status))?;
|
||||
}
|
||||
writer.event("terminated", json!({}))?;
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("continued runtime observation failed: {err:#}");
|
||||
writer.output("stderr", format!("{message}\n"))?;
|
||||
writer.event(
|
||||
"stopped",
|
||||
json!({
|
||||
"reason": "exception",
|
||||
"description": message,
|
||||
"threadId": thread_id,
|
||||
"allThreadsStopped": false,
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some((stopped_thread, line)) = next_breakpoint {
|
||||
match freeze_all_at_line(&mut state, stopped_thread, line, None) {
|
||||
Ok(()) => {
|
||||
if let Err(err) = record_coordinator_debug_epoch(
|
||||
&mut state,
|
||||
stopped_thread,
|
||||
"breakpoint",
|
||||
) {
|
||||
let message = format!("coordinator debug epoch failed: {err:#}");
|
||||
writer.output("stderr", format!("{message}\n"))?;
|
||||
writer.error_response(&request, message)?;
|
||||
continue;
|
||||
}
|
||||
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!({}))?;
|
||||
}
|
||||
}
|
||||
"next" | "stepIn" | "stepOut" => {
|
||||
if state.runtime_backend != RuntimeBackend::Simulated {
|
||||
writer.error_response(
|
||||
&request,
|
||||
"source stepping is not yet available from the executing node; use continue or pause instead of a synthetic step",
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
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)
|
||||
.or_else(|| {
|
||||
request
|
||||
.get("arguments")
|
||||
.and_then(|arguments| arguments.get("frameId"))
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|frame_id| {
|
||||
state
|
||||
.threads
|
||||
.values()
|
||||
.find(|thread| thread.frame_id == frame_id)
|
||||
.map(|thread| thread.id)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| default_thread_id(&state));
|
||||
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 state.runtime_backend != RuntimeBackend::Simulated {
|
||||
if thread_id == MAIN_THREAD && restarting_failed_task {
|
||||
match relaunch_services_main_runtime(&state) {
|
||||
Ok(record) => {
|
||||
state.apply_runtime_record(record);
|
||||
let stopped_thread = stopped_thread_for_breakpoint(&state);
|
||||
position_confirmed_breakpoint_stop(&mut state, stopped_thread);
|
||||
writer.response(&request, true, json!({}))?;
|
||||
writer.output(
|
||||
"console",
|
||||
"Rebuilt the current bundle and restarted the failed coordinator main from its entry boundary\n",
|
||||
)?;
|
||||
writer.event(
|
||||
"stopped",
|
||||
json!({
|
||||
"reason": "breakpoint",
|
||||
"description": "Restarted main from the rebuilt bundle; every active participant confirmed all-stop",
|
||||
"threadId": stopped_thread,
|
||||
"allThreadsStopped": true,
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("coordinator main restart failed: {err:#}");
|
||||
writer.output("stderr", format!("{message}\n"))?;
|
||||
writer.error_response(&request, message)?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match restart_task_through_coordinator(&mut state, thread_id) {
|
||||
Ok(message) => {
|
||||
let previous_debug_epoch =
|
||||
state.coordinator_debug_epoch.unwrap_or(state.epoch);
|
||||
if let Some(thread) = state.threads.get_mut(&thread_id) {
|
||||
thread.state = DebugRuntimeState::Running;
|
||||
thread.recent_output.push(message.clone());
|
||||
}
|
||||
state.last_task_failed = false;
|
||||
state.command_status = message.clone();
|
||||
writer.response(&request, true, json!({}))?;
|
||||
writer.output("console", format!("{message}\n"))?;
|
||||
if matches!(
|
||||
state.runtime_backend,
|
||||
RuntimeBackend::LocalServices | RuntimeBackend::LiveServices
|
||||
) {
|
||||
match wait_for_services_runtime_outcome(
|
||||
&state,
|
||||
previous_debug_epoch,
|
||||
) {
|
||||
Ok(RuntimeContinuationOutcome::Breakpoint(record)) => {
|
||||
state.apply_runtime_record(record);
|
||||
let stopped_thread = stopped_thread_for_breakpoint(&state);
|
||||
position_confirmed_breakpoint_stop(
|
||||
&mut state,
|
||||
stopped_thread,
|
||||
);
|
||||
writer.event(
|
||||
"stopped",
|
||||
json!({
|
||||
"reason": "breakpoint",
|
||||
"description": "Restarted task reached a Wasm probe and every active participant confirmed all-stop",
|
||||
"threadId": stopped_thread,
|
||||
"allThreadsStopped": true,
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
Ok(RuntimeContinuationOutcome::Terminal(record)) => {
|
||||
state.apply_runtime_record(record);
|
||||
writer.event("terminated", json!({}))?;
|
||||
}
|
||||
Err(err) => {
|
||||
writer.event(
|
||||
"stopped",
|
||||
json!({
|
||||
"reason": "exception",
|
||||
"description": format!("restarted runtime observation failed: {err:#}"),
|
||||
"threadId": thread_id,
|
||||
"allThreadsStopped": false,
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("coordinator task restart failed: {err:#}");
|
||||
writer.output("stderr", format!("{message}\n"))?;
|
||||
writer.error_response(&request, message)?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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 restart_task_through_coordinator(state: &mut AdapterState, thread_id: i64) -> Result<String> {
|
||||
let task = state
|
||||
.threads
|
||||
.get(&thread_id)
|
||||
.or_else(|| state.threads.get(&default_thread_id(state)))
|
||||
.map(|thread| thread.task.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("selected debug thread does not map to a virtual task"))?;
|
||||
let record = restart_task(state, &task)?;
|
||||
if !record.accepted {
|
||||
let mut message = format!(
|
||||
"coordinator refused task restart for `{task}`: {}",
|
||||
record.message
|
||||
);
|
||||
if record.requires_whole_process_restart {
|
||||
message.push_str("; whole virtual-process restart required");
|
||||
}
|
||||
if record.active_task {
|
||||
message.push_str("; selected task is still active");
|
||||
}
|
||||
if record.completed_event_observed {
|
||||
message.push_str("; only terminal task-event metadata is available");
|
||||
}
|
||||
return Err(anyhow::anyhow!(message));
|
||||
}
|
||||
if !record.clean_boundary_available {
|
||||
return Err(anyhow::anyhow!(
|
||||
"coordinator accepted task restart for `{task}` without a clean checkpoint boundary"
|
||||
));
|
||||
}
|
||||
let restarted_task = record.restarted_task_instance.ok_or_else(|| {
|
||||
anyhow::anyhow!("coordinator accepted task restart without a new task-instance ID")
|
||||
})?;
|
||||
if let Some(thread) = state.threads.get_mut(&thread_id) {
|
||||
thread.task = restarted_task.clone();
|
||||
}
|
||||
state.debug_probes =
|
||||
crate::breakpoints::load_bundle_debug_probes(&state.project, &state.source_path);
|
||||
Ok(format!(
|
||||
"Coordinator restarted task `{task}` as `{restarted_task}` from the rebuilt bundle and a clean runtime checkpoint boundary"
|
||||
))
|
||||
}
|
||||
|
||||
fn record_coordinator_debug_epoch(
|
||||
state: &mut AdapterState,
|
||||
stopped_thread: i64,
|
||||
reason: &str,
|
||||
) -> Result<()> {
|
||||
if state.runtime_backend == RuntimeBackend::Simulated {
|
||||
return Ok(());
|
||||
}
|
||||
let stopped_task = state
|
||||
.threads
|
||||
.get(&stopped_thread)
|
||||
.map(|thread| thread.task.clone())
|
||||
.unwrap_or_else(|| state.threads[&default_thread_id(state)].task.clone());
|
||||
let record = create_debug_epoch(state, &stopped_task, reason)?;
|
||||
let status = wait_for_debug_epoch_frozen(state, record.epoch)?;
|
||||
state.epoch = record.epoch;
|
||||
state.coordinator_debug_epoch = Some(record.epoch);
|
||||
let previous_status = state.command_status.clone();
|
||||
state.command_status = format!(
|
||||
"{previous_status}; debug epoch {} {} after {}/{} signed participant freeze acknowledgements",
|
||||
status.epoch,
|
||||
status.command,
|
||||
status.acknowledgements.len(),
|
||||
status.expected_tasks
|
||||
);
|
||||
if let Some(thread) = state.threads.get_mut(&stopped_thread) {
|
||||
thread.recent_output.push(format!(
|
||||
"coordinator debug epoch {} reached all-stop after {}/{} signed participant acknowledgements",
|
||||
status.epoch,
|
||||
status.acknowledgements.len(),
|
||||
status.expected_tasks
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_thread_id(state: &AdapterState) -> i64 {
|
||||
if state.runtime_backend == RuntimeBackend::Simulated
|
||||
&& state.threads.contains_key(&LINUX_THREAD)
|
||||
{
|
||||
LINUX_THREAD
|
||||
} else {
|
||||
MAIN_THREAD
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_coordinator_epoch(state: &mut AdapterState) -> Result<()> {
|
||||
let Some(epoch) = state.coordinator_debug_epoch else {
|
||||
return Ok(());
|
||||
};
|
||||
if state.runtime_backend == RuntimeBackend::Simulated {
|
||||
return Ok(());
|
||||
}
|
||||
let record = resume_debug_epoch(state, epoch)?;
|
||||
let status = wait_for_debug_epoch_resumed(state, epoch)?;
|
||||
state.coordinator_debug_epoch = None;
|
||||
state.command_status = format!(
|
||||
"debug epoch {} resumed after {}/{} signed participant acknowledgements",
|
||||
status.epoch,
|
||||
status.acknowledgements.len(),
|
||||
status.expected_tasks
|
||||
);
|
||||
let status_thread = default_thread_id(state);
|
||||
if let Some(thread) = state.threads.get_mut(&status_thread) {
|
||||
thread.recent_output.push(format!(
|
||||
"coordinator debug epoch {} resumed with {} after {}/{} acknowledgements ({} affected task(s))",
|
||||
status.epoch,
|
||||
status.command,
|
||||
status.acknowledgements.len(),
|
||||
status.expected_tasks,
|
||||
record.affected_tasks
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_backend_from_launch_arg(
|
||||
value: Option<&str>,
|
||||
) -> Result<RuntimeBackend, String> {
|
||||
match value {
|
||||
None | Some("local-services") => Ok(RuntimeBackend::LocalServices),
|
||||
Some("live-services") => Ok(RuntimeBackend::LiveServices),
|
||||
Some(value) if is_explicit_demo_backend(value) => Ok(RuntimeBackend::Simulated),
|
||||
Some(value) => Err(format!(
|
||||
"unsupported Disasmer runtimeBackend `{value}`; use local-services, live-services, or explicit demo"
|
||||
)),
|
||||
}
|
||||
}
|
||||
340
crates/disasmer-dap/src/breakpoints.rs
Normal file
340
crates/disasmer-dap/src/breakpoints.rs
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
use std::fs;
|
||||
|
||||
use disasmer_core::{
|
||||
discover_source_debug_probes, BundleDebugProbe, DebugRuntimeState, TaskInstanceId,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD};
|
||||
use crate::virtual_model::{AdapterState, VirtualThread};
|
||||
|
||||
pub(crate) fn request_thread<'a>(request: &Value, state: &'a AdapterState) -> &'a VirtualThread {
|
||||
let thread_id = request_thread_id(request).unwrap_or(MAIN_THREAD);
|
||||
state
|
||||
.threads
|
||||
.get(&thread_id)
|
||||
.unwrap_or_else(|| &state.threads[&MAIN_THREAD])
|
||||
}
|
||||
|
||||
pub(crate) fn request_thread_id(request: &Value) -> Option<i64> {
|
||||
request
|
||||
.get("arguments")
|
||||
.and_then(|value| value.get("threadId"))
|
||||
.and_then(Value::as_i64)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ResolvedBreakpoint {
|
||||
pub(crate) id: usize,
|
||||
pub(crate) line: i64,
|
||||
pub(crate) verified: bool,
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
impl ResolvedBreakpoint {
|
||||
pub(crate) fn to_dap(&self) -> Value {
|
||||
json!({
|
||||
"id": self.id,
|
||||
"verified": self.verified,
|
||||
"line": self.line,
|
||||
"message": self.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_bundle_debug_probes(project: &str, source_path: &str) -> Vec<BundleDebugProbe> {
|
||||
let source = fs::read_to_string(crate::source::resolve_source_path(project, source_path));
|
||||
source
|
||||
.map(|source| discover_source_debug_probes(source_path, &source))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_breakpoints(
|
||||
state: &mut AdapterState,
|
||||
requested_lines: Vec<i64>,
|
||||
) -> Vec<ResolvedBreakpoint> {
|
||||
let resolved = requested_lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, line)| {
|
||||
let probe = debug_probe_for_line(state, line);
|
||||
let verified = probe.is_some()
|
||||
|| (state.debug_probes.is_empty()
|
||||
&& source_function_name_at_line(state, line).is_some());
|
||||
let message = match probe {
|
||||
Some(probe) => format!(
|
||||
"Mapped to Disasmer debug probe {} for task {}",
|
||||
probe.id, probe.task
|
||||
),
|
||||
None if verified => "Mapped to Disasmer virtual source location".to_owned(),
|
||||
None => "No Disasmer debug probe metadata covers this source line".to_owned(),
|
||||
};
|
||||
ResolvedBreakpoint {
|
||||
id: index + 1,
|
||||
line,
|
||||
verified,
|
||||
message,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
state.breakpoints = resolved
|
||||
.iter()
|
||||
.filter(|breakpoint| breakpoint.verified)
|
||||
.map(|breakpoint| breakpoint.line)
|
||||
.collect();
|
||||
resolved
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_breakpoints_for_source(
|
||||
state: &mut AdapterState,
|
||||
requested_source_path: Option<&str>,
|
||||
requested_lines: Vec<i64>,
|
||||
) -> Vec<ResolvedBreakpoint> {
|
||||
if requested_source_path.is_none_or(|requested_source_path| {
|
||||
crate::source::source_paths_match(&state.project, &state.source_path, requested_source_path)
|
||||
}) {
|
||||
return resolve_breakpoints(state, requested_lines);
|
||||
}
|
||||
|
||||
requested_lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, line)| ResolvedBreakpoint {
|
||||
id: index + 1,
|
||||
line,
|
||||
verified: false,
|
||||
message: format!(
|
||||
"This debug session is configured for `{}`; breakpoints from another source file are not applied",
|
||||
state.source_path
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) 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)]
|
||||
pub(crate) struct FreezeFailure {
|
||||
pub(crate) thread_id: i64,
|
||||
pub(crate) task: TaskInstanceId,
|
||||
}
|
||||
|
||||
impl FreezeFailure {
|
||||
pub(crate) fn message(&self) -> String {
|
||||
format!(
|
||||
"debug all-stop failed: participant `{}` on thread {} could not freeze",
|
||||
self.task, self.thread_id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
pub(crate) 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(())
|
||||
}
|
||||
|
||||
pub(crate) fn stopped_thread_for_breakpoint(state: &AdapterState) -> i64 {
|
||||
if let Some(stopped_task) = state.stopped_task.as_ref() {
|
||||
if let Some(thread) = state
|
||||
.threads
|
||||
.values()
|
||||
.find(|thread| &thread.task == stopped_task)
|
||||
{
|
||||
return thread.id;
|
||||
}
|
||||
}
|
||||
state
|
||||
.breakpoints
|
||||
.first()
|
||||
.map(|line| thread_for_source_line(state, *line))
|
||||
.unwrap_or(MAIN_THREAD)
|
||||
}
|
||||
|
||||
pub(crate) fn position_confirmed_breakpoint_stop(state: &mut AdapterState, stopped_thread: i64) {
|
||||
let line = state
|
||||
.breakpoints
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|line| thread_for_source_line(state, *line) == stopped_thread)
|
||||
.or_else(|| state.breakpoints.first().copied());
|
||||
if let (Some(line), Some(thread)) = (line, state.threads.get_mut(&stopped_thread)) {
|
||||
thread.line = line;
|
||||
thread.recent_output.push(format!(
|
||||
"debug epoch {} confirmed frozen by all participants",
|
||||
state.epoch
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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(probe) = debug_probe_for_line(state, line) {
|
||||
return state
|
||||
.threads
|
||||
.values()
|
||||
.find(|thread| thread.task_definition == probe.task)
|
||||
.map(|thread| thread.id)
|
||||
.unwrap_or(MAIN_THREAD);
|
||||
}
|
||||
|
||||
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 debug_probe_for_line(state: &AdapterState, line: i64) -> Option<&BundleDebugProbe> {
|
||||
let line = u32::try_from(line).ok()?;
|
||||
state
|
||||
.debug_probes
|
||||
.iter()
|
||||
.find(|probe| probe.line_start <= line && line <= probe.line_end)
|
||||
}
|
||||
|
||||
pub(crate) fn source_function_name_at_line(state: &AdapterState, line: i64) -> Option<String> {
|
||||
let source = fs::read_to_string(crate::source::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))
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
pub(crate) 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));
|
||||
}
|
||||
}
|
||||
129
crates/disasmer-dap/src/dap_protocol.rs
Normal file
129
crates/disasmer-dap/src/dap_protocol.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
use std::io::{self, BufRead, Write};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DapWriter {
|
||||
seq: i64,
|
||||
}
|
||||
|
||||
impl DapWriter {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self { seq: 1 }
|
||||
}
|
||||
|
||||
pub(crate) 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,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) 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(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) 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,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) 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()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn initialize_capabilities() -> Value {
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsTerminateRequest": true,
|
||||
"supportsRestartRequest": true,
|
||||
"supportsRestartFrame": true,
|
||||
"supportsEvaluateForHovers": true,
|
||||
"supportsStepBack": false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) 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)?))
|
||||
}
|
||||
63
crates/disasmer-dap/src/demo_backend.rs
Normal file
63
crates/disasmer-dap/src/demo_backend.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use disasmer_core::{DebugRuntimeState, TaskInstanceId};
|
||||
|
||||
use crate::virtual_model::{AdapterState, VirtualThread};
|
||||
|
||||
pub(crate) const MAIN_THREAD: i64 = 1;
|
||||
pub(crate) const LINUX_THREAD: i64 = 2;
|
||||
pub(crate) const WINDOWS_THREAD: i64 = 3;
|
||||
pub(crate) const PACKAGE_THREAD: i64 = 4;
|
||||
|
||||
pub(crate) fn is_explicit_demo_backend(value: &str) -> bool {
|
||||
value == "simulated" || value == "demo"
|
||||
}
|
||||
|
||||
pub(crate) 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-release", "package artifacts", 64),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|thread| (thread.id, thread))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn start_simulated_backend(state: &mut AdapterState) {
|
||||
state.command_status = "running under simulated debug adapter state".to_owned();
|
||||
}
|
||||
|
||||
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: TaskInstanceId::from(task),
|
||||
task_definition: disasmer_core::TaskDefinitionId::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,
|
||||
runtime_stack_frames: Vec::new(),
|
||||
wasm_local_values: Vec::new(),
|
||||
runtime_task_args: Vec::new(),
|
||||
runtime_handles: Vec::new(),
|
||||
runtime_command_status: None,
|
||||
}
|
||||
}
|
||||
43
crates/disasmer-dap/src/main.rs
Normal file
43
crates/disasmer-dap/src/main.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use anyhow::Result;
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
|
||||
mod adapter;
|
||||
mod breakpoints;
|
||||
mod dap_protocol;
|
||||
mod demo_backend;
|
||||
mod runtime_client;
|
||||
mod source;
|
||||
mod variables;
|
||||
mod view_state;
|
||||
mod virtual_model;
|
||||
|
||||
#[cfg(test)]
|
||||
use adapter::runtime_backend_from_launch_arg;
|
||||
#[cfg(test)]
|
||||
use breakpoints::{
|
||||
freeze_all, resolve_breakpoints, resolve_breakpoints_for_source,
|
||||
restart_requires_whole_process, stopped_thread_for_breakpoint,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use dap_protocol::{initialize_capabilities, read_message};
|
||||
#[cfg(test)]
|
||||
use runtime_client::{client_user_request, parse_task_restart_response};
|
||||
#[cfg(test)]
|
||||
use variables::variables_response;
|
||||
#[cfg(test)]
|
||||
use virtual_model::{AdapterState, RuntimeLaunchRecord};
|
||||
|
||||
#[cfg(test)]
|
||||
use demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD};
|
||||
#[cfg(test)]
|
||||
use disasmer_core::{BundleDebugProbe, DebugRuntimeState, TaskInstanceId};
|
||||
#[cfg(test)]
|
||||
use virtual_model::{process_id, RuntimeBackend};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
adapter::run_adapter()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
974
crates/disasmer-dap/src/runtime_client.rs
Normal file
974
crates/disasmer-dap/src/runtime_client.rs
Normal file
|
|
@ -0,0 +1,974 @@
|
|||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use disasmer_core::TaskInstanceId;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::virtual_model::{AdapterState, RuntimeLaunchRecord};
|
||||
|
||||
mod debug_protocol;
|
||||
mod local_tools;
|
||||
mod transport;
|
||||
pub(crate) use debug_protocol::parse_task_restart_response;
|
||||
use debug_protocol::{
|
||||
coordinator_debug_epoch_request, parse_debug_epoch_response, wait_for_debug_epoch_state,
|
||||
};
|
||||
use local_tools::{child_stderr_suffix, local_tool_command};
|
||||
pub(crate) use transport::client_user_request;
|
||||
use transport::coordinator_request;
|
||||
|
||||
pub(crate) struct LocalRuntimeSession {
|
||||
coordinator: Option<Child>,
|
||||
worker: Option<Child>,
|
||||
}
|
||||
|
||||
impl Drop for LocalRuntimeSession {
|
||||
fn drop(&mut self) {
|
||||
for child in [&mut self.worker, &mut self.coordinator] {
|
||||
if let Some(mut child) = child.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DebugBundle {
|
||||
module_base64: String,
|
||||
digest: String,
|
||||
entry_export: String,
|
||||
entry_definition: String,
|
||||
}
|
||||
|
||||
struct ChildGuard(Option<Child>);
|
||||
|
||||
impl ChildGuard {
|
||||
fn new(child: Child) -> Self {
|
||||
Self(Some(child))
|
||||
}
|
||||
|
||||
fn child_mut(&mut self) -> &mut Child {
|
||||
self.0.as_mut().expect("guarded child is present")
|
||||
}
|
||||
|
||||
fn take(&mut self) -> Child {
|
||||
self.0.take().expect("guarded child is present")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child) = self.0.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct DebugEpochRecord {
|
||||
pub(crate) epoch: u64,
|
||||
pub(crate) command: String,
|
||||
pub(crate) affected_tasks: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct DebugEpochStatusRecord {
|
||||
pub(crate) epoch: u64,
|
||||
pub(crate) command: String,
|
||||
pub(crate) expected_tasks: usize,
|
||||
pub(crate) acknowledgements: Vec<Value>,
|
||||
pub(crate) fully_frozen: bool,
|
||||
pub(crate) fully_resumed: bool,
|
||||
pub(crate) failed: bool,
|
||||
pub(crate) failure_messages: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct TaskRestartRecord {
|
||||
pub(crate) accepted: bool,
|
||||
pub(crate) restarted_task_instance: Option<TaskInstanceId>,
|
||||
pub(crate) clean_boundary_available: bool,
|
||||
pub(crate) requires_whole_process_restart: bool,
|
||||
pub(crate) active_task: bool,
|
||||
pub(crate) completed_event_observed: bool,
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
pub(crate) enum RuntimeContinuationOutcome {
|
||||
Breakpoint(RuntimeLaunchRecord),
|
||||
Terminal(RuntimeLaunchRecord),
|
||||
}
|
||||
|
||||
pub(crate) fn run_local_services_runtime(
|
||||
state: &AdapterState,
|
||||
) -> Result<(RuntimeLaunchRecord, LocalRuntimeSession)> {
|
||||
let repo = std::env::current_dir()?;
|
||||
let mut coordinator_command = local_tool_command(
|
||||
"DISASMER_COORDINATOR_BIN",
|
||||
"disasmer-coordinator",
|
||||
"disasmer-coordinator",
|
||||
&repo,
|
||||
);
|
||||
let mut coordinator = coordinator_command
|
||||
.args(["--listen", "127.0.0.1:0", "--allow-local-trusted-loopback"])
|
||||
.current_dir(&repo)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let result = (|| {
|
||||
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"))?
|
||||
.to_owned();
|
||||
coordinator_request(
|
||||
&listen,
|
||||
json!({
|
||||
"type": "create_project",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"name": "DAP local services project",
|
||||
}),
|
||||
)?;
|
||||
let enrollment = coordinator_request(
|
||||
&listen,
|
||||
json!({
|
||||
"type": "create_node_enrollment_grant",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"ttl_seconds": 60,
|
||||
}),
|
||||
)?;
|
||||
let enrollment_grant = enrollment
|
||||
.get("grant")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("local coordinator omitted the node enrollment grant"))?
|
||||
.to_owned();
|
||||
|
||||
let mut worker_command =
|
||||
local_tool_command("DISASMER_NODE_BIN", "disasmer-node", "disasmer-node", &repo);
|
||||
let mut worker = ChildGuard::new(
|
||||
worker_command
|
||||
.args([
|
||||
"--coordinator",
|
||||
&listen,
|
||||
"--tenant",
|
||||
state.tenant.as_str(),
|
||||
"--project-id",
|
||||
state.project_id.as_str(),
|
||||
"--node",
|
||||
"dap-node",
|
||||
"--enrollment-grant",
|
||||
&enrollment_grant,
|
||||
"--worker",
|
||||
"--project-root",
|
||||
&state.project,
|
||||
"--assignment-poll-ms",
|
||||
"20",
|
||||
])
|
||||
.current_dir(&repo)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?,
|
||||
);
|
||||
wait_for_local_node(&listen, state, "dap-node", worker.child_mut())?;
|
||||
|
||||
let record = launch_services_debug_entrypoint(&listen, state, &repo)?;
|
||||
Ok((record, worker.take()))
|
||||
})();
|
||||
match result {
|
||||
Ok((record, worker)) => Ok((
|
||||
record,
|
||||
LocalRuntimeSession {
|
||||
coordinator: Some(coordinator),
|
||||
worker: Some(worker),
|
||||
},
|
||||
)),
|
||||
Err(error) => {
|
||||
let _ = coordinator.kill();
|
||||
let _ = coordinator.wait();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_debug_bundle(state: &AdapterState, repo: &Path) -> Result<DebugBundle> {
|
||||
let mut command = local_tool_command("DISASMER_CLI_BIN", "disasmer", "disasmer-cli", repo);
|
||||
let output = command
|
||||
.args(["build", "--project", &state.project, "--json"])
|
||||
.current_dir(repo)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"Disasmer bundle build failed before debug launch: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let report: Value = serde_json::from_slice(&output.stdout)?;
|
||||
let module_path = report
|
||||
.pointer("/bundle_artifact/module")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("bundle build omitted module path"))?;
|
||||
let module_path = if Path::new(module_path).is_absolute() {
|
||||
Path::new(module_path).to_path_buf()
|
||||
} else {
|
||||
repo.join(module_path)
|
||||
};
|
||||
let module = std::fs::read(&module_path)?;
|
||||
let entrypoints: Value = serde_json::from_slice(&std::fs::read(
|
||||
module_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("bundle module path has no parent"))?
|
||||
.join("entrypoints.json"),
|
||||
)?)?;
|
||||
let descriptor = entrypoints
|
||||
.as_array()
|
||||
.and_then(|descriptors| {
|
||||
descriptors.iter().find(|descriptor| {
|
||||
descriptor.get("name").and_then(Value::as_str) == Some(state.entry.as_str())
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| anyhow!("bundle has no entrypoint `{}`", state.entry))?;
|
||||
let entry_export = descriptor
|
||||
.get("export")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("entrypoint `{}` omitted its Wasm export", state.entry))?
|
||||
.to_owned();
|
||||
let entry_definition = descriptor
|
||||
.get("stable_id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"entrypoint `{}` omitted its stable definition ID",
|
||||
state.entry
|
||||
)
|
||||
})?
|
||||
.to_owned();
|
||||
let digest = report
|
||||
.pointer("/bundle_artifact/bundle_digest")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("bundle build omitted bundle digest"))?
|
||||
.to_owned();
|
||||
Ok(DebugBundle {
|
||||
module_base64: BASE64_STANDARD.encode(module),
|
||||
digest,
|
||||
entry_export,
|
||||
entry_definition,
|
||||
})
|
||||
}
|
||||
|
||||
fn launch_services_debug_entrypoint(
|
||||
coordinator: &str,
|
||||
state: &AdapterState,
|
||||
repo: &Path,
|
||||
) -> Result<RuntimeLaunchRecord> {
|
||||
let bundle = build_debug_bundle(state, repo)?;
|
||||
let started = coordinator_request(
|
||||
coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "start_process",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
"restart": state.restart_existing,
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
let epoch = started
|
||||
.get("epoch")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| anyhow!("coordinator did not report a process epoch"))?;
|
||||
let probe_symbols = state.requested_probe_symbols();
|
||||
if probe_symbols.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"no executable Disasmer probe corresponds to the configured source breakpoints"
|
||||
));
|
||||
}
|
||||
coordinator_request(
|
||||
coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "set_debug_breakpoints",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
"probe_symbols": probe_symbols,
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
let launch = coordinator_request(
|
||||
coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "launch_task",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"task_spec": {
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"process": state.process.as_str(),
|
||||
"task_definition": bundle.entry_definition,
|
||||
"task_instance": format!("ti:{}:main", state.process),
|
||||
"dispatch": {
|
||||
"kind": "coordinator_node_wasm",
|
||||
"export": bundle.entry_export,
|
||||
"abi": "entrypoint_v1",
|
||||
},
|
||||
"environment_id": null,
|
||||
"environment": null,
|
||||
"environment_digest": null,
|
||||
"required_capabilities": [],
|
||||
"dependency_cache": null,
|
||||
"source_snapshot": null,
|
||||
"required_artifacts": [],
|
||||
"args": [],
|
||||
"vfs_epoch": epoch,
|
||||
"bundle_digest": bundle.digest,
|
||||
},
|
||||
"wait_for_node": true,
|
||||
"artifact_path": "/vfs/artifacts/dap-output.txt",
|
||||
"wasm_module_base64": bundle.module_base64,
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
if launch.get("type").and_then(Value::as_str) != Some("main_launched") {
|
||||
return Err(anyhow!(
|
||||
"coordinator did not start the capless main runtime: {}",
|
||||
serde_json::to_string(&launch)?
|
||||
));
|
||||
}
|
||||
let breakpoint = wait_for_breakpoint_hit(coordinator, state)?;
|
||||
let debug_epoch = breakpoint
|
||||
.get("hit_epoch")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| anyhow!("breakpoint status omitted the hit debug epoch"))?;
|
||||
let frozen = wait_for_debug_epoch_state_at(coordinator, state, debug_epoch, true)?;
|
||||
let expected = frozen
|
||||
.get("expected_tasks")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0);
|
||||
let acknowledged = frozen
|
||||
.get("acknowledgements")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0);
|
||||
if expected == 0 || acknowledged != expected {
|
||||
return Err(anyhow!(
|
||||
"debug epoch {debug_epoch} claimed frozen without every active participant"
|
||||
));
|
||||
}
|
||||
let process_statuses = coordinator_request(
|
||||
coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "list_processes",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
let process_status = process_statuses
|
||||
.get("processes")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|processes| {
|
||||
processes.iter().find(|process| {
|
||||
process.get("process").and_then(Value::as_str) == Some(state.process.as_str())
|
||||
})
|
||||
})
|
||||
.cloned();
|
||||
let node = frozen
|
||||
.get("acknowledgements")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("node"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("coordinator-main")
|
||||
.to_owned();
|
||||
Ok(RuntimeLaunchRecord {
|
||||
coordinator: coordinator.to_owned(),
|
||||
node,
|
||||
node_report: json!({
|
||||
"process": started,
|
||||
"task_launch": launch,
|
||||
"breakpoint": breakpoint,
|
||||
"debug_epoch": frozen,
|
||||
"process_status": process_status,
|
||||
"process_statuses": process_statuses,
|
||||
}),
|
||||
task_events: json!({ "events": [] }),
|
||||
placed_task_launched: true,
|
||||
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,
|
||||
debug_epoch: Some(debug_epoch),
|
||||
stopped_task: breakpoint
|
||||
.get("hit_task")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
stopped_probe_symbol: breakpoint
|
||||
.get("hit_probe_symbol")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
all_participants_frozen: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_local_node(
|
||||
coordinator: &str,
|
||||
state: &AdapterState,
|
||||
node: &str,
|
||||
worker: &mut Child,
|
||||
) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
if let Some(status) = worker.try_wait()? {
|
||||
return Err(anyhow!(
|
||||
"local Disasmer worker `{node}` exited before attaching ({status}){}",
|
||||
child_stderr_suffix(worker)
|
||||
));
|
||||
}
|
||||
let response = coordinator_request(
|
||||
coordinator,
|
||||
json!({
|
||||
"type": "list_node_descriptors",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
}),
|
||||
)?;
|
||||
if response
|
||||
.get("descriptors")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|descriptors| {
|
||||
descriptors
|
||||
.iter()
|
||||
.any(|descriptor| descriptor.get("id").and_then(Value::as_str) == Some(node))
|
||||
})
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = worker.kill();
|
||||
let _ = worker.wait();
|
||||
return Err(anyhow!(
|
||||
"local Disasmer worker `{node}` did not attach within 30 seconds{}",
|
||||
child_stderr_suffix(worker)
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_breakpoint_hit(coordinator: &str, state: &AdapterState) -> Result<Value> {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let response = coordinator_request(
|
||||
coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "inspect_debug_breakpoints",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
if response.get("hit_epoch").and_then(Value::as_u64).is_some() {
|
||||
return Ok(response);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"executing Wasm did not reach any configured source breakpoint within 30 seconds"
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_debug_epoch_state_at(
|
||||
coordinator: &str,
|
||||
state: &AdapterState,
|
||||
epoch: u64,
|
||||
frozen: bool,
|
||||
) -> Result<Value> {
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let response = coordinator_request(
|
||||
coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "inspect_debug_epoch",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
"epoch": epoch,
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
if response.get("failed").and_then(Value::as_bool) == Some(true) {
|
||||
return Err(anyhow!(
|
||||
"debug epoch {epoch} participant failed: {}",
|
||||
response
|
||||
.get("failure_messages")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
));
|
||||
}
|
||||
let ready_field = if frozen {
|
||||
"fully_frozen"
|
||||
} else {
|
||||
"fully_resumed"
|
||||
};
|
||||
if response.get(ready_field).and_then(Value::as_bool) == Some(true) {
|
||||
return Ok(response);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"debug epoch {epoch} did not reach {ready_field} within 60 seconds"
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_live_services_runtime(state: &AdapterState) -> Result<RuntimeLaunchRecord> {
|
||||
let coordinator =
|
||||
crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint);
|
||||
let repo = std::env::current_dir()?;
|
||||
launch_services_debug_entrypoint(&coordinator, state, &repo)
|
||||
}
|
||||
|
||||
pub(crate) fn relaunch_services_main_runtime(state: &AdapterState) -> Result<RuntimeLaunchRecord> {
|
||||
let coordinator =
|
||||
crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint);
|
||||
let repo = std::env::current_dir()?;
|
||||
let mut restart_state = state.clone();
|
||||
restart_state.restart_existing = true;
|
||||
launch_services_debug_entrypoint(&coordinator, &restart_state, &repo)
|
||||
}
|
||||
|
||||
pub(crate) fn attach_services_runtime(state: &AdapterState) -> Result<RuntimeLaunchRecord> {
|
||||
let coordinator =
|
||||
crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint);
|
||||
let debug_attach = coordinator_request(
|
||||
&coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "debug_attach",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
let allowed = debug_attach
|
||||
.get("authorization")
|
||||
.and_then(|authorization| authorization.get("allowed"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if !allowed {
|
||||
let reason = debug_attach
|
||||
.get("authorization")
|
||||
.and_then(|authorization| authorization.get("reason"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("debug attach was denied by coordinator authorization");
|
||||
return Err(anyhow!("debug attach denied: {reason}"));
|
||||
}
|
||||
let events = coordinator_request(
|
||||
&coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
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);
|
||||
let process_statuses = coordinator_request(
|
||||
&coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "list_processes",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
let process_status = process_statuses
|
||||
.get("processes")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|processes| {
|
||||
processes.iter().find(|process| {
|
||||
process.get("process").and_then(Value::as_str) == Some(state.process.as_str())
|
||||
})
|
||||
})
|
||||
.cloned();
|
||||
let node = process_status
|
||||
.as_ref()
|
||||
.and_then(|status| status.get("connected_nodes"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|nodes| nodes.first())
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("coordinator-main")
|
||||
.to_owned();
|
||||
let active_main = process_status
|
||||
.as_ref()
|
||||
.and_then(|status| status.get("main_task_instance"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some();
|
||||
|
||||
Ok(RuntimeLaunchRecord {
|
||||
coordinator,
|
||||
node,
|
||||
node_report: json!({
|
||||
"debug_attach": debug_attach,
|
||||
"process_status": process_status,
|
||||
"process_statuses": process_statuses,
|
||||
}),
|
||||
task_events: events,
|
||||
placed_task_launched: active_main || event_count > 0,
|
||||
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,
|
||||
debug_epoch: None,
|
||||
stopped_task: None,
|
||||
stopped_probe_symbol: None,
|
||||
all_participants_frozen: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn create_debug_epoch(
|
||||
state: &AdapterState,
|
||||
stopped_task: &TaskInstanceId,
|
||||
reason: &str,
|
||||
) -> Result<DebugEpochRecord> {
|
||||
let response = coordinator_debug_epoch_request(
|
||||
state,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "create_debug_epoch",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
"stopped_task": stopped_task.as_str(),
|
||||
"reason": reason,
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
parse_debug_epoch_response(response)
|
||||
}
|
||||
|
||||
pub(crate) fn resume_debug_epoch(state: &AdapterState, epoch: u64) -> Result<DebugEpochRecord> {
|
||||
let response = coordinator_debug_epoch_request(
|
||||
state,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "resume_debug_epoch",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
"epoch": epoch,
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
parse_debug_epoch_response(response)
|
||||
}
|
||||
|
||||
pub(crate) fn wait_for_debug_epoch_frozen(
|
||||
state: &AdapterState,
|
||||
epoch: u64,
|
||||
) -> Result<DebugEpochStatusRecord> {
|
||||
wait_for_debug_epoch_state(state, epoch, true)
|
||||
}
|
||||
|
||||
pub(crate) fn wait_for_debug_epoch_resumed(
|
||||
state: &AdapterState,
|
||||
epoch: u64,
|
||||
) -> Result<DebugEpochStatusRecord> {
|
||||
wait_for_debug_epoch_state(state, epoch, false)
|
||||
}
|
||||
|
||||
pub(crate) fn wait_for_services_runtime_outcome(
|
||||
state: &AdapterState,
|
||||
previous_debug_epoch: u64,
|
||||
) -> Result<RuntimeContinuationOutcome> {
|
||||
let coordinator =
|
||||
crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint);
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
loop {
|
||||
// Terminal task events remain inspectable after the active process slot is
|
||||
// released. Read them before active-process debug state so a fast main exit
|
||||
// cannot turn a successful continuation into an authorization error.
|
||||
let events = coordinator_request(
|
||||
&coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "list_task_events",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
if let Some(outcome) = terminal_runtime_outcome(&coordinator, state, &events) {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
let breakpoint = match coordinator_request(
|
||||
&coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "inspect_debug_breakpoints",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
}),
|
||||
),
|
||||
) {
|
||||
Ok(breakpoint) => breakpoint,
|
||||
Err(inspect_error) => {
|
||||
// Main completion records its terminal event and clears ephemeral
|
||||
// debug state in one coordinator pump. That pump can occur between
|
||||
// the event read above and this inspection request. Re-read the
|
||||
// durable event stream before treating the missing debug state as
|
||||
// an adapter failure.
|
||||
let events = coordinator_request(
|
||||
&coordinator,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "list_task_events",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
if let Some(outcome) = terminal_runtime_outcome(&coordinator, state, &events) {
|
||||
return Ok(outcome);
|
||||
}
|
||||
return Err(inspect_error);
|
||||
}
|
||||
};
|
||||
if let Some(epoch) = breakpoint.get("hit_epoch").and_then(Value::as_u64) {
|
||||
if epoch > previous_debug_epoch {
|
||||
let frozen = wait_for_debug_epoch_state_at(&coordinator, state, epoch, true)?;
|
||||
let node = frozen
|
||||
.get("acknowledgements")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("node"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_owned();
|
||||
return Ok(RuntimeContinuationOutcome::Breakpoint(
|
||||
RuntimeLaunchRecord {
|
||||
coordinator,
|
||||
node,
|
||||
node_report: json!({
|
||||
"breakpoint": breakpoint,
|
||||
"debug_epoch": frozen,
|
||||
}),
|
||||
task_events: json!({ "events": [] }),
|
||||
placed_task_launched: true,
|
||||
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: state.runtime_event_count,
|
||||
debug_epoch: Some(epoch),
|
||||
stopped_task: breakpoint
|
||||
.get("hit_task")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
stopped_probe_symbol: breakpoint
|
||||
.get("hit_probe_symbol")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
all_participants_frozen: true,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"continued virtual process {} neither reached another executing Wasm breakpoint nor recorded terminal entrypoint state within 120 seconds",
|
||||
state.process
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_runtime_outcome(
|
||||
coordinator: &str,
|
||||
state: &AdapterState,
|
||||
events: &Value,
|
||||
) -> Option<RuntimeContinuationOutcome> {
|
||||
let event = events
|
||||
.get("events")
|
||||
.and_then(Value::as_array)?
|
||||
.get(state.runtime_event_count..)?
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|event| event.get("executor").and_then(Value::as_str) == Some("coordinator_main"))?;
|
||||
let status_code = event
|
||||
.get("status_code")
|
||||
.and_then(Value::as_i64)
|
||||
.map(|status| status as i32)
|
||||
.or_else(
|
||||
|| match event.get("terminal_state").and_then(Value::as_str) {
|
||||
Some("completed") => Some(0),
|
||||
Some("failed" | "cancelled") => Some(1),
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
Some(RuntimeContinuationOutcome::Terminal(RuntimeLaunchRecord {
|
||||
coordinator: coordinator.to_owned(),
|
||||
node: event
|
||||
.get("node")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("coordinator-main")
|
||||
.to_owned(),
|
||||
node_report: json!({ "terminal_event": event }),
|
||||
task_events: events.clone(),
|
||||
placed_task_launched: true,
|
||||
status_code,
|
||||
stdout_bytes: event
|
||||
.get("stdout_bytes")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
stderr_bytes: event
|
||||
.get("stderr_bytes")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
stdout_tail: event
|
||||
.get("stdout_tail")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned(),
|
||||
stderr_tail: event
|
||||
.get("stderr_tail")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned(),
|
||||
stdout_truncated: event
|
||||
.get("stdout_truncated")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
stderr_truncated: event
|
||||
.get("stderr_truncated")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
artifact_path: event
|
||||
.get("artifact_path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
event_count: events
|
||||
.get("events")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0),
|
||||
debug_epoch: None,
|
||||
stopped_task: None,
|
||||
stopped_probe_symbol: None,
|
||||
all_participants_frozen: false,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn restart_task(
|
||||
state: &AdapterState,
|
||||
task: &TaskInstanceId,
|
||||
) -> Result<TaskRestartRecord> {
|
||||
let repo = std::env::current_dir()?;
|
||||
let replacement = build_debug_bundle(state, &repo)?;
|
||||
let response = coordinator_debug_epoch_request(
|
||||
state,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "restart_task",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
"task": task.as_str(),
|
||||
"replacement_bundle": {
|
||||
"bundle_digest": replacement.digest,
|
||||
"wasm_module_base64": replacement.module_base64,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
parse_task_restart_response(response)
|
||||
}
|
||||
177
crates/disasmer-dap/src/runtime_client/debug_protocol.rs
Normal file
177
crates/disasmer-dap/src/runtime_client/debug_protocol.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use disasmer_core::TaskInstanceId;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::virtual_model::AdapterState;
|
||||
|
||||
use super::{
|
||||
client_user_request, coordinator_request, DebugEpochRecord, DebugEpochStatusRecord,
|
||||
TaskRestartRecord,
|
||||
};
|
||||
|
||||
pub(super) fn coordinator_debug_epoch_request(
|
||||
state: &AdapterState,
|
||||
payload: Value,
|
||||
) -> Result<Value> {
|
||||
let coordinator =
|
||||
crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint);
|
||||
coordinator_request(&coordinator, payload)
|
||||
}
|
||||
|
||||
pub(super) fn parse_debug_epoch_response(response: Value) -> Result<DebugEpochRecord> {
|
||||
let epoch = response
|
||||
.get("epoch")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| anyhow!("coordinator debug epoch response did not include an epoch"))?;
|
||||
let command = response
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("coordinator debug epoch response did not include a command"))?
|
||||
.to_owned();
|
||||
let affected_tasks = response
|
||||
.get("affected_tasks")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0);
|
||||
Ok(DebugEpochRecord {
|
||||
epoch,
|
||||
command,
|
||||
affected_tasks,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn wait_for_debug_epoch_state(
|
||||
state: &AdapterState,
|
||||
epoch: u64,
|
||||
frozen: bool,
|
||||
) -> Result<DebugEpochStatusRecord> {
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let response = coordinator_debug_epoch_request(
|
||||
state,
|
||||
client_user_request(
|
||||
state,
|
||||
json!({
|
||||
"type": "inspect_debug_epoch",
|
||||
"tenant": state.tenant,
|
||||
"project": state.project_id,
|
||||
"actor_user": state.actor_user,
|
||||
"process": state.process.as_str(),
|
||||
"epoch": epoch,
|
||||
}),
|
||||
),
|
||||
)?;
|
||||
let status = parse_debug_epoch_status(response)?;
|
||||
if status.failed {
|
||||
return Err(anyhow!(
|
||||
"debug epoch {epoch} participant failed: {}",
|
||||
status.failure_messages.join("; ")
|
||||
));
|
||||
}
|
||||
if (frozen && status.fully_frozen) || (!frozen && status.fully_resumed) {
|
||||
return Ok(status);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"debug epoch {epoch} did not receive {}/{} signed participant acknowledgements for {} within 60 seconds",
|
||||
status.acknowledgements.len(),
|
||||
status.expected_tasks,
|
||||
if frozen { "frozen state" } else { "resumed state" }
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_debug_epoch_status(response: Value) -> Result<DebugEpochStatusRecord> {
|
||||
let epoch = response
|
||||
.get("epoch")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| anyhow!("coordinator debug epoch status omitted epoch"))?;
|
||||
let command = response
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("coordinator debug epoch status omitted command"))?
|
||||
.to_owned();
|
||||
let expected_tasks = response
|
||||
.get("expected_tasks")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0);
|
||||
let acknowledgements = response
|
||||
.get("acknowledgements")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let failure_messages = response
|
||||
.get("failure_messages")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
Ok(DebugEpochStatusRecord {
|
||||
epoch,
|
||||
command,
|
||||
expected_tasks,
|
||||
acknowledgements,
|
||||
fully_frozen: response
|
||||
.get("fully_frozen")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
fully_resumed: response
|
||||
.get("fully_resumed")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
failed: response
|
||||
.get("failed")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
failure_messages,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_task_restart_response(response: Value) -> Result<TaskRestartRecord> {
|
||||
Ok(TaskRestartRecord {
|
||||
accepted: response
|
||||
.get("accepted")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| anyhow!("coordinator task restart response did not include accepted"))?,
|
||||
restarted_task_instance: response
|
||||
.get("restarted_task_instance")
|
||||
.and_then(Value::as_str)
|
||||
.map(TaskInstanceId::new),
|
||||
clean_boundary_available: response
|
||||
.get("clean_boundary_available")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| {
|
||||
anyhow!("coordinator task restart response did not include clean_boundary_available")
|
||||
})?,
|
||||
requires_whole_process_restart: response
|
||||
.get("requires_whole_process_restart")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"coordinator task restart response did not include requires_whole_process_restart"
|
||||
)
|
||||
})?,
|
||||
active_task: response
|
||||
.get("active_task")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| anyhow!("coordinator task restart response did not include active_task"))?,
|
||||
completed_event_observed: response
|
||||
.get("completed_event_observed")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| {
|
||||
anyhow!("coordinator task restart response did not include completed_event_observed")
|
||||
})?,
|
||||
message: response
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("coordinator task restart response did not include message"))?
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
63
crates/disasmer-dap/src/runtime_client/local_tools.rs
Normal file
63
crates/disasmer-dap/src/runtime_client/local_tools.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
|
||||
pub(super) fn child_stderr_suffix(child: &mut Child) -> String {
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut stream) = child.stderr.take() {
|
||||
let _ = stream.read_to_string(&mut stderr);
|
||||
}
|
||||
let stderr = stderr.trim();
|
||||
if stderr.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("; worker stderr: {stderr}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn local_tool_command(
|
||||
env_var: &str,
|
||||
binary: &str,
|
||||
package: &str,
|
||||
repo: &Path,
|
||||
) -> Command {
|
||||
if let Some(path) = std::env::var_os(env_var) {
|
||||
return Command::new(path);
|
||||
}
|
||||
let current_exe = std::env::current_exe().ok();
|
||||
if !workspace_development_executable(current_exe.as_deref(), repo) {
|
||||
if let Some(path) = sibling_tool_path(current_exe.as_deref(), binary) {
|
||||
return Command::new(path);
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace-built adapter must ask Cargo for the matching service binary:
|
||||
// a sibling executable can be older than the adapter even though both happen
|
||||
// to live in target/debug. Installed releases take the sibling path above.
|
||||
let mut command = Command::new("cargo");
|
||||
command.args(["run", "-q", "-p", package, "--bin", binary, "--"]);
|
||||
command
|
||||
}
|
||||
|
||||
fn sibling_tool_path(current_exe: Option<&Path>, binary: &str) -> Option<PathBuf> {
|
||||
let mut sibling = current_exe?.to_path_buf();
|
||||
sibling.set_file_name(format!("{binary}{}", std::env::consts::EXE_SUFFIX));
|
||||
sibling.is_file().then_some(sibling)
|
||||
}
|
||||
|
||||
fn workspace_development_executable(current_exe: Option<&Path>, repo: &Path) -> bool {
|
||||
let Some(current_exe) = current_exe else {
|
||||
return false;
|
||||
};
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR")
|
||||
.map(PathBuf::from)
|
||||
.map(|path| {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
repo.join(path)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| repo.join("target"));
|
||||
current_exe.starts_with(target)
|
||||
}
|
||||
38
crates/disasmer-dap/src/runtime_client/transport.rs
Normal file
38
crates/disasmer-dap/src/runtime_client/transport.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use disasmer_control::ControlSession;
|
||||
use disasmer_core::coordinator_wire_request;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::virtual_model::AdapterState;
|
||||
|
||||
pub(crate) fn client_user_request(state: &AdapterState, mut request: Value) -> Value {
|
||||
let Some(session_secret) = state.client_session_secret.as_deref() else {
|
||||
return request;
|
||||
};
|
||||
if let Value::Object(fields) = &mut request {
|
||||
fields.remove("tenant");
|
||||
fields.remove("project");
|
||||
fields.remove("actor_user");
|
||||
}
|
||||
json!({
|
||||
"type": "authenticated",
|
||||
"session_secret": session_secret,
|
||||
"request": request,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn coordinator_request(addr: &str, request: Value) -> Result<Value> {
|
||||
let mut session = ControlSession::connect(addr)?;
|
||||
let wire_request = coordinator_wire_request("dap-1", request);
|
||||
let response = session.request(&wire_request)?;
|
||||
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)
|
||||
}
|
||||
71
crates/disasmer-dap/src/source.rs
Normal file
71
crates/disasmer-dap/src/source.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::virtual_model::AdapterState;
|
||||
|
||||
pub(crate) fn infer_source_path(project: &str) -> String {
|
||||
if Path::new(project).join("src/build.rs").is_file() {
|
||||
"src/build.rs".to_owned()
|
||||
} else if Path::new(project).join("src/main.rs").is_file() {
|
||||
"src/main.rs".to_owned()
|
||||
} else if Path::new(project).join("src/lib.rs").is_file() {
|
||||
"src/lib.rs".to_owned()
|
||||
} else {
|
||||
"src/main.rs".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_name(source_path: &str) -> &str {
|
||||
Path::new(source_path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(source_path)
|
||||
}
|
||||
|
||||
pub(crate) fn stack_source_path(state: &AdapterState) -> String {
|
||||
normalized_source_path(&state.project, &state.source_path)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn source_paths_match(project: &str, configured: &str, requested: &str) -> bool {
|
||||
normalized_source_path(project, configured) == normalized_source_path(project, requested)
|
||||
}
|
||||
|
||||
pub(crate) 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",
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_source_path(project: &str, source_path: &str) -> PathBuf {
|
||||
let path = Path::new(source_path);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
Path::new(project).join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_source_path(project: &str, source_path: &str) -> PathBuf {
|
||||
let resolved = resolve_source_path(project, 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)
|
||||
}
|
||||
817
crates/disasmer-dap/src/tests.rs
Normal file
817
crates/disasmer-dap/src/tests.rs
Normal file
|
|
@ -0,0 +1,817 @@
|
|||
#![allow(clippy::field_reassign_with_default)]
|
||||
|
||||
use std::fs;
|
||||
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 runtime_backend_defaults_to_local_services_and_requires_explicit_demo() {
|
||||
assert_eq!(
|
||||
runtime_backend_from_launch_arg(None).unwrap(),
|
||||
RuntimeBackend::LocalServices
|
||||
);
|
||||
assert_eq!(
|
||||
runtime_backend_from_launch_arg(Some("simulated")).unwrap(),
|
||||
RuntimeBackend::Simulated
|
||||
);
|
||||
assert_eq!(
|
||||
runtime_backend_from_launch_arg(Some("demo")).unwrap(),
|
||||
RuntimeBackend::Simulated
|
||||
);
|
||||
assert!(runtime_backend_from_launch_arg(Some("unknown")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_dap_uses_matching_stored_cli_session_and_omits_claimed_scope() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("examples/demo");
|
||||
fs::create_dir_all(temp.path().join(".disasmer")).unwrap();
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
fs::write(
|
||||
temp.path().join(".disasmer/session.json"),
|
||||
serde_json::to_vec(&json!({
|
||||
"coordinator": "https://hosted.example.test",
|
||||
"tenant": "tenant-session",
|
||||
"project": "project-session",
|
||||
"user": "user-session",
|
||||
"session_secret": "dap-session-secret"
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut state = AdapterState::default();
|
||||
state
|
||||
.configure_launch(
|
||||
project.to_string_lossy().into_owned(),
|
||||
"build".to_owned(),
|
||||
RuntimeBackend::LiveServices,
|
||||
Some("https://hosted.example.test/".to_owned()),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.tenant.as_str(), "tenant-session");
|
||||
assert_eq!(state.project_id.as_str(), "project-session");
|
||||
assert_eq!(state.actor_user.as_str(), "user-session");
|
||||
assert_eq!(
|
||||
state.client_session_secret.as_deref(),
|
||||
Some("dap-session-secret")
|
||||
);
|
||||
|
||||
let request = client_user_request(
|
||||
&state,
|
||||
json!({
|
||||
"type": "debug_attach",
|
||||
"tenant": "forged-tenant",
|
||||
"project": "forged-project",
|
||||
"actor_user": "forged-user",
|
||||
"process": "vp"
|
||||
}),
|
||||
);
|
||||
assert_eq!(request["type"], "authenticated");
|
||||
assert_eq!(request["session_secret"], "dap-session-secret");
|
||||
assert_eq!(request["request"]["type"], "debug_attach");
|
||||
assert!(request["request"].get("tenant").is_none());
|
||||
assert!(request["request"].get("project").is_none());
|
||||
assert!(request["request"].get("actor_user").is_none());
|
||||
|
||||
let mismatch = state.configure_launch(
|
||||
project.to_string_lossy().into_owned(),
|
||||
"build".to_owned(),
|
||||
RuntimeBackend::LiveServices,
|
||||
Some("https://different.example.test".to_owned()),
|
||||
None,
|
||||
);
|
||||
assert!(mismatch
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("does not match the authenticated CLI session"));
|
||||
}
|
||||
|
||||
#[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 task_restart_response_preserves_coordinator_boundary_decision() {
|
||||
let record = parse_task_restart_response(json!({
|
||||
"type": "task_restart",
|
||||
"accepted": false,
|
||||
"clean_boundary_available": false,
|
||||
"requires_whole_process_restart": true,
|
||||
"active_task": false,
|
||||
"completed_event_observed": true,
|
||||
"message": "selected task has only terminal event metadata; restart requires a captured checkpoint boundary"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert!(!record.accepted);
|
||||
assert!(!record.clean_boundary_available);
|
||||
assert!(record.requires_whole_process_restart);
|
||||
assert!(!record.active_task);
|
||||
assert!(record.completed_event_observed);
|
||||
assert!(record.message.contains("checkpoint boundary"));
|
||||
}
|
||||
|
||||
#[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!({ "terminal_event": {
|
||||
"task": "compile-linux-1",
|
||||
"task_definition": "compile-linux"
|
||||
} }),
|
||||
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,
|
||||
debug_epoch: None,
|
||||
stopped_task: None,
|
||||
stopped_probe_symbol: None,
|
||||
all_participants_frozen: false,
|
||||
});
|
||||
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("task failed")));
|
||||
}
|
||||
|
||||
#[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, TaskInstanceId::from("package-release"));
|
||||
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 breakpoint_resolution_uses_bundle_debug_probe_metadata() {
|
||||
let mut state = AdapterState::default();
|
||||
state.debug_probes = vec![BundleDebugProbe {
|
||||
id: "probe-linux".to_owned(),
|
||||
source_path: "src/build.rs".to_owned(),
|
||||
line_start: 20,
|
||||
line_end: 30,
|
||||
function: "compile_linux".to_owned(),
|
||||
task: disasmer_core::TaskDefinitionId::from("compile-linux"),
|
||||
}];
|
||||
|
||||
let resolved = resolve_breakpoints(&mut state, vec![22, 80]);
|
||||
|
||||
assert_eq!(resolved.len(), 2);
|
||||
assert!(resolved[0].verified);
|
||||
assert!(resolved[0].message.contains("probe-linux"));
|
||||
assert!(!resolved[1].verified);
|
||||
assert!(resolved[1].message.contains("No Disasmer debug probe"));
|
||||
assert_eq!(state.breakpoints, vec![22]);
|
||||
assert_eq!(stopped_thread_for_breakpoint(&state), LINUX_THREAD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakpoint_updates_from_another_source_do_not_replace_configured_breakpoints() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let source_dir = project.path().join("src");
|
||||
fs::create_dir_all(&source_dir).unwrap();
|
||||
fs::write(source_dir.join("build.rs"), "pub fn build() {}\n").unwrap();
|
||||
fs::write(source_dir.join("main.rs"), "fn main() {}\n").unwrap();
|
||||
|
||||
let mut state = AdapterState::default();
|
||||
state.project = project.path().to_string_lossy().into_owned();
|
||||
state.source_path = "src/build.rs".to_owned();
|
||||
state.breakpoints = vec![1];
|
||||
|
||||
let requested_source = source_dir.join("main.rs");
|
||||
let resolved = resolve_breakpoints_for_source(&mut state, requested_source.to_str(), vec![1]);
|
||||
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert!(!resolved[0].verified);
|
||||
assert!(resolved[0]
|
||||
.message
|
||||
.contains("configured for `src/build.rs`"));
|
||||
assert_eq!(state.breakpoints, vec![1]);
|
||||
}
|
||||
|
||||
#[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, TaskInstanceId::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 vscode_and_dap_share_stable_workspace_process_identity() {
|
||||
assert_eq!(
|
||||
process_id("/workspace/app", "build"),
|
||||
disasmer_core::ProcessId::from("vp-e4bd6ef50539")
|
||||
);
|
||||
}
|
||||
|
||||
#[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!({ "terminal_event": {
|
||||
"task": "compile-linux-1",
|
||||
"task_definition": "compile-linux"
|
||||
} }),
|
||||
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,
|
||||
debug_epoch: None,
|
||||
stopped_task: None,
|
||||
stopped_probe_symbol: None,
|
||||
all_participants_frozen: false,
|
||||
});
|
||||
{
|
||||
let linux = state.threads.get_mut(&LINUX_THREAD).unwrap();
|
||||
linux.runtime_task_args = vec![("source".to_owned(), "source://checkout".to_owned())];
|
||||
linux.runtime_handles = vec![(
|
||||
"artifact".to_owned(),
|
||||
"artifact://runtime/output".to_owned(),
|
||||
)];
|
||||
linux.runtime_command_status = Some("frozen native command pid 42".to_owned());
|
||||
}
|
||||
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"] == "source"
|
||||
&& variable["value"] == "source://checkout"
|
||||
&& variable["type"] == "task-argument"
|
||||
}));
|
||||
assert!(args.iter().any(|variable| {
|
||||
variable["name"] == "artifact"
|
||||
&& variable["value"] == "artifact://runtime/output"
|
||||
&& variable["type"] == "runtime-handle"
|
||||
}));
|
||||
|
||||
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);
|
||||
let command = command["variables"].as_array().unwrap();
|
||||
assert!(command.iter().any(|variable| {
|
||||
variable["name"] == "status" && variable["value"] == "frozen native command pid 42"
|
||||
}));
|
||||
assert!(!command.iter().any(|variable| {
|
||||
variable["name"] == "program" || variable["name"] == "required_capability"
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_record_replaces_demo_threads_with_coordinator_task_events() {
|
||||
let mut state = AdapterState::default();
|
||||
state.runtime_backend = RuntimeBackend::LiveServices;
|
||||
state.apply_attach_record(RuntimeLaunchRecord {
|
||||
coordinator: "127.0.0.1:9443".to_owned(),
|
||||
node: "coordinator-debug-attach".to_owned(),
|
||||
node_report: json!({
|
||||
"debug_attach": {
|
||||
"authorization": {
|
||||
"allowed": true,
|
||||
"reason": "debug attach allowed"
|
||||
}
|
||||
}
|
||||
}),
|
||||
task_events: json!({
|
||||
"events": [{
|
||||
"tenant": "tenant",
|
||||
"project": "project",
|
||||
"process": state.process.to_string(),
|
||||
"node": "worker-linux",
|
||||
"executor": "node",
|
||||
"task_definition": "compile-linux",
|
||||
"task": "compile-linux-1",
|
||||
"terminal_state": "completed",
|
||||
"status_code": 0,
|
||||
"stdout_bytes": 4,
|
||||
"stderr_bytes": 0,
|
||||
"stdout_tail": "done",
|
||||
"stderr_tail": "",
|
||||
"stdout_truncated": false,
|
||||
"stderr_truncated": false,
|
||||
"artifact_path": "/vfs/artifacts/from-attach.txt",
|
||||
"artifact_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"artifact_size_bytes": 4
|
||||
}]
|
||||
}),
|
||||
placed_task_launched: true,
|
||||
status_code: Some(0),
|
||||
stdout_bytes: 4,
|
||||
stderr_bytes: 0,
|
||||
stdout_tail: "done".to_owned(),
|
||||
stderr_tail: String::new(),
|
||||
stdout_truncated: false,
|
||||
stderr_truncated: false,
|
||||
artifact_path: Some("/vfs/artifacts/from-attach.txt".to_owned()),
|
||||
event_count: 1,
|
||||
debug_epoch: None,
|
||||
stopped_task: None,
|
||||
stopped_probe_symbol: None,
|
||||
all_participants_frozen: false,
|
||||
});
|
||||
|
||||
assert_eq!(state.threads.len(), 2);
|
||||
assert!(state.threads.contains_key(&MAIN_THREAD));
|
||||
assert!(!state.threads.contains_key(&WINDOWS_THREAD));
|
||||
let linux = state
|
||||
.threads
|
||||
.get(&LINUX_THREAD)
|
||||
.expect("coordinator task event should become debugger thread");
|
||||
assert_eq!(linux.task, TaskInstanceId::from("compile-linux-1"));
|
||||
assert_eq!(
|
||||
linux.task_definition,
|
||||
disasmer_core::TaskDefinitionId::from("compile-linux")
|
||||
);
|
||||
assert_eq!(linux.stdout_tail, "done");
|
||||
assert_eq!(linux.state, DebugRuntimeState::Completed);
|
||||
assert_eq!(
|
||||
state.runtime_artifact_path.as_deref(),
|
||||
Some("/vfs/artifacts/from-attach.txt")
|
||||
);
|
||||
assert!(state
|
||||
.command_status
|
||||
.contains("attached to existing virtual process"));
|
||||
assert!(state.command_status.contains("coordinator task event"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_projects_active_coordinator_main_without_inventing_a_worker_task() {
|
||||
let mut state = AdapterState::default();
|
||||
state.runtime_backend = RuntimeBackend::LiveServices;
|
||||
let main_instance = format!("ti:{}:main", state.process);
|
||||
state.apply_attach_record(RuntimeLaunchRecord {
|
||||
coordinator: "127.0.0.1:9443".to_owned(),
|
||||
node: "coordinator-main".to_owned(),
|
||||
node_report: json!({
|
||||
"debug_attach": { "authorization": { "allowed": true } },
|
||||
"process_status": {
|
||||
"process": state.process.to_string(),
|
||||
"state": "running",
|
||||
"main_task_definition": "sha256:main-definition",
|
||||
"main_task_instance": main_instance,
|
||||
"main_state": "running",
|
||||
"main_debug_epoch": null,
|
||||
"connected_nodes": [],
|
||||
"coordinator_epoch": 1
|
||||
}
|
||||
}),
|
||||
task_events: json!({ "events": [] }),
|
||||
placed_task_launched: true,
|
||||
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,
|
||||
debug_epoch: None,
|
||||
stopped_task: None,
|
||||
stopped_probe_symbol: None,
|
||||
all_participants_frozen: false,
|
||||
});
|
||||
|
||||
assert_eq!(state.threads.len(), 1);
|
||||
let main = state.threads.get(&MAIN_THREAD).unwrap();
|
||||
assert_eq!(main.task, TaskInstanceId::from(main_instance.as_str()));
|
||||
assert_eq!(
|
||||
main.task_definition,
|
||||
disasmer_core::TaskDefinitionId::from("sha256:main-definition")
|
||||
);
|
||||
assert_eq!(main.state, DebugRuntimeState::Running);
|
||||
assert!(main.name.contains("coordinator main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinator_main_terminal_event_updates_main_thread_instead_of_adding_one() {
|
||||
let mut state = AdapterState::default();
|
||||
state.runtime_backend = RuntimeBackend::LiveServices;
|
||||
let main_instance = format!("ti:{}:main", state.process);
|
||||
state.apply_attach_record(RuntimeLaunchRecord {
|
||||
coordinator: "127.0.0.1:9443".to_owned(),
|
||||
node: "coordinator-main".to_owned(),
|
||||
node_report: json!({ "debug_attach": { "authorization": { "allowed": true } } }),
|
||||
task_events: json!({
|
||||
"events": [
|
||||
{
|
||||
"node": "worker-linux",
|
||||
"executor": "node",
|
||||
"task_definition": "compile_linux",
|
||||
"task": "ti:child:1",
|
||||
"terminal_state": "completed",
|
||||
"status_code": 0
|
||||
},
|
||||
{
|
||||
"node": "coordinator-main",
|
||||
"executor": "coordinator_main",
|
||||
"task_definition": "sha256:main-definition",
|
||||
"task": main_instance,
|
||||
"terminal_state": "completed",
|
||||
"status_code": 0
|
||||
}
|
||||
]
|
||||
}),
|
||||
placed_task_launched: true,
|
||||
status_code: Some(0),
|
||||
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: 2,
|
||||
debug_epoch: None,
|
||||
stopped_task: None,
|
||||
stopped_probe_symbol: None,
|
||||
all_participants_frozen: false,
|
||||
});
|
||||
|
||||
assert_eq!(state.threads.len(), 2);
|
||||
let main = state.threads.get(&MAIN_THREAD).unwrap();
|
||||
assert_eq!(main.task, TaskInstanceId::from(main_instance.as_str()));
|
||||
assert_eq!(main.state, DebugRuntimeState::Completed);
|
||||
assert!(state
|
||||
.threads
|
||||
.get(&LINUX_THREAD)
|
||||
.is_some_and(|thread| thread.task == TaskInstanceId::from("ti:child:1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_locals_infer_disasmer_api_values_from_runtime_state() {
|
||||
let mut state = AdapterState::default();
|
||||
let project = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../examples/launch-build-demo")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
state.project = project.to_string_lossy().into_owned();
|
||||
state.source_path = "src/build.rs".to_owned();
|
||||
let source = fs::read_to_string(project.join(&state.source_path)).unwrap();
|
||||
let inspection_line = source
|
||||
.lines()
|
||||
.position(|line| line.contains("let package_artifact = package.join()"))
|
||||
.map(|index| index as i64 + 1)
|
||||
.expect("flagship source must expose the post-join locals inspection point");
|
||||
state.threads.get_mut(&MAIN_THREAD).unwrap().line = inspection_line;
|
||||
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 source_locals_infer_task_with_arg_values_from_runtime_state() {
|
||||
let project =
|
||||
std::env::temp_dir().join(format!("disasmer-dap-task-with-arg-{}", std::process::id()));
|
||||
let src = project.join("src");
|
||||
fs::create_dir_all(&src).unwrap();
|
||||
fs::write(
|
||||
src.join("main.rs"),
|
||||
r#"use disasmer::{Artifact, EnvRef, SourceSnapshot};
|
||||
|
||||
async fn build_release() -> Result<(), disasmer::TaskArgError> {
|
||||
let source = prepare_source_snapshot();
|
||||
|
||||
let compile = disasmer::spawn::task_with_arg(source.clone(), compile_linux)
|
||||
.name("compile linux")
|
||||
.env(linux_command_env())
|
||||
.start()
|
||||
.await?;
|
||||
|
||||
let compile_thread = compile.virtual_thread_id();
|
||||
let linux_artifact = compile.join().await;
|
||||
|
||||
let package = disasmer::spawn::task_with_arg(vec![linux_artifact.clone()], package_release)
|
||||
.name("package release")
|
||||
.env(coordinator_env())
|
||||
.start()
|
||||
.await?;
|
||||
|
||||
let package_thread = package.virtual_thread_id();
|
||||
let release_artifact = package.join().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_source_snapshot() -> SourceSnapshot {
|
||||
SourceSnapshot {
|
||||
digest: "source://coordinator/quick-test-checkout".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn linux_command_env() -> EnvRef {
|
||||
disasmer::env!("linux-command")
|
||||
}
|
||||
|
||||
fn coordinator_env() -> EnvRef {
|
||||
disasmer::env!("coordinator")
|
||||
}
|
||||
|
||||
fn compile_linux(source: SourceSnapshot) -> Artifact {
|
||||
Artifact { id: source.digest }
|
||||
}
|
||||
|
||||
fn package_release(inputs: Vec<Artifact>) -> Artifact {
|
||||
inputs.into_iter().next().unwrap()
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut state = AdapterState::default();
|
||||
state.project = project.to_string_lossy().into_owned();
|
||||
state.source_path = "src/main.rs".to_owned();
|
||||
state.threads.get_mut(&MAIN_THREAD).unwrap().line = 23;
|
||||
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"] == "source"
|
||||
&& variable["value"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("source://coordinator/quick-test-checkout"))
|
||||
}));
|
||||
assert!(locals.iter().any(|variable| {
|
||||
variable["name"] == "compile"
|
||||
&& variable["value"].as_str().is_some_and(|value| {
|
||||
value.contains("TaskHandle")
|
||||
&& value.contains("compile-linux")
|
||||
&& value.contains("virtual_thread_id = 2")
|
||||
&& value.contains("linux-command")
|
||||
})
|
||||
}));
|
||||
assert!(locals
|
||||
.iter()
|
||||
.any(|variable| variable["name"] == "compile_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"] == "package"
|
||||
&& variable["value"].as_str().is_some_and(|value| {
|
||||
value.contains("TaskHandle")
|
||||
&& value.contains("package-release")
|
||||
&& value.contains("virtual_thread_id = 4")
|
||||
&& value.contains("coordinator")
|
||||
})
|
||||
}));
|
||||
assert!(locals
|
||||
.iter()
|
||||
.any(|variable| variable["name"] == "package_thread" && variable["value"] == "4"));
|
||||
assert!(locals.iter().any(|variable| {
|
||||
variable["name"] == "release_artifact"
|
||||
&& variable["value"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("Artifact"))
|
||||
}));
|
||||
|
||||
let _ = fs::remove_dir_all(project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_frame_locals_expose_only_values_from_the_node_snapshot() {
|
||||
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();
|
||||
let source = fs::read_to_string(Path::new(&state.project).join(&state.source_path)).unwrap();
|
||||
state.threads.get_mut(&MAIN_THREAD).unwrap().line = source
|
||||
.lines()
|
||||
.position(|line| line.contains("input + 1"))
|
||||
.map(|line| line as i64 + 1)
|
||||
.expect("task_add_one source line");
|
||||
state
|
||||
.threads
|
||||
.get_mut(&MAIN_THREAD)
|
||||
.unwrap()
|
||||
.wasm_local_values = vec![("wasm_local_0".to_owned(), "I32(41)".to_owned())];
|
||||
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"));
|
||||
}
|
||||
735
crates/disasmer-dap/src/variables.rs
Normal file
735
crates/disasmer-dap/src/variables.rs
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
|
||||
use disasmer_core::{Digest, TaskInstanceId};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::breakpoints::parse_rust_function_name;
|
||||
use crate::demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD};
|
||||
use crate::virtual_model::{AdapterState, VirtualThread};
|
||||
|
||||
pub(crate) 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 {
|
||||
let mut variables = thread
|
||||
.runtime_task_args
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"value": value,
|
||||
"type": "task-argument",
|
||||
"variablesReference": 0,
|
||||
})
|
||||
})
|
||||
.chain(thread.runtime_handles.iter().map(|(name, value)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"value": value,
|
||||
"type": "runtime-handle",
|
||||
"variablesReference": 0,
|
||||
})
|
||||
}))
|
||||
.collect::<Vec<_>>();
|
||||
if variables.is_empty() {
|
||||
variables.push(json!({
|
||||
"name": "runtime-boundary-diagnostic",
|
||||
"value": "the frozen runtime participant reported no task arguments or handles at this probe",
|
||||
"type": "diagnostic",
|
||||
"variablesReference": 0,
|
||||
}));
|
||||
}
|
||||
return json!({ "variables": variables });
|
||||
}
|
||||
|
||||
if thread.target_ref == reference {
|
||||
let runtime_backed =
|
||||
state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated;
|
||||
return json!({
|
||||
"variables": [
|
||||
{
|
||||
"name": "task",
|
||||
"value": thread.task.to_string(),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "environment",
|
||||
"value": if runtime_backed { "not reported by the frozen node participant" } else { task_environment(thread) },
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "kind",
|
||||
"value": if thread.id == MAIN_THREAD { "wasm entrypoint" } else { "wasm task" },
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "required_capability",
|
||||
"value": if runtime_backed { "not reported by the frozen node participant" } else if thread.id == MAIN_THREAD { "none" } else { "Command" },
|
||||
"variablesReference": 0
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if thread.vfs_ref == reference {
|
||||
if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated {
|
||||
return json!({
|
||||
"variables": [
|
||||
{
|
||||
"name": "runtime-vfs-diagnostic",
|
||||
"value": state.runtime_artifact_path.as_deref().map_or(
|
||||
"the frozen node participant did not report a VFS or artifact handle snapshot",
|
||||
|_| "the runtime reported a terminal artifact path; no broader VFS snapshot was reported"
|
||||
),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "reported_artifact_path",
|
||||
"value": state.runtime_artifact_path.as_deref().unwrap_or("unavailable"),
|
||||
"variablesReference": 0
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
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": thread.runtime_command_status.as_deref().unwrap_or(&state.command_status),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "command_spec",
|
||||
"value": command_spec_summary(state, thread),
|
||||
"variablesReference": if thread.runtime_command_status.is_some() { thread.command_ref } else { 0 }
|
||||
},
|
||||
{
|
||||
"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 {
|
||||
if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated {
|
||||
return json!({
|
||||
"variables": [
|
||||
{
|
||||
"name": "status",
|
||||
"value": thread.runtime_command_status.as_deref().unwrap_or("no native command state was reported by this participant"),
|
||||
"variablesReference": 0
|
||||
},
|
||||
{
|
||||
"name": "command-spec-diagnostic",
|
||||
"value": "the node debug snapshot does not yet report native command program, arguments, or capability requirements",
|
||||
"variablesReference": 0
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
fn wasm_frame_local_variables(_state: &AdapterState, thread: &VirtualThread) -> Vec<Value> {
|
||||
if thread.wasm_local_values.is_empty() {
|
||||
return vec![json!({
|
||||
"name": "wasm-local-diagnostic",
|
||||
"value": "the frozen node participant did not report inspectable Wasm frame locals at this safepoint",
|
||||
"type": "unavailable-local",
|
||||
"variablesReference": 0
|
||||
})];
|
||||
}
|
||||
thread
|
||||
.wasm_local_values
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"value": value,
|
||||
"type": "wasm-frame-local",
|
||||
"variablesReference": 0
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn source_locals_for_thread(
|
||||
state: &AdapterState,
|
||||
thread: &VirtualThread,
|
||||
) -> (Vec<SourceLocal>, String) {
|
||||
let source_path = crate::source::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 {
|
||||
SourceSnapshot(String),
|
||||
TaskHandle {
|
||||
task: TaskInstanceId,
|
||||
thread_id: i64,
|
||||
env: String,
|
||||
state: String,
|
||||
},
|
||||
ThreadId(i64),
|
||||
Artifact(String),
|
||||
}
|
||||
|
||||
impl SourceLocalRuntimeValue {
|
||||
fn display(&self, _state: &AdapterState) -> String {
|
||||
match self {
|
||||
SourceLocalRuntimeValue::SourceSnapshot(digest) => {
|
||||
format!("SourceSnapshot {{ digest = \"{digest}\" }}")
|
||||
}
|
||||
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 statement.contains("prepare_source_snapshot()") {
|
||||
return Some(SourceLocalRuntimeValue::SourceSnapshot(
|
||||
source_snapshot_digest_from_project(state).unwrap_or_else(|| {
|
||||
format!(
|
||||
"source://local-checkout/{}",
|
||||
project_snapshot_suffix(&state.project)
|
||||
)
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(task_function) = extract_spawn_task_function(statement) {
|
||||
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_spawn_task_function(statement: &str) -> Option<String> {
|
||||
for marker in ["disasmer::spawn::async_task(", "disasmer::spawn::task("] {
|
||||
if let Some(args) = extract_call_arguments(statement, marker) {
|
||||
return args.first().cloned();
|
||||
}
|
||||
}
|
||||
for marker in [
|
||||
"disasmer::spawn::async_task_with_arg(",
|
||||
"disasmer::spawn::task_with_arg(",
|
||||
] {
|
||||
if let Some(args) = extract_call_arguments(statement, marker) {
|
||||
return args.get(1).cloned();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn source_snapshot_digest_from_project(state: &AdapterState) -> Option<String> {
|
||||
let source = fs::read_to_string(crate::source::resolve_source_path(
|
||||
&state.project,
|
||||
&state.source_path,
|
||||
))
|
||||
.ok()?;
|
||||
let digest_marker = source.find("digest:")?;
|
||||
let after_digest = &source[digest_marker..];
|
||||
let first_quote = after_digest.find('"')? + 1;
|
||||
let rest = &after_digest[first_quote..];
|
||||
let end_quote = rest.find('"')?;
|
||||
Some(rest[..end_quote].to_owned())
|
||||
}
|
||||
|
||||
fn extract_call_arguments(statement: &str, marker: &str) -> Option<Vec<String>> {
|
||||
let start = statement.find(marker)? + marker.len();
|
||||
let rest = &statement[start..];
|
||||
let mut args = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut depth = 0_i32;
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
for ch in rest.chars() {
|
||||
if in_string {
|
||||
current.push(ch);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'"' => {
|
||||
in_string = true;
|
||||
current.push(ch);
|
||||
}
|
||||
'(' | '[' | '{' => {
|
||||
depth += 1;
|
||||
current.push(ch);
|
||||
}
|
||||
')' => {
|
||||
if depth == 0 {
|
||||
let value = current.trim();
|
||||
if !value.is_empty() {
|
||||
args.push(value.to_owned());
|
||||
}
|
||||
return (!args.is_empty()).then_some(args);
|
||||
}
|
||||
depth -= 1;
|
||||
current.push(ch);
|
||||
}
|
||||
']' | '}' => {
|
||||
depth -= 1;
|
||||
current.push(ch);
|
||||
}
|
||||
',' if depth == 0 => {
|
||||
let value = current.trim();
|
||||
if !value.is_empty() {
|
||||
args.push(value.to_owned());
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
_ => current.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
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_command_env") || statement.contains("env!(\"linux-command\")") {
|
||||
return Some("linux-command".to_owned());
|
||||
}
|
||||
if statement.contains("linux_env") || statement.contains("env!(\"linux\")") {
|
||||
return Some("linux".to_owned());
|
||||
}
|
||||
if statement.contains("coordinator_env") || statement.contains("env!(\"coordinator\")") {
|
||||
return Some("coordinator".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
|
||||
|| (thread.id == PACKAGE_THREAD && normalized == "package-release")
|
||||
})
|
||||
}
|
||||
|
||||
fn task_environment(thread: &VirtualThread) -> &'static str {
|
||||
match thread.id {
|
||||
WINDOWS_THREAD => "windows",
|
||||
MAIN_THREAD => "coordinator",
|
||||
PACKAGE_THREAD => "coordinator",
|
||||
LINUX_THREAD => "linux",
|
||||
_ => "unconstrained",
|
||||
}
|
||||
}
|
||||
|
||||
fn artifact_handle_value(state: &AdapterState, thread: &VirtualThread) -> String {
|
||||
if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated {
|
||||
return state.runtime_artifact_path.clone().unwrap_or_else(|| {
|
||||
"unavailable: runtime participant did not report an artifact handle".to_owned()
|
||||
});
|
||||
}
|
||||
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 {
|
||||
if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated {
|
||||
return thread
|
||||
.runtime_command_status
|
||||
.clone()
|
||||
.unwrap_or_else(|| "no native command state reported".to_owned());
|
||||
}
|
||||
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 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()
|
||||
}
|
||||
89
crates/disasmer-dap/src/view_state.rs
Normal file
89
crates/disasmer-dap/src/view_state.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::virtual_model::{AdapterState, RuntimeLaunchRecord};
|
||||
|
||||
pub(crate) fn normalize_coordinator_endpoint(endpoint: &str) -> String {
|
||||
endpoint.trim().trim_end_matches('/').to_owned()
|
||||
}
|
||||
|
||||
pub(crate) 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(())
|
||||
}
|
||||
775
crates/disasmer-dap/src/virtual_model.rs
Normal file
775
crates/disasmer-dap/src/virtual_model.rs
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use disasmer_core::{
|
||||
BundleDebugProbe, DebugRuntimeState, Digest, ProcessId, ProjectId, TaskDefinitionId,
|
||||
TaskInstanceId, TenantId, UserId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::demo_backend::{launch_threads, LINUX_THREAD, MAIN_THREAD};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct VirtualThread {
|
||||
pub(crate) id: i64,
|
||||
pub(crate) frame_id: i64,
|
||||
pub(crate) locals_ref: i64,
|
||||
pub(crate) wasm_locals_ref: i64,
|
||||
pub(crate) args_ref: i64,
|
||||
pub(crate) runtime_ref: i64,
|
||||
pub(crate) output_ref: i64,
|
||||
pub(crate) target_ref: i64,
|
||||
pub(crate) vfs_ref: i64,
|
||||
pub(crate) command_ref: i64,
|
||||
pub(crate) task: TaskInstanceId,
|
||||
pub(crate) task_definition: TaskDefinitionId,
|
||||
pub(crate) name: String,
|
||||
pub(crate) line: i64,
|
||||
pub(crate) state: DebugRuntimeState,
|
||||
pub(crate) freeze_supported: bool,
|
||||
pub(crate) recent_output: Vec<String>,
|
||||
pub(crate) stdout_bytes: u64,
|
||||
pub(crate) stderr_bytes: u64,
|
||||
pub(crate) stdout_tail: String,
|
||||
pub(crate) stderr_tail: String,
|
||||
pub(crate) stdout_truncated: bool,
|
||||
pub(crate) stderr_truncated: bool,
|
||||
pub(crate) runtime_stack_frames: Vec<String>,
|
||||
pub(crate) wasm_local_values: Vec<(String, String)>,
|
||||
pub(crate) runtime_task_args: Vec<(String, String)>,
|
||||
pub(crate) runtime_handles: Vec<(String, String)>,
|
||||
pub(crate) runtime_command_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AdapterState {
|
||||
pub(crate) process: ProcessId,
|
||||
pub(crate) epoch: u64,
|
||||
pub(crate) entry: String,
|
||||
pub(crate) project: String,
|
||||
pub(crate) source_path: String,
|
||||
pub(crate) runtime_backend: RuntimeBackend,
|
||||
pub(crate) coordinator_endpoint: String,
|
||||
pub(crate) tenant: TenantId,
|
||||
pub(crate) project_id: ProjectId,
|
||||
pub(crate) actor_user: UserId,
|
||||
pub(crate) client_session_secret: Option<String>,
|
||||
pub(crate) session_mode: DapSessionMode,
|
||||
pub(crate) restart_existing: bool,
|
||||
pub(crate) command_status: String,
|
||||
pub(crate) last_task_failed: bool,
|
||||
pub(crate) runtime_artifact_path: Option<String>,
|
||||
pub(crate) runtime_event_count: usize,
|
||||
pub(crate) coordinator_debug_epoch: Option<u64>,
|
||||
pub(crate) stopped_task: Option<TaskInstanceId>,
|
||||
pub(crate) debug_probes: Vec<BundleDebugProbe>,
|
||||
pub(crate) breakpoints: Vec<i64>,
|
||||
pub(crate) threads: BTreeMap<i64, VirtualThread>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum RuntimeBackend {
|
||||
Simulated,
|
||||
LocalServices,
|
||||
LiveServices,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum DapSessionMode {
|
||||
Launch,
|
||||
Attach,
|
||||
}
|
||||
|
||||
impl RuntimeBackend {
|
||||
pub(crate) 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,
|
||||
coordinator_endpoint: "https://disasmer.michelpaulissen.com".to_owned(),
|
||||
tenant: TenantId::from("tenant"),
|
||||
project_id: ProjectId::from("project"),
|
||||
actor_user: UserId::from("dap"),
|
||||
client_session_secret: None,
|
||||
session_mode: DapSessionMode::Launch,
|
||||
restart_existing: false,
|
||||
command_status: "waiting for launch".to_owned(),
|
||||
last_task_failed: false,
|
||||
runtime_artifact_path: None,
|
||||
runtime_event_count: 0,
|
||||
coordinator_debug_epoch: None,
|
||||
stopped_task: None,
|
||||
debug_probes: Vec::new(),
|
||||
breakpoints: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdapterState {
|
||||
pub(crate) fn requested_probe_symbols(&self) -> Vec<String> {
|
||||
let mut symbols = self
|
||||
.breakpoints
|
||||
.iter()
|
||||
.filter_map(|line| {
|
||||
self.debug_probes.iter().find(|probe| {
|
||||
probe.source_path == self.source_path
|
||||
&& *line >= i64::from(probe.line_start)
|
||||
&& *line <= i64::from(probe.line_end)
|
||||
})
|
||||
})
|
||||
.map(|probe| format!("disasmer.probe.{}", probe.function))
|
||||
.collect::<Vec<_>>();
|
||||
symbols.sort();
|
||||
symbols.dedup();
|
||||
symbols
|
||||
}
|
||||
|
||||
pub(crate) fn configure_launch(
|
||||
&mut self,
|
||||
project: String,
|
||||
entry: String,
|
||||
runtime_backend: RuntimeBackend,
|
||||
coordinator_endpoint: Option<String>,
|
||||
source_path: Option<String>,
|
||||
) -> Result<()> {
|
||||
self.process = process_id(&project, &entry);
|
||||
self.entry = entry;
|
||||
self.source_path =
|
||||
source_path.unwrap_or_else(|| crate::source::infer_source_path(&project));
|
||||
self.project = project;
|
||||
self.runtime_backend = runtime_backend;
|
||||
let project_scope = load_project_scope(&self.project);
|
||||
if self.runtime_backend == RuntimeBackend::LiveServices {
|
||||
let session = load_client_session(&self.project).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"no authenticated CLI session was found for this workspace; run `disasmer login --browser` from {}",
|
||||
self.project
|
||||
)
|
||||
})?;
|
||||
if let Some(configured) = project_scope.as_ref() {
|
||||
let mismatches = scope_mismatches(configured, &session);
|
||||
if !mismatches.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"the stored CLI session does not match this workspace ({mismatches}); run `disasmer login --browser` from {}",
|
||||
self.project
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(requested) = coordinator_endpoint.as_deref() {
|
||||
if crate::view_state::normalize_coordinator_endpoint(requested)
|
||||
!= crate::view_state::normalize_coordinator_endpoint(&session.coordinator)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"the debug coordinator does not match the authenticated CLI session coordinator"
|
||||
));
|
||||
}
|
||||
}
|
||||
self.coordinator_endpoint =
|
||||
crate::view_state::normalize_coordinator_endpoint(&session.coordinator);
|
||||
self.tenant = TenantId::new(session.tenant);
|
||||
self.project_id = ProjectId::new(session.project);
|
||||
self.actor_user = UserId::new(session.user);
|
||||
self.client_session_secret = Some(session.session_secret);
|
||||
} else {
|
||||
self.coordinator_endpoint = crate::view_state::normalize_coordinator_endpoint(
|
||||
coordinator_endpoint.as_deref().unwrap_or("127.0.0.1:0"),
|
||||
);
|
||||
if let Some(scope) = project_scope {
|
||||
self.tenant = TenantId::new(scope.tenant);
|
||||
self.project_id = ProjectId::new(scope.project);
|
||||
self.actor_user = UserId::new(scope.user);
|
||||
}
|
||||
self.client_session_secret = None;
|
||||
}
|
||||
self.session_mode = DapSessionMode::Launch;
|
||||
self.restart_existing = false;
|
||||
self.command_status = "waiting for configurationDone".to_owned();
|
||||
self.last_task_failed = false;
|
||||
self.runtime_artifact_path = None;
|
||||
self.runtime_event_count = 0;
|
||||
self.coordinator_debug_epoch = None;
|
||||
self.stopped_task = None;
|
||||
self.debug_probes =
|
||||
crate::breakpoints::load_bundle_debug_probes(&self.project, &self.source_path);
|
||||
self.epoch = 0;
|
||||
self.breakpoints.clear();
|
||||
self.threads = launch_threads(&self.entry);
|
||||
if self.runtime_backend != RuntimeBackend::Simulated {
|
||||
self.threads.retain(|id, _| *id == MAIN_THREAD);
|
||||
if let Some(main) = self.threads.get_mut(&MAIN_THREAD) {
|
||||
main.task = TaskInstanceId::new(self.entry.clone());
|
||||
main.name = format!("{} virtual process", self.entry);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply_runtime_record(&mut self, record: RuntimeLaunchRecord) {
|
||||
self.coordinator_endpoint = record.coordinator.clone();
|
||||
if !record.placed_task_launched {
|
||||
self.command_status = format!(
|
||||
"virtual process started through {}; no runtime task event observed 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("an attached worker will be required when the process reaches work that must be placed on a node".to_owned());
|
||||
}
|
||||
let _ = crate::view_state::write_view_state(self, &record);
|
||||
return;
|
||||
}
|
||||
|
||||
let task_failed = record.status_code.is_some_and(|status| status != 0);
|
||||
self.command_status = if record.all_participants_frozen {
|
||||
format!(
|
||||
"frozen through {} at executing Wasm probe {} with debug epoch {}",
|
||||
self.runtime_backend.description(),
|
||||
record.stopped_probe_symbol.as_deref().unwrap_or("unknown"),
|
||||
record.debug_epoch.unwrap_or_default()
|
||||
)
|
||||
} else if let Some(status) = record.status_code {
|
||||
format!(
|
||||
"{} through {} with status {status}",
|
||||
if task_failed { "failed" } else { "completed" },
|
||||
self.runtime_backend.description()
|
||||
)
|
||||
} else {
|
||||
format!("running through {}", self.runtime_backend.description())
|
||||
};
|
||||
self.last_task_failed = task_failed;
|
||||
self.runtime_artifact_path = record.artifact_path.clone();
|
||||
self.runtime_event_count = record.event_count;
|
||||
if record.all_participants_frozen {
|
||||
self.epoch = record.debug_epoch.unwrap_or(self.epoch);
|
||||
self.coordinator_debug_epoch = record.debug_epoch;
|
||||
self.stopped_task = record.stopped_task.as_deref().map(TaskInstanceId::new);
|
||||
if let Some(acknowledgements) = record
|
||||
.node_report
|
||||
.pointer("/debug_epoch/acknowledgements")
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
let entry = self.entry.clone();
|
||||
for acknowledgement in acknowledgements {
|
||||
let Some(task) = acknowledgement.get("task").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(task_definition) = acknowledgement
|
||||
.get("task_definition")
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let normalized = task.replace('_', "-");
|
||||
let coordinator_main = acknowledgement.get("node").and_then(Value::as_str)
|
||||
== Some("coordinator-main");
|
||||
let thread_id = self
|
||||
.threads
|
||||
.values()
|
||||
.find(|thread| {
|
||||
thread.task.as_str() == task
|
||||
|| thread.task.as_str() == normalized
|
||||
|| thread.task_definition.as_str() == task_definition
|
||||
|| (thread.id == MAIN_THREAD
|
||||
&& (coordinator_main
|
||||
|| task_definition == entry
|
||||
|| task_definition == "main"
|
||||
|| task_definition == "build"))
|
||||
})
|
||||
.map(|thread| thread.id)
|
||||
.unwrap_or_else(|| self.insert_runtime_thread(task, task_definition));
|
||||
let thread = self
|
||||
.threads
|
||||
.get_mut(&thread_id)
|
||||
.expect("runtime thread was found or inserted");
|
||||
thread.task = TaskInstanceId::new(task);
|
||||
thread.task_definition = TaskDefinitionId::new(task_definition);
|
||||
thread.runtime_stack_frames = acknowledgement
|
||||
.get("stack_frames")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
thread.wasm_local_values =
|
||||
value_string_pairs(acknowledgement.get("local_values"));
|
||||
thread.runtime_task_args = value_string_pairs(acknowledgement.get("task_args"));
|
||||
thread.runtime_handles = value_string_pairs(acknowledgement.get("handles"));
|
||||
thread.runtime_command_status = acknowledgement
|
||||
.get("command_status")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
}
|
||||
}
|
||||
for thread in self.threads.values_mut() {
|
||||
if thread.state == DebugRuntimeState::Running {
|
||||
thread.state = DebugRuntimeState::Frozen;
|
||||
}
|
||||
}
|
||||
if let Some(thread) = self.threads.get_mut(&MAIN_THREAD) {
|
||||
thread.recent_output.push(format!(
|
||||
"executing task {} reported the breakpoint hit",
|
||||
record.stopped_task.as_deref().unwrap_or("unknown")
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(terminal_event) = record.node_report.get("terminal_event") {
|
||||
let terminal_task = terminal_event
|
||||
.get("task")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(self.entry.as_str())
|
||||
.to_owned();
|
||||
let terminal_task_definition = terminal_event
|
||||
.get("task_definition")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(self.entry.as_str())
|
||||
.to_owned();
|
||||
let terminal_thread_id = self
|
||||
.threads
|
||||
.values()
|
||||
.find(|thread| {
|
||||
thread.task.as_str() == terminal_task.as_str()
|
||||
|| thread.task_definition.as_str() == terminal_task_definition.as_str()
|
||||
})
|
||||
.map(|thread| thread.id)
|
||||
.unwrap_or_else(|| {
|
||||
self.insert_runtime_thread(&terminal_task, &terminal_task_definition)
|
||||
});
|
||||
if let Some(thread) = self.threads.get_mut(&terminal_thread_id) {
|
||||
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!(
|
||||
"{} task {} with {} stdout bytes and {} stderr bytes",
|
||||
terminal_event
|
||||
.get("executor")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("runtime"),
|
||||
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
|
||||
));
|
||||
thread.state = if task_failed {
|
||||
DebugRuntimeState::Failed(format!(
|
||||
"local services task exited with status {:?}",
|
||||
record.status_code
|
||||
))
|
||||
} else {
|
||||
DebugRuntimeState::Completed
|
||||
};
|
||||
}
|
||||
}
|
||||
let _ = crate::view_state::write_view_state(self, &record);
|
||||
}
|
||||
|
||||
fn insert_runtime_thread(&mut self, task: &str, task_definition: &str) -> i64 {
|
||||
let id = self
|
||||
.threads
|
||||
.keys()
|
||||
.next_back()
|
||||
.copied()
|
||||
.unwrap_or(MAIN_THREAD)
|
||||
+ 1;
|
||||
let line = self
|
||||
.debug_probes
|
||||
.iter()
|
||||
.find(|probe| {
|
||||
probe.task.as_str() == task_definition || probe.function == task_definition
|
||||
})
|
||||
.map(|probe| i64::from(probe.line_start))
|
||||
.unwrap_or(1);
|
||||
let definition_name = task_definition.replace(['_', '-'], " ");
|
||||
let name = if task == task_definition {
|
||||
definition_name
|
||||
} else {
|
||||
format!("{definition_name} ({task})")
|
||||
};
|
||||
self.threads.insert(
|
||||
id,
|
||||
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: TaskInstanceId::new(task),
|
||||
task_definition: TaskDefinitionId::new(task_definition),
|
||||
name,
|
||||
line,
|
||||
state: DebugRuntimeState::Running,
|
||||
freeze_supported: true,
|
||||
recent_output: vec!["runtime participant reported by the node".to_owned()],
|
||||
stdout_bytes: 0,
|
||||
stderr_bytes: 0,
|
||||
stdout_tail: String::new(),
|
||||
stderr_tail: String::new(),
|
||||
stdout_truncated: false,
|
||||
stderr_truncated: false,
|
||||
runtime_stack_frames: Vec::new(),
|
||||
wasm_local_values: Vec::new(),
|
||||
runtime_task_args: Vec::new(),
|
||||
runtime_handles: Vec::new(),
|
||||
runtime_command_status: None,
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
pub(crate) fn apply_attach_record(&mut self, record: RuntimeLaunchRecord) {
|
||||
self.coordinator_endpoint = record.coordinator.clone();
|
||||
self.command_status = format!(
|
||||
"attached to existing virtual process through {}; coordinator task event(s): {}",
|
||||
self.runtime_backend.description(),
|
||||
record.event_count
|
||||
);
|
||||
self.last_task_failed = false;
|
||||
self.runtime_artifact_path = first_event_string(&record.task_events, "artifact_path");
|
||||
self.runtime_event_count = record.event_count;
|
||||
self.threads = coordinator_threads_from_events(
|
||||
&self.entry,
|
||||
&record.task_events,
|
||||
record.node_report.get("process_status"),
|
||||
);
|
||||
if let Some(thread) = self.threads.get_mut(&MAIN_THREAD) {
|
||||
thread.recent_output.push(format!(
|
||||
"attached to existing virtual process through {}",
|
||||
self.runtime_backend.description()
|
||||
));
|
||||
thread.recent_output.push(format!(
|
||||
"coordinator returned {} task event(s) for this process",
|
||||
record.event_count
|
||||
));
|
||||
}
|
||||
let _ = crate::view_state::write_view_state(self, &record);
|
||||
}
|
||||
}
|
||||
|
||||
fn value_string_pairs(value: Option<&Value>) -> Vec<(String, String)> {
|
||||
value
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|pair| {
|
||||
let pair = pair.as_array()?;
|
||||
Some((
|
||||
pair.first()?.as_str()?.to_owned(),
|
||||
pair.get(1)?.as_str()?.to_owned(),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ProjectScope {
|
||||
coordinator: Option<String>,
|
||||
tenant: String,
|
||||
project: String,
|
||||
user: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ClientSession {
|
||||
coordinator: String,
|
||||
tenant: String,
|
||||
project: String,
|
||||
user: String,
|
||||
session_secret: String,
|
||||
}
|
||||
|
||||
fn load_project_scope(project: &str) -> Option<ProjectScope> {
|
||||
for ancestor in Path::new(project).ancestors() {
|
||||
let file = ancestor.join(".disasmer/project.json");
|
||||
let Ok(bytes) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(config) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
return Some(ProjectScope {
|
||||
coordinator: config
|
||||
.get("coordinator")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
tenant: config.get("tenant")?.as_str()?.to_owned(),
|
||||
project: config.get("project")?.as_str()?.to_owned(),
|
||||
user: config.get("user")?.as_str()?.to_owned(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn load_client_session(project: &str) -> Option<ClientSession> {
|
||||
for ancestor in Path::new(project).ancestors() {
|
||||
let file = ancestor.join(".disasmer/session.json");
|
||||
let Ok(bytes) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(session) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let secret = session
|
||||
.get("session_secret")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|secret| !secret.trim().is_empty())?;
|
||||
return Some(ClientSession {
|
||||
coordinator: session.get("coordinator")?.as_str()?.to_owned(),
|
||||
tenant: session.get("tenant")?.as_str()?.to_owned(),
|
||||
project: session.get("project")?.as_str()?.to_owned(),
|
||||
user: session.get("user")?.as_str()?.to_owned(),
|
||||
session_secret: secret.to_owned(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn scope_mismatches(project: &ProjectScope, session: &ClientSession) -> String {
|
||||
let mut fields = Vec::new();
|
||||
if project.coordinator.as_deref().is_some_and(|coordinator| {
|
||||
crate::view_state::normalize_coordinator_endpoint(coordinator)
|
||||
!= crate::view_state::normalize_coordinator_endpoint(&session.coordinator)
|
||||
}) {
|
||||
fields.push("coordinator");
|
||||
}
|
||||
if project.tenant != session.tenant {
|
||||
fields.push("tenant");
|
||||
}
|
||||
if project.project != session.project {
|
||||
fields.push("project");
|
||||
}
|
||||
if project.user != session.user {
|
||||
fields.push("user");
|
||||
}
|
||||
fields.join(", ")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RuntimeLaunchRecord {
|
||||
pub(crate) coordinator: String,
|
||||
pub(crate) node: String,
|
||||
pub(crate) node_report: Value,
|
||||
pub(crate) task_events: Value,
|
||||
pub(crate) placed_task_launched: bool,
|
||||
pub(crate) status_code: Option<i32>,
|
||||
pub(crate) stdout_bytes: u64,
|
||||
pub(crate) stderr_bytes: u64,
|
||||
pub(crate) stdout_tail: String,
|
||||
pub(crate) stderr_tail: String,
|
||||
pub(crate) stdout_truncated: bool,
|
||||
pub(crate) stderr_truncated: bool,
|
||||
pub(crate) artifact_path: Option<String>,
|
||||
pub(crate) event_count: usize,
|
||||
pub(crate) debug_epoch: Option<u64>,
|
||||
pub(crate) stopped_task: Option<String>,
|
||||
pub(crate) stopped_probe_symbol: Option<String>,
|
||||
pub(crate) all_participants_frozen: bool,
|
||||
}
|
||||
|
||||
pub(crate) 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 coordinator_threads_from_events(
|
||||
entry: &str,
|
||||
task_events: &Value,
|
||||
process_status: Option<&Value>,
|
||||
) -> BTreeMap<i64, VirtualThread> {
|
||||
let mut threads = BTreeMap::new();
|
||||
let mut main = launch_threads(entry)
|
||||
.remove(&MAIN_THREAD)
|
||||
.expect("launch thread model should include main");
|
||||
if let Some(status) = process_status {
|
||||
if let Some(task) = status.get("main_task_instance").and_then(Value::as_str) {
|
||||
main.task = TaskInstanceId::from(task);
|
||||
}
|
||||
if let Some(definition) = status.get("main_task_definition").and_then(Value::as_str) {
|
||||
main.task_definition = TaskDefinitionId::from(definition);
|
||||
}
|
||||
main.name = format!("{entry} coordinator main ({})", main.task);
|
||||
main.state = if status
|
||||
.get("main_debug_epoch")
|
||||
.and_then(Value::as_u64)
|
||||
.is_some()
|
||||
{
|
||||
DebugRuntimeState::Frozen
|
||||
} else {
|
||||
DebugRuntimeState::Running
|
||||
};
|
||||
main.recent_output = vec![format!(
|
||||
"coordinator reports main state {}",
|
||||
status
|
||||
.get("main_state")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("running")
|
||||
)];
|
||||
}
|
||||
threads.insert(MAIN_THREAD, main);
|
||||
|
||||
let Some(events) = task_events.get("events").and_then(Value::as_array) else {
|
||||
return threads;
|
||||
};
|
||||
let mut next_worker_thread = LINUX_THREAD;
|
||||
for (index, event) in events.iter().enumerate() {
|
||||
let coordinator_main =
|
||||
event.get("executor").and_then(Value::as_str) == Some("coordinator_main");
|
||||
let id = if coordinator_main {
|
||||
MAIN_THREAD
|
||||
} else {
|
||||
let id = next_worker_thread;
|
||||
next_worker_thread += 1;
|
||||
id
|
||||
};
|
||||
let Some(mut thread) = coordinator_event_thread(id, index, event) else {
|
||||
continue;
|
||||
};
|
||||
if coordinator_main {
|
||||
thread.name = format!("{entry} coordinator main ({})", thread.task);
|
||||
thread.recent_output.push(
|
||||
"terminal state was recorded by the coordinator-hosted main runtime".to_owned(),
|
||||
);
|
||||
}
|
||||
threads.insert(id, thread);
|
||||
}
|
||||
threads
|
||||
}
|
||||
|
||||
fn coordinator_event_thread(id: i64, event_index: usize, event: &Value) -> Option<VirtualThread> {
|
||||
let task = event.get("task").and_then(Value::as_str)?;
|
||||
let task_definition = event.get("task_definition").and_then(Value::as_str)?;
|
||||
let definition_name = task_definition.replace(['_', '-'], " ");
|
||||
let name = if task == task_definition {
|
||||
definition_name
|
||||
} else {
|
||||
format!("{definition_name} ({task})")
|
||||
};
|
||||
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_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 status_code = event.get("status_code").and_then(Value::as_i64);
|
||||
let state = match event.get("terminal_state").and_then(Value::as_str) {
|
||||
Some("completed") => DebugRuntimeState::Completed,
|
||||
Some("cancelled") => DebugRuntimeState::Failed(format!("task {task} was cancelled")),
|
||||
Some("failed") => DebugRuntimeState::Failed(format!("task {task} failed")),
|
||||
_ if status_code == Some(0) => DebugRuntimeState::Completed,
|
||||
_ => {
|
||||
DebugRuntimeState::Failed(format!("task {task} completed with status {status_code:?}"))
|
||||
}
|
||||
};
|
||||
Some(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: TaskInstanceId::from(task),
|
||||
task_definition: TaskDefinitionId::from(task_definition),
|
||||
name,
|
||||
line: 42 + event_index as i64,
|
||||
state,
|
||||
freeze_supported: true,
|
||||
recent_output: vec![format!(
|
||||
"coordinator task event from {} {}",
|
||||
event
|
||||
.get("executor")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("node"),
|
||||
event
|
||||
.get("node")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
)],
|
||||
stdout_bytes,
|
||||
stderr_bytes,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
stdout_truncated: event
|
||||
.get("stdout_truncated")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
stderr_truncated: event
|
||||
.get("stderr_truncated")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
runtime_stack_frames: Vec::new(),
|
||||
wasm_local_values: Vec::new(),
|
||||
runtime_task_args: Vec::new(),
|
||||
runtime_handles: Vec::new(),
|
||||
runtime_command_status: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn first_event_string(task_events: &Value, field: &str) -> Option<String> {
|
||||
task_events
|
||||
.get("events")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|events| events.iter().find_map(|event| event.get(field)))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue