Public release release-e47f9c27bbeb

Source commit: e47f9c27bbebe6759f6b6fe7b12fd00bb20d116d

Public tree identity: sha256:4c1af1dfd67d3f2b5088531ea8727b11124b013d2251a4d5088f51a6424c0196
This commit is contained in:
Clusterflux release 2026-07-24 15:52:30 +02:00
parent 3a4d4fa7ae
commit 9223c54939
30 changed files with 4195 additions and 434 deletions

View file

@ -61,12 +61,20 @@ impl ControlSession {
endpoint: &str,
api_path: &str,
) -> Result<Self, ControlTransportError> {
let mut session = Self::connect(endpoint)?;
let session = Self::connect(endpoint)?;
#[cfg(not(target_arch = "wasm32"))]
if let ControlTransport::Https { url, .. } = &mut session.transport {
*url = endpoint_api_url(endpoint, api_path)?;
{
let mut session = session;
if let ControlTransport::Https { url, .. } = &mut session.transport {
*url = endpoint_api_url(endpoint, api_path)?;
}
Ok(session)
}
#[cfg(target_arch = "wasm32")]
{
let _ = api_path;
Ok(session)
}
Ok(session)
}
pub fn connect_with_timeouts(

View file

@ -35,6 +35,7 @@ pub(super) struct DebugEpochRuntime {
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct DebugBreakpointPlan {
pub(super) actor: UserId,
pub(super) revision: u64,
pub(super) probe_symbols: BTreeSet<String>,
pub(super) hit_epoch: Option<u64>,
pub(super) hit_task: Option<TaskInstanceId>,
@ -85,6 +86,7 @@ impl CoordinatorService {
project: String,
actor_user: String,
process: String,
revision: u64,
probe_symbols: Vec<String>,
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let probe_symbols = validate_probe_symbols(probe_symbols)?;
@ -115,10 +117,28 @@ impl CoordinatorService {
))
.into());
}
let key = process_control_key(&tenant, &project, &process);
if let Some(current) = self.debug_breakpoints.get(&key) {
if current.actor == actor && revision < current.revision {
return Ok(CoordinatorResponse::DebugBreakpoints {
process,
actor,
revision: current.revision,
probe_symbols: current.probe_symbols.iter().cloned().collect(),
hit_epoch: current.hit_epoch,
hit_task: current.hit_task.clone(),
hit_probe_symbol: current.hit_probe_symbol.clone(),
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
used_debug_read_bytes: audit_event.used_debug_read_bytes,
audit_event,
});
}
}
self.debug_breakpoints.insert(
process_control_key(&tenant, &project, &process),
key,
DebugBreakpointPlan {
actor: actor.clone(),
revision,
probe_symbols: probe_symbols.iter().cloned().collect(),
hit_epoch: None,
hit_task: None,
@ -128,6 +148,7 @@ impl CoordinatorService {
Ok(CoordinatorResponse::DebugBreakpoints {
process,
actor,
revision,
probe_symbols,
hit_epoch: None,
hit_task: None,
@ -190,6 +211,7 @@ impl CoordinatorService {
Ok(CoordinatorResponse::DebugBreakpoints {
process,
actor,
revision: plan.revision,
probe_symbols: plan.probe_symbols.into_iter().collect(),
hit_epoch: plan.hit_epoch,
hit_task: plan.hit_task,

View file

@ -33,12 +33,14 @@ impl CoordinatorService {
project,
actor_user,
process,
revision,
probe_symbols,
} => self.handle_set_debug_breakpoints(
tenant,
project,
actor_user,
process,
revision,
probe_symbols,
),
CoordinatorRequest::InspectDebugBreakpoints {
@ -96,12 +98,14 @@ impl CoordinatorService {
),
AuthenticatedCoordinatorRequest::SetDebugBreakpoints {
process,
revision,
probe_symbols,
} => self.handle_set_debug_breakpoints(
tenant.as_str().to_owned(),
project.as_str().to_owned(),
actor.as_str().to_owned(),
process,
revision,
probe_symbols,
),
AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { process } => self

View file

@ -30,14 +30,14 @@ use super::{
};
#[derive(Clone)]
struct MainScope {
tenant: TenantId,
project: ProjectId,
process: ProcessId,
task_definition: TaskDefinitionId,
task_instance: TaskInstanceId,
epoch: u64,
launch_id: u64,
pub(super) struct MainScope {
pub(super) tenant: TenantId,
pub(super) project: ProjectId,
pub(super) process: ProcessId,
pub(super) task_definition: TaskDefinitionId,
pub(super) task_instance: TaskInstanceId,
pub(super) epoch: u64,
pub(super) launch_id: u64,
}
enum MainCommand {
@ -889,7 +889,7 @@ impl CoordinatorService {
}
}
fn record_coordinator_main_completion(
pub(super) fn record_coordinator_main_completion(
&mut self,
scope: MainScope,
result: Result<WasmTaskResult, String>,
@ -1229,4 +1229,60 @@ mod tests {
assert!(service.active_tasks.contains(&child_key));
assert!(!service.task_aborts.contains(&child_key));
}
#[test]
fn failed_main_aborts_unfinished_children_and_clears_process_debug_state() {
let mut service = CoordinatorService::new(7);
let tenant = TenantId::from("tenant");
let project = ProjectId::from("project");
let process = ProcessId::from("vp-failed-main");
let main_task = TaskInstanceId::from("ti:vp-failed-main:main");
let child_task = TaskInstanceId::from("ti:vp-failed-main:child:1");
let child_node = NodeId::from("worker");
let scope = MainScope {
tenant: tenant.clone(),
project: project.clone(),
process: process.clone(),
task_definition: TaskDefinitionId::from("build"),
task_instance: main_task.clone(),
epoch: 7,
launch_id: 1,
};
service
.coordinator
.start_process(tenant.clone(), project.clone(), process.clone());
let process_key = process_control_key(&tenant, &project, &process);
service.main_runtime.controls.insert(
process_key.clone(),
CoordinatorMainControl {
task_definition: scope.task_definition.clone(),
task_instance: main_task,
abort: Arc::new(AtomicBool::new(false)),
debug: Arc::new(WasmDebugControl::default()),
state: "running".to_owned(),
stopped_probe_symbol: None,
handles: Arc::new(Mutex::new(HashMap::new())),
launch_id: 1,
},
);
let child_key = task_control_key(&tenant, &project, &process, &child_node, &child_task);
service.active_tasks.insert(child_key.clone());
service.debug_epochs.insert(process_key.clone(), 2);
service.record_coordinator_main_completion(scope, Err("main crashed".to_owned()));
assert!(service
.coordinator
.active_process(&tenant, &project, &process)
.is_none());
assert!(service.active_tasks.contains(&child_key));
assert!(service.task_aborts.contains(&child_key));
assert!(service.process_aborts.contains(&process_key));
assert!(!service.debug_epochs.contains_key(&process_key));
assert!(service.task_events.iter().any(|event| {
event.process == process
&& event.executor == TaskExecutor::CoordinatorMain
&& event.terminal_state == TaskTerminalState::Failed
}));
}
}

File diff suppressed because it is too large Load diff

View file

@ -430,6 +430,7 @@ pub enum CoordinatorResponse {
DebugBreakpoints {
process: ProcessId,
actor: UserId,
revision: u64,
probe_symbols: Vec<String>,
hit_epoch: Option<u64>,
hit_task: Option<TaskInstanceId>,

View file

@ -19,7 +19,9 @@ impl CoordinatorService {
))
})?;
let payload_digest = clusterflux_core::signed_request_payload_digest(&request_payload);
let signed_node = NodeId::new(signed_node);
let signed_node = NodeId::try_new(signed_node).map_err(|error| {
CoordinatorServiceError::Protocol(format!("invalid signed node identifier: {error}"))
})?;
if request_node != signed_node {
return Err(CoordinatorError::Unauthorized(
"signed node request node does not match the wrapped request node".to_owned(),
@ -144,6 +146,19 @@ impl CoordinatorService {
provider,
source_snapshot,
),
CoordinatorRequest::RequestRendezvous {
scope,
source,
destination,
direct_connectivity,
failure_reason,
} => self.handle_request_rendezvous(
scope,
source,
destination,
direct_connectivity,
failure_reason,
),
CoordinatorRequest::ReconnectNode {
node,
process,
@ -322,6 +337,7 @@ fn signed_node_request_kind(
CoordinatorRequest::LaunchChildTask { .. } => Ok("launch_child_task"),
CoordinatorRequest::JoinChildTask { .. } => Ok("join_child_task"),
CoordinatorRequest::CompleteSourcePreparation { .. } => Ok("complete_source_preparation"),
CoordinatorRequest::RequestRendezvous { .. } => Ok("request_rendezvous"),
CoordinatorRequest::ReconnectNode { .. } => Ok("reconnect_node"),
CoordinatorRequest::PollTaskControl { .. } => Ok("poll_task_control"),
CoordinatorRequest::PollDebugCommand { .. } => Ok("poll_debug_command"),
@ -356,7 +372,14 @@ fn signed_node_request_node(
| CoordinatorRequest::ReportDebugProbeHit { node, .. }
| CoordinatorRequest::ReportTaskLog { node, .. }
| CoordinatorRequest::ReportVfsMetadata { node, .. }
| CoordinatorRequest::TaskCompleted { node, .. } => Ok(NodeId::new(node.clone())),
| CoordinatorRequest::TaskCompleted { node, .. } => {
NodeId::try_new(node.clone()).map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"invalid wrapped node identifier: {error}"
))
})
}
CoordinatorRequest::RequestRendezvous { source, .. } => Ok(source.node.clone()),
_ => Err(CoordinatorError::Unauthorized(
"signed_node envelope only accepts node-originated coordinator requests".to_owned(),
)

View file

@ -189,6 +189,11 @@ fn authorize_client_request(
#[cfg(test)]
mod transport_boundary_tests {
use std::io::{BufRead as _, BufReader};
use clusterflux_core::{coordinator_wire_request, ProjectId, TenantId, UserId};
use serde_json::json;
use super::*;
#[test]
@ -197,4 +202,96 @@ mod transport_boundary_tests {
let error = CoordinatorService::new(1).serve_tcp(listener).unwrap_err();
assert!(error.to_string().contains("restricted to loopback"));
}
#[test]
fn malformed_identifiers_are_rejected_before_the_shared_service_lock_and_do_not_poison_it() {
let (listener, addr) = bind_listener("127.0.0.1:0").unwrap();
let mut coordinator = CoordinatorService::new(11);
coordinator
.issue_cli_session(
TenantId::from("tenant"),
ProjectId::from("project"),
UserId::from("user"),
"healthy-session",
None,
)
.unwrap();
let shared = Arc::new(Mutex::new(coordinator));
let server_shared = Arc::clone(&shared);
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
handle_shared_stream(server_shared, stream, ClientAuthorityMode::Strict).unwrap();
});
let mut stream = TcpStream::connect(addr).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
for (index, malformed_process) in [
String::new(),
" ".to_owned(),
"bad\0process".to_owned(),
"bad process!".to_owned(),
"x".repeat(clusterflux_core::MAX_EXTERNAL_ID_BYTES + 1),
]
.into_iter()
.enumerate()
{
let malformed = coordinator_wire_request(
format!("malformed-{index}"),
json!({
"type": "authenticated",
"session_secret": "healthy-session",
"request": {
"type": "abort_process",
"process": malformed_process,
"launch_attempt": "valid-attempt"
}
}),
);
serde_json::to_writer(&mut stream, &malformed).unwrap();
stream.write_all(b"\n").unwrap();
stream.flush().unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let CoordinatorResponse::Error { message } =
serde_json::from_str::<CoordinatorResponse>(&line).unwrap()
else {
panic!("malformed identifier request unexpectedly succeeded");
};
assert!(
message.contains("malformed external identifier")
&& message.contains("request.request.process"),
"unexpected malformed identifier response: {message}"
);
let valid = coordinator_wire_request(
format!("healthy-{index}"),
json!({
"type": "authenticated",
"session_secret": "healthy-session",
"request": { "type": "auth_status" }
}),
);
serde_json::to_writer(&mut stream, &valid).unwrap();
stream.write_all(b"\n").unwrap();
stream.flush().unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
assert!(
matches!(
serde_json::from_str::<CoordinatorResponse>(&line).unwrap(),
CoordinatorResponse::AuthStatus {
authenticated: true,
..
}
),
"valid authenticated traffic failed after malformed request {index}"
);
}
stream.shutdown(std::net::Shutdown::Both).unwrap();
server.join().unwrap();
assert!(!shared.is_poisoned());
}
}

View file

@ -12,8 +12,8 @@ use clusterflux_core::{
AgentSignedRequest, AgentWorkflowScope, ArtifactFlush, ArtifactHandle, ArtifactId, Capability,
DataPlaneObject, DataPlaneScope, Digest, EnvironmentBackend, EnvironmentRequirements,
LimitKind, NodeCapabilities, NodeEndpoint, NodeSignedRequest, Os, ResourceLimits,
SourceProviderKind, TaskBoundaryValue, TaskDispatch, TaskInstanceId, TaskJoinState, TaskSpec,
VfsPath, WasmExportAbi,
SourceProviderKind, TaskBoundaryValue, TaskDefinitionId, TaskDispatch, TaskInstanceId,
TaskJoinState, TaskSpec, VfsPath, WasmExportAbi, WasmTaskResult,
};
use serde_json::json;
@ -521,6 +521,9 @@ fn signed_node_request_auto(request: CoordinatorRequest) -> CoordinatorRequest {
CoordinatorRequest::CompleteSourcePreparation { node, .. } => {
(node.clone(), "complete_source_preparation")
}
CoordinatorRequest::RequestRendezvous { source, .. } => {
(source.node.to_string(), "request_rendezvous")
}
CoordinatorRequest::ReconnectNode { node, .. } => (node.clone(), "reconnect_node"),
CoordinatorRequest::PollTaskControl { node, .. } => (node.clone(), "poll_task_control"),
CoordinatorRequest::PollDebugCommand { node, .. } => (node.clone(), "poll_debug_command"),
@ -2752,6 +2755,7 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() {
},
);
let CoordinatorResponse::DebugBreakpoints {
revision,
probe_symbols,
hit_epoch,
..
@ -2761,15 +2765,37 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() {
project: "project".to_owned(),
actor_user: "user".to_owned(),
process: "process".to_owned(),
revision: 1,
probe_symbols: vec!["clusterflux.probe.compile_linux".to_owned()],
})
.unwrap()
else {
panic!("expected debug breakpoints response");
};
assert_eq!(revision, 1);
assert_eq!(probe_symbols, ["clusterflux.probe.compile_linux"]);
assert_eq!(hit_epoch, None);
let CoordinatorResponse::DebugBreakpoints {
revision,
probe_symbols,
..
} = service
.handle_request(CoordinatorRequest::SetDebugBreakpoints {
tenant: "tenant".to_owned(),
project: "project".to_owned(),
actor_user: "user".to_owned(),
process: "process".to_owned(),
revision: 0,
probe_symbols: vec!["clusterflux.probe.stale".to_owned()],
})
.unwrap()
else {
panic!("expected stale breakpoint response");
};
assert_eq!(revision, 1);
assert_eq!(probe_symbols, ["clusterflux.probe.compile_linux"]);
let CoordinatorResponse::DebugProbeHit {
breakpoint_matched,
debug_epoch,
@ -3583,6 +3609,184 @@ fn service_rejects_second_active_process_unless_restarting_same_process() {
assert!(!service.main_runtime.controls.contains_key(&process_key));
}
#[test]
fn completed_main_waits_for_final_child_then_preserves_history_and_releases_the_slot() {
let mut service = CoordinatorService::new(31);
let tenant = TenantId::from("tenant");
let project = ProjectId::from("project");
let process = ProcessId::from("process-main-before-child");
let child = TaskInstanceId::from("child-active");
let process_key = process_control_key(&tenant, &project, &process);
service
.handle_request(CoordinatorRequest::AttachNode {
tenant: tenant.to_string(),
project: project.to_string(),
node: "worker".to_owned(),
public_key: test_node_public_key("worker"),
})
.unwrap();
service
.handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: tenant.to_string(),
project: project.to_string(),
actor_user: None,
actor_agent: None,
agent_public_key_fingerprint: None,
agent_signature: None,
process: process.to_string(),
restart: false,
})
.unwrap();
service
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
node: "worker".to_owned(),
process: process.to_string(),
epoch: 31,
})
.unwrap();
register_test_task_assignment(
&mut service,
tenant.as_str(),
project.as_str(),
process.as_str(),
"worker",
"child-definition",
child.as_str(),
31,
);
let main = TaskInstanceId::from("main-instance");
service.main_runtime.controls.insert(
process_key.clone(),
super::main_runtime::CoordinatorMainControl {
task_definition: TaskDefinitionId::from("build"),
task_instance: main.clone(),
abort: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
debug: std::sync::Arc::new(clusterflux_wasm_runtime::WasmDebugControl::default()),
state: "running".to_owned(),
stopped_probe_symbol: None,
handles: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
launch_id: 1,
},
);
service.debug_epochs.insert(process_key.clone(), 9);
service.record_coordinator_main_completion(
super::main_runtime::MainScope {
tenant: tenant.clone(),
project: project.clone(),
process: process.clone(),
task_definition: TaskDefinitionId::from("build"),
task_instance: main,
epoch: 31,
launch_id: 1,
},
Ok(WasmTaskResult::completed(
TaskInstanceId::from("main-instance"),
TaskBoundaryValue::SmallJson(json!("main completed")),
)),
);
assert!(service
.coordinator
.active_process(&tenant, &project, &process)
.is_some());
assert!(service.debug_epochs.contains_key(&process_key));
assert!(service
.active_tasks
.iter()
.any(|(_, _, retained_process, _, task)| {
retained_process == &process && task == &child
}));
let blocked_next = service
.handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: tenant.to_string(),
project: project.to_string(),
actor_user: None,
actor_agent: None,
agent_public_key_fingerprint: None,
agent_signature: None,
process: "process-too-early".to_owned(),
restart: false,
})
.unwrap_err();
assert!(blocked_next
.to_string()
.contains("already has active virtual process"));
let artifact_bytes = b"child artifact survives terminal cleanup";
service
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
tenant: tenant.to_string(),
project: project.to_string(),
process: process.to_string(),
node: "worker".to_owned(),
task: child.to_string(),
terminal_state: Some(TaskTerminalState::Completed),
status_code: Some(0),
stdout_bytes: artifact_bytes.len() as u64,
stderr_bytes: 0,
stdout_tail: "child completed".to_owned(),
stderr_tail: String::new(),
stdout_truncated: false,
stderr_truncated: false,
artifact_path: Some("/vfs/artifacts/child-output".to_owned()),
artifact_digest: Some(Digest::sha256(artifact_bytes)),
artifact_size_bytes: Some(artifact_bytes.len() as u64),
result: Some(TaskBoundaryValue::SmallJson(json!("child completed"))),
})
.unwrap();
assert!(service
.coordinator
.active_process(&tenant, &project, &process)
.is_none());
assert!(!service.debug_epochs.contains_key(&process_key));
let CoordinatorResponse::TaskEvents { events } = service
.handle_request(CoordinatorRequest::ListTaskEvents {
tenant: tenant.to_string(),
project: project.to_string(),
actor_user: "user".to_owned(),
process: Some(process.to_string()),
})
.unwrap()
else {
panic!("expected retained task events");
};
assert_eq!(events.len(), 2);
assert!(events.iter().any(|event| {
event.executor == TaskExecutor::CoordinatorMain
&& event.terminal_state == TaskTerminalState::Completed
}));
assert!(events.iter().any(|event| {
event.task == child && event.artifact_digest == Some(Digest::sha256(artifact_bytes))
}));
let metadata = service
.artifact_registry
.metadata(&ArtifactId::from("child-output"))
.expect("artifact metadata must survive terminal cleanup");
assert_eq!(metadata.process, process);
assert_eq!(metadata.digest, Digest::sha256(artifact_bytes));
let next = service
.handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: tenant.to_string(),
project: project.to_string(),
actor_user: None,
actor_agent: None,
agent_public_key_fingerprint: None,
agent_signature: None,
process: "process-after-cleanup".to_owned(),
restart: false,
})
.unwrap();
assert!(matches!(next, CoordinatorResponse::ProcessStarted { .. }));
}
#[test]
fn quiescent_cooperative_cancel_releases_slot_immediately() {
let mut service = CoordinatorService::new(17);
@ -6084,6 +6288,35 @@ fn service_meters_rendezvous_before_direct_transfer_plan() {
assert!(quota.to_string().contains("RendezvousAttempt"));
}
#[test]
fn enrolled_node_can_request_a_structurally_scoped_rendezvous_with_signed_authority() {
let mut service = CoordinatorService::new(7);
service
.handle_request(CoordinatorRequest::AttachNode {
tenant: "tenant".to_owned(),
project: "project".to_owned(),
node: "node-a".to_owned(),
public_key: test_node_public_key("node-a"),
})
.unwrap();
let response = service
.handle_signed_node_request_auto(CoordinatorRequest::RequestRendezvous {
scope: data_plane_scope("project"),
source: endpoint("node-a"),
destination: endpoint("node-b"),
direct_connectivity: true,
failure_reason: String::new(),
})
.unwrap();
let CoordinatorResponse::RendezvousPlan { plan, .. } = response else {
panic!("expected signed rendezvous plan");
};
assert_eq!(plan.source.node, NodeId::from("node-a"));
assert_eq!(plan.destination.node, NodeId::from("node-b"));
}
#[test]
fn service_rejects_task_completion_outside_node_scope() {
let mut service = CoordinatorService::new(1);

View file

@ -364,7 +364,13 @@ pub fn agent_workflow_request_scope_from_payload(
.get("task_instance")
.and_then(Value::as_str)
.ok_or_else(|| "launch_task agent scope is missing task_instance".to_owned())?;
(process, Some(TaskInstanceId::from(task)))
(
process,
Some(
TaskInstanceId::try_new(task)
.map_err(|error| format!("malformed launch_task task instance: {error}"))?,
),
)
}
_ => {
return Err(format!(
@ -373,10 +379,13 @@ pub fn agent_workflow_request_scope_from_payload(
}
};
AgentWorkflowRequestScope::new(
TenantId::from(tenant),
ProjectId::from(project),
TenantId::try_new(tenant)
.map_err(|error| format!("malformed agent workflow tenant: {error}"))?,
ProjectId::try_new(project)
.map_err(|error| format!("malformed agent workflow project: {error}"))?,
request_kind,
ProcessId::from(process),
ProcessId::try_new(process)
.map_err(|error| format!("malformed agent workflow process: {error}"))?,
task,
)
}

View file

@ -1,7 +1,35 @@
#[cfg(not(target_arch = "wasm32"))]
use serde::{de::Error as _, Deserializer};
use serde::{Deserialize, Serialize};
pub const MAX_EXTERNAL_ID_BYTES: usize = 255;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OpaqueTokenError {
reason: String,
}
impl std::fmt::Display for OpaqueTokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.reason)
}
}
impl std::error::Error for OpaqueTokenError {}
pub fn validate_opaque_token(value: &str, max_bytes: usize) -> Result<(), OpaqueTokenError> {
let reason = if value.trim().is_empty() {
Some("value must not be empty or whitespace-only".to_owned())
} else if value.len() > max_bytes {
Some(format!("value exceeds the {max_bytes}-byte limit"))
} else if value.chars().any(char::is_control) {
Some("control characters are forbidden".to_owned())
} else {
None
};
reason.map_or(Ok(()), |reason| Err(OpaqueTokenError { reason }))
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IdParseError {
id_type: &'static str,
@ -55,7 +83,8 @@ fn validate_id(value: &str, id_type: &'static str) -> Result<(), IdParseError> {
macro_rules! id_type {
($name:ident) => {
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[cfg_attr(target_arch = "wasm32", derive(Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct $name(String);
impl $name {
@ -81,6 +110,17 @@ macro_rules! id_type {
}
}
#[cfg(not(target_arch = "wasm32"))]
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::try_new(value).map_err(D::Error::custom)
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
@ -134,4 +174,38 @@ mod tests {
assert_hostile_values(TenantId::try_new);
assert_hostile_values(UserId::try_new);
}
#[test]
fn every_identifier_type_validates_during_deserialization() {
macro_rules! assert_deserialization {
($id:ty) => {
assert!(serde_json::from_str::<$id>(r#""valid-id""#).is_ok());
let error = serde_json::from_str::<$id>(r#""hostile id!""#).unwrap_err();
assert!(
error.to_string().contains("is invalid"),
"{} produced an unexpected error: {error}",
stringify!($id)
);
};
}
assert_deserialization!(AgentId);
assert_deserialization!(ArtifactId);
assert_deserialization!(NodeId);
assert_deserialization!(ProcessId);
assert_deserialization!(LaunchAttemptId);
assert_deserialization!(ProjectId);
assert_deserialization!(TaskDefinitionId);
assert_deserialization!(TaskInstanceId);
assert_deserialization!(TenantId);
assert_deserialization!(UserId);
}
#[test]
fn opaque_tokens_are_bounded_without_using_identifier_syntax() {
validate_opaque_token("opaque secret/+==", 64).unwrap();
assert!(validate_opaque_token("", 64).is_err());
assert!(validate_opaque_token("bad\0token", 64).is_err());
assert!(validate_opaque_token(&"x".repeat(65), 64).is_err());
}
}

View file

@ -66,9 +66,9 @@ pub use execution::{
WasmTaskOutcome, WasmTaskResult, MAX_WASM_TASK_ENVELOPE_BYTES, WASM_TASK_ABI_VERSION,
};
pub use ids::{
AgentId, ArtifactId, DebugSessionId, IdParseError, LaunchAttemptId, NodeId, ProcessId,
ProjectId, RequestId, TaskDefinitionId, TaskInstanceId, TenantId, UserId,
MAX_EXTERNAL_ID_BYTES,
validate_opaque_token, AgentId, ArtifactId, DebugSessionId, IdParseError, LaunchAttemptId,
NodeId, OpaqueTokenError, ProcessId, ProjectId, RequestId, TaskDefinitionId, TaskInstanceId,
TenantId, UserId, MAX_EXTERNAL_ID_BYTES,
};
pub use limits::{
LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits,

View file

@ -64,6 +64,7 @@ struct LaunchCompletion {
state: AdapterState,
local_runtime_session: Option<LocalRuntimeSession>,
breakpoint_revision: u64,
observation_diagnostics: Vec<String>,
}
pub(crate) fn run_adapter() -> Result<()> {
@ -108,6 +109,9 @@ pub(crate) fn run_adapter() -> Result<()> {
if let Some(session) = completion.local_runtime_session {
_local_runtime_session = Some(session);
}
for diagnostic in completion.observation_diagnostics {
writer.output("stderr", format!("{diagnostic}\n"))?;
}
runtime_started = true;
if state.breakpoints_installed {
emit_verified_breakpoints(&mut writer, &state)?;
@ -252,7 +256,12 @@ pub(crate) fn run_adapter() -> Result<()> {
revision,
result,
} => {
if generation == runtime_generation && revision == state.breakpoint_revision {
if breakpoint_update_is_current(
generation,
runtime_generation,
revision,
state.breakpoint_revision,
) {
match result {
Ok(()) => {
state.breakpoints_installed = true;
@ -478,6 +487,7 @@ pub(crate) fn run_adapter() -> Result<()> {
state: completed_state,
local_runtime_session: None,
breakpoint_revision,
observation_diagnostics: Vec::new(),
}
})
.map_err(|error| format!("services runtime attach failed: {error:#}"))
@ -486,6 +496,8 @@ pub(crate) fn run_adapter() -> Result<()> {
RuntimeBackend::LocalServices => {
run_local_services_runtime(&completed_state)
.map(|(record, session)| {
let observation_diagnostics =
runtime_observation_diagnostics(&record);
completed_state.apply_runtime_record(record);
let breakpoint_revision =
completed_state.breakpoint_revision;
@ -493,6 +505,7 @@ pub(crate) fn run_adapter() -> Result<()> {
state: completed_state,
local_runtime_session: Some(session),
breakpoint_revision,
observation_diagnostics,
}
})
.map_err(|error| {
@ -502,6 +515,8 @@ pub(crate) fn run_adapter() -> Result<()> {
RuntimeBackend::LiveServices => {
run_live_services_runtime(&completed_state)
.map(|record| {
let observation_diagnostics =
runtime_observation_diagnostics(&record);
completed_state.apply_runtime_record(record);
let breakpoint_revision =
completed_state.breakpoint_revision;
@ -509,6 +524,7 @@ pub(crate) fn run_adapter() -> Result<()> {
state: completed_state,
local_runtime_session: None,
breakpoint_revision,
observation_diagnostics,
}
})
.map_err(|error| {
@ -1051,6 +1067,29 @@ fn spawn_runtime_observer(
});
}
pub(crate) fn breakpoint_update_is_current(
update_generation: u64,
runtime_generation: u64,
update_revision: u64,
current_revision: u64,
) -> bool {
update_generation == runtime_generation && update_revision == current_revision
}
fn runtime_observation_diagnostics(
record: &crate::virtual_model::RuntimeLaunchRecord,
) -> Vec<String> {
record
.node_report
.get("observation_diagnostics")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
}
fn emit_verified_breakpoints(writer: &mut DapWriter, state: &AdapterState) -> Result<()> {
if !state.breakpoints_installed {
return Ok(());

View file

@ -539,7 +539,24 @@ fn new_launch_attempt_id() -> String {
fn set_services_debug_breakpoints_at(coordinator: &str, state: &AdapterState) -> Result<()> {
static INJECTED_INSTALL_FAILURE: AtomicBool = AtomicBool::new(false);
if std::env::var_os("CLUSTERFLUX_TEST_DAP_BREAKPOINT_INSTALLATION_FAILURE").is_some()
if std::env::var("CLUSTERFLUX_TEST_DAP_BREAKPOINT_DELAY_REVISION")
.ok()
.and_then(|value| value.parse::<u64>().ok())
== Some(state.breakpoint_revision)
{
let delay_ms = std::env::var("CLUSTERFLUX_TEST_DAP_BREAKPOINT_DELAY_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(750);
std::thread::sleep(Duration::from_millis(delay_ms));
}
let revision_failure =
std::env::var("CLUSTERFLUX_TEST_DAP_BREAKPOINT_INSTALLATION_FAILURE_REVISION")
.ok()
.and_then(|value| value.parse::<u64>().ok())
== Some(state.breakpoint_revision);
if (revision_failure
|| std::env::var_os("CLUSTERFLUX_TEST_DAP_BREAKPOINT_INSTALLATION_FAILURE").is_some())
&& !state.breakpoints.is_empty()
&& !INJECTED_INSTALL_FAILURE.swap(true, Ordering::SeqCst)
{
@ -557,6 +574,7 @@ fn set_services_debug_breakpoints_at(coordinator: &str, state: &AdapterState) ->
"project": state.project_id,
"actor_user": state.actor_user,
"process": state.process.as_str(),
"revision": state.breakpoint_revision,
"probe_symbols": state.requested_probe_symbols(),
}),
),
@ -1026,6 +1044,12 @@ pub(crate) fn observe_services_runtime(
let inject_connection_loss =
std::env::var("CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS").as_deref() == Ok("1");
let mut connection_loss_injected = false;
let inject_snapshot_failure =
std::env::var_os("CLUSTERFLUX_TEST_DAP_OBSERVER_SNAPSHOT_FAILURE").is_some();
let mut snapshot_failure_injected = false;
let inject_process_status_failure =
std::env::var_os("CLUSTERFLUX_TEST_DAP_OBSERVER_PROCESS_STATUS_FAILURE").is_some();
let mut process_status_failure_injected = false;
let inject_fallback_failure =
std::env::var_os("CLUSTERFLUX_TEST_DAP_OBSERVER_FALLBACK_FAILURE").is_some();
let mut fallback_failure_stage = 0_u8;
@ -1095,11 +1119,49 @@ pub(crate) fn observe_services_runtime(
continue;
}
if let Some(mut outcome) = terminal_runtime_outcome(&coordinator, state, &events) {
let task_snapshots = fetch_task_snapshots_in(current_session, state)
.unwrap_or_else(|_| json!({ "snapshots": [] }));
let (process_statuses, process_status) =
fetch_current_process_status_in(current_session, state)
.unwrap_or_else(|_| (json!({ "processes": [] }), None));
let snapshot_request = if inject_snapshot_failure && !snapshot_failure_injected {
snapshot_failure_injected = true;
Err(anyhow!("injected task snapshot transport failure"))
} else {
fetch_task_snapshots_in(current_session, state)
};
let task_snapshots = match snapshot_request {
Ok(snapshots) => snapshots,
Err(error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
"runtime terminal snapshot observation failed: {error:#}; reconnecting in {} ms",
reconnect_delay.as_millis()
))) {
return Ok(());
}
session = None;
std::thread::sleep(reconnect_delay);
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
continue;
}
};
let process_status_request =
if inject_process_status_failure && !process_status_failure_injected {
process_status_failure_injected = true;
Err(anyhow!("injected process-status transport failure"))
} else {
fetch_current_process_status_in(current_session, state)
};
let (process_statuses, process_status) = match process_status_request {
Ok(status) => status,
Err(error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
"runtime terminal process observation failed: {error:#}; reconnecting in {} ms",
reconnect_delay.as_millis()
))) {
return Ok(());
}
session = None;
std::thread::sleep(reconnect_delay);
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
continue;
}
};
if !has_current_runtime_task(&task_snapshots) {
if let RuntimeContinuationOutcome::Terminal(record) = &mut outcome {
record.node_report = json!({
@ -1113,7 +1175,13 @@ pub(crate) fn observe_services_runtime(
return Ok(());
}
}
let task_snapshots = match fetch_task_snapshots_in(current_session, state) {
let snapshot_request = if inject_snapshot_failure && !snapshot_failure_injected {
snapshot_failure_injected = true;
Err(anyhow!("injected task snapshot transport failure"))
} else {
fetch_task_snapshots_in(current_session, state)
};
let task_snapshots = match snapshot_request {
Ok(snapshots) => snapshots,
Err(error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
@ -1128,22 +1196,28 @@ pub(crate) fn observe_services_runtime(
continue;
}
};
let (process_statuses, process_status) =
match fetch_current_process_status_in(current_session, state) {
Ok(status) => status,
Err(error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
"runtime process observation failed: {error:#}; reconnecting in {} ms",
reconnect_delay.as_millis()
))) {
return Ok(());
}
session = None;
std::thread::sleep(reconnect_delay);
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
continue;
}
let process_status_request =
if inject_process_status_failure && !process_status_failure_injected {
process_status_failure_injected = true;
Err(anyhow!("injected process-status transport failure"))
} else {
fetch_current_process_status_in(current_session, state)
};
let (process_statuses, process_status) = match process_status_request {
Ok(status) => status,
Err(error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
"runtime process observation failed: {error:#}; reconnecting in {} ms",
reconnect_delay.as_millis()
))) {
return Ok(());
}
session = None;
std::thread::sleep(reconnect_delay);
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
continue;
}
};
reconnect_delay = Duration::from_millis(100);
if let Some((failed_task, _attempt_id)) = failed_awaiting_action_snapshot(&task_snapshots) {
let failed_task = failed_task.to_owned();
@ -1277,8 +1351,21 @@ pub(crate) fn observe_services_runtime(
}
};
if let Some(mut outcome) = terminal_runtime_outcome(&coordinator, state, &events) {
let task_snapshots = fetch_task_snapshots_in(current_session, state)
.unwrap_or_else(|_| json!({ "snapshots": [] }));
let task_snapshots = match fetch_task_snapshots_in(current_session, state) {
Ok(snapshots) => snapshots,
Err(snapshot_error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
"runtime fallback terminal snapshot observation failed: {snapshot_error:#}; reconnecting in {} ms",
reconnect_delay.as_millis()
))) {
return Ok(());
}
session = None;
std::thread::sleep(reconnect_delay);
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
continue;
}
};
if !has_current_runtime_task(&task_snapshots) {
if let RuntimeContinuationOutcome::Terminal(record) = &mut outcome {
record.node_report = json!({

View file

@ -173,7 +173,9 @@ pub(crate) fn parse_task_restart_response(response: Value) -> Result<TaskRestart
restarted_task_instance: response
.get("restarted_task_instance")
.and_then(Value::as_str)
.map(TaskInstanceId::new),
.map(TaskInstanceId::try_new)
.transpose()
.map_err(|error| anyhow!("malformed restarted task instance: {error}"))?,
restarted_attempt_id: response
.get("restarted_attempt_id")
.and_then(Value::as_str)

View file

@ -45,6 +45,13 @@ fn initialize_capabilities_use_standard_dap_flags() {
.all(|key| key.starts_with("supports") && !key.contains("clusterflux")));
}
#[test]
fn stale_breakpoint_success_and_failure_completions_are_not_current() {
assert!(crate::adapter::breakpoint_update_is_current(4, 4, 9, 9));
assert!(!crate::adapter::breakpoint_update_is_current(4, 4, 8, 9));
assert!(!crate::adapter::breakpoint_update_is_current(3, 4, 9, 9));
}
#[test]
fn runtime_backend_defaults_to_local_services_and_requires_explicit_demo() {
assert_eq!(

View file

@ -313,7 +313,10 @@ impl AdapterState {
self.debug_all_threads_stopped = 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);
self.stopped_task = record
.stopped_task
.as_deref()
.and_then(|task| TaskInstanceId::try_new(task).ok());
self.stopped_probe_symbol = record.stopped_probe_symbol.clone();
if let Some(acknowledgements) = record
.node_report
@ -330,6 +333,12 @@ impl AdapterState {
else {
continue;
};
let Ok(task_id) = TaskInstanceId::try_new(task) else {
continue;
};
let Ok(task_definition_id) = TaskDefinitionId::try_new(task_definition) else {
continue;
};
let coordinator_main = acknowledgement.get("node").and_then(Value::as_str)
== Some("coordinator-main");
let thread_id = if coordinator_main {
@ -339,14 +348,19 @@ impl AdapterState {
.values()
.find(|thread| thread.task.as_str() == task)
.map(|thread| thread.id)
.unwrap_or_else(|| self.insert_runtime_thread(task, task_definition))
.unwrap_or_else(|| {
self.insert_runtime_thread(
task_id.clone(),
task_definition_id.clone(),
)
})
};
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.task = task_id;
thread.task_definition = task_definition_id;
thread.runtime_stack_frames = acknowledgement
.get("stack_frames")
.and_then(Value::as_array)
@ -428,18 +442,22 @@ impl AdapterState {
let _ = crate::view_state::write_view_state(self, &record);
}
fn insert_runtime_thread(&mut self, task: &str, task_definition: &str) -> i64 {
fn insert_runtime_thread(
&mut self,
task: TaskInstanceId,
task_definition: TaskDefinitionId,
) -> i64 {
let id = self.allocate_runtime_thread_id();
let line = self
.debug_probes
.iter()
.find(|probe| {
probe.task.as_str() == task_definition || probe.function == task_definition
probe.task == task_definition || probe.function == task_definition.as_str()
})
.map(|probe| i64::from(probe.line_start))
.unwrap_or(1);
let definition_name = task_definition.replace(['_', '-'], " ");
let name = if task == task_definition {
let definition_name = task_definition.as_str().replace(['_', '-'], " ");
let name = if task.as_str() == task_definition.as_str() {
definition_name
} else {
format!("{definition_name} ({task})")
@ -457,9 +475,9 @@ impl AdapterState {
target_ref: 6000 + id,
vfs_ref: 7000 + id,
command_ref: 8000 + id,
task: TaskInstanceId::new(task),
task,
attempt_id: "unknown-attempt".to_owned(),
task_definition: TaskDefinitionId::new(task_definition),
task_definition,
name,
line,
state: DebugRuntimeState::Running,
@ -725,7 +743,12 @@ pub(crate) fn process_id(project: &str, entry: &str) -> ProcessId {
ProcessId::new(format!("vp-{suffix}"))
}
fn coordinator_main_thread(entry: &str, task: &str, task_definition: &str) -> VirtualThread {
fn coordinator_main_thread(
entry: &str,
task: TaskInstanceId,
task_definition: TaskDefinitionId,
) -> VirtualThread {
let name = format!("{entry} coordinator main ({task})");
VirtualThread {
id: MAIN_THREAD,
frame_id: 1_001,
@ -737,10 +760,10 @@ fn coordinator_main_thread(entry: &str, task: &str, task_definition: &str) -> Vi
target_ref: 4_501,
vfs_ref: 5_001,
command_ref: 5_501,
task: TaskInstanceId::from(task),
task,
attempt_id: "main:unknown".to_owned(),
task_definition: TaskDefinitionId::from(task_definition),
name: format!("{entry} coordinator main ({task})"),
task_definition,
name,
line: 0,
state: DebugRuntimeState::Running,
freeze_supported: true,
@ -776,10 +799,14 @@ fn coordinator_threads_from_events(
.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 Ok(task) = TaskInstanceId::try_new(task) {
main.task = task;
}
}
if let Some(definition) = status.get("main_task_definition").and_then(Value::as_str) {
main.task_definition = TaskDefinitionId::from(definition);
if let Ok(definition) = TaskDefinitionId::try_new(definition) {
main.task_definition = definition;
}
}
main.name = format!("{entry} coordinator main ({})", main.task);
main.state = if status
@ -841,6 +868,12 @@ fn coordinator_threads_from_snapshots(
.get("main_task_definition")
.and_then(Value::as_str)
.unwrap_or(entry);
let (Ok(task), Ok(task_definition)) = (
TaskInstanceId::try_new(task),
TaskDefinitionId::try_new(task_definition),
) else {
return threads;
};
let mut main = coordinator_main_thread(entry, task, task_definition);
main.attempt_id = format!(
"main:{}",
@ -895,6 +928,12 @@ fn coordinator_threads_from_snapshots(
let Some(task_definition) = snapshot.get("task_definition").and_then(Value::as_str) else {
continue;
};
let Ok(task_id) = TaskInstanceId::try_new(task) else {
continue;
};
let Ok(task_definition_id) = TaskDefinitionId::try_new(task_definition) else {
continue;
};
let attempt_id = snapshot
.get("attempt_id")
.and_then(Value::as_str)
@ -945,9 +984,9 @@ fn coordinator_threads_from_snapshots(
target_ref: 6000 + id,
vfs_ref: 7000 + id,
command_ref: 8000 + id,
task: TaskInstanceId::from(task),
task: task_id,
attempt_id,
task_definition: TaskDefinitionId::from(task_definition),
task_definition: task_definition_id,
name: format!("{display_name} ({task}, attempt {short_attempt})"),
line: snapshot
.get("source_line")
@ -994,6 +1033,8 @@ fn coordinator_threads_from_snapshots(
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 task_id = TaskInstanceId::try_new(task).ok()?;
let task_definition_id = TaskDefinitionId::try_new(task_definition).ok()?;
let definition_name = task_definition.replace(['_', '-'], " ");
let name = if task == task_definition {
definition_name
@ -1039,13 +1080,13 @@ fn coordinator_event_thread(id: i64, _event_index: usize, event: &Value) -> Opti
target_ref: 6000 + id,
vfs_ref: 7000 + id,
command_ref: 8000 + id,
task: TaskInstanceId::from(task),
task: task_id,
attempt_id: event
.get("attempt_id")
.and_then(Value::as_str)
.unwrap_or("unknown-attempt")
.to_owned(),
task_definition: TaskDefinitionId::from(task_definition),
task_definition: task_definition_id,
name,
line: 0,
state,

View file

@ -7,8 +7,7 @@ use std::time::{Duration, Instant};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use clusterflux_core::{
sign_node_request, signed_request_payload_digest, ArtifactId, Digest, NodeCapabilities, NodeId,
ProcessId, ProjectId, RequestId, TaskInstanceId, TaskSpec, TenantId,
MIN_SIGNED_NODE_POLL_INTERVAL_MS,
ProcessId, ProjectId, TaskInstanceId, TaskSpec, TenantId, MIN_SIGNED_NODE_POLL_INTERVAL_MS,
};
use serde_json::{json, Value};
@ -577,7 +576,7 @@ fn parse_args() -> Result<Args, Box<dyn std::error::Error>> {
ProjectId::try_new(project.clone())?;
NodeId::try_new(node.clone())?;
if let Some(grant) = enrollment_grant.as_ref() {
RequestId::try_new(grant.clone())?;
clusterflux_core::validate_opaque_token(grant, 512)?;
}
Ok(Args {
coordinator: coordinator.ok_or("--coordinator is required")?,