Public dry run dryrun-20b59dc46b72

Source commit: 20b59dc46b72103c2f8a516c692b5cc3d54fab19

Public tree identity: sha256:aaa5ac7b58b2a0d23c6b11e5f76324bf3839ca1f62653a83deb736932adcfbd9
This commit is contained in:
Disasmer release dry run 2026-07-14 10:08:15 +02:00
commit 2bef715211
221 changed files with 79792 additions and 0 deletions

View 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

View 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"
)),
}
}

View 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));
}
}

View 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)?))
}

View 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,
}
}

View 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;

View 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)
}

View 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(),
})
}

View 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)
}

View 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)
}

View 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)
}

View 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"));
}

View 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()
}

View 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(())
}

View 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)
}