swactor/xtask/src/demo/node.rs

688 lines
26 KiB
Rust
Raw Normal View History

//! Node role: a real swactor runtime that joins the supervisor over iroh.
//!
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
//! Re-exec'd from the xtask binary by the demo provider (local process kind)
//! or launched as the entrypoint of the demo docker image. Builds an engine +
//! `IrohDriver` (relay disabled), joins the supervisor's endpoint, and then
//! **announces itself over the control plane**: a tagged gossip frame
//! ([`ANNOUNCE_TAG`]) carrying the provision attempt token, its iroh node
//! key, and its advertised endpoint address. The supervisor's announce relay
//! routes that frame by attempt to the owning bootstrap actor — the wire
//! announce *is* the readiness signal (no shared filesystem, no stdout
//! capture), and it repeats every [`HEARTBEAT_PERIOD`] as a liveness
//! heartbeat.
//!
//! Telemetry: the node owns a real [`TelemetryEndpoint`] whose stream id is
//! its transport identity (iroh node key). The runtime stats hook feeds
//! `runtime.actors` rosters, a `node.status` channel carries liveness
//! records, and beat actors keep the roster visibly alive. The supervisor
//! pulls this endpoint over `TELEMETRY_ALPN`
//! ([`iroh_driver::spawn_pull_server`]) — the node never dials out for
//! telemetry.
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
use std::sync::Arc;
use std::time::Duration;
use iroh::RelayMode;
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
use serde_json::json;
feat(myelin): enforce actor-owned control flow Architecture enforcement: - Install a repository-owned rustc wrapper for ordinary cargo check, build, and test commands. Resolve compiler item identities so renamed imports and helper wrappers cannot hide spawning, timing, blocking, polling, thread, or runtime-driving capabilities. - Define the execution-owner crates and reject dependencies from those substrates back into Myelin policy. Add compile-pass and compile-fail contracts for actor helpers, execution owners, test waits, forbidden capabilities, suppression attempts, and owner dependency inversions. Execution ownership: - Add engine-owned actor timers with cancellation and generation identity, then migrate lifecycle deadlines and protocol ticks off application tasks. Keep networking, process output, telemetry, and blocking provider calls in their approved I/O substrates. - Move process spawn, wait, signal, Unix listener, and output-following mechanics into swactor-process. Isolate Vast.ai blocking HTTP mechanics behind its adapter while actors retain retry, recovery, and provisioning decisions. Myelin control flow: - Rework manual control, worker lifecycle, provisioning, provider recovery, job deployment, distribution, edge orchestration, and shutdown as actor state transitions and typed effects. Preserve durable provider adoption and command outcomes across graceful and abrupt restarts. - Replace controller loops and timer-forwarding tasks with actor messages; leave substrate tasks as cancellable observation streams with no durable policy state. Properties and resource ownership: - Add deterministic engine and component properties, a stateful mock-VastAI lifecycle model, persisted regression cases, controlled fault injection, and a bounded nightly workflow covering restart and teardown behavior. - Terminate reply observers, cancel telemetry collectors, bound dashboard projections, and release child observers, file descriptors, process records, and inode-verified Unix sockets on every terminal path. Verified with the compiler-policy contracts, 105 Myelin library tests, 32 swactor-process tests, telemetry cancellation contracts, randomized stateful restart cases, cargo check, and formatting checks.
2026-08-19 21:38:14 +00:00
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::config::RuntimeConfig;
feat(myelin): enforce actor-owned control flow Architecture enforcement: - Install a repository-owned rustc wrapper for ordinary cargo check, build, and test commands. Resolve compiler item identities so renamed imports and helper wrappers cannot hide spawning, timing, blocking, polling, thread, or runtime-driving capabilities. - Define the execution-owner crates and reject dependencies from those substrates back into Myelin policy. Add compile-pass and compile-fail contracts for actor helpers, execution owners, test waits, forbidden capabilities, suppression attempts, and owner dependency inversions. Execution ownership: - Add engine-owned actor timers with cancellation and generation identity, then migrate lifecycle deadlines and protocol ticks off application tasks. Keep networking, process output, telemetry, and blocking provider calls in their approved I/O substrates. - Move process spawn, wait, signal, Unix listener, and output-following mechanics into swactor-process. Isolate Vast.ai blocking HTTP mechanics behind its adapter while actors retain retry, recovery, and provisioning decisions. Myelin control flow: - Rework manual control, worker lifecycle, provisioning, provider recovery, job deployment, distribution, edge orchestration, and shutdown as actor state transitions and typed effects. Preserve durable provider adoption and command outcomes across graceful and abrupt restarts. - Replace controller loops and timer-forwarding tasks with actor messages; leave substrate tasks as cancellable observation streams with no durable policy state. Properties and resource ownership: - Add deterministic engine and component properties, a stateful mock-VastAI lifecycle model, persisted regression cases, controlled fault injection, and a bounded nightly workflow covering restart and teardown behavior. - Terminate reply observers, cancel telemetry collectors, bound dashboard projections, and release child observers, file descriptors, process records, and inode-verified Unix sockets on every terminal path. Verified with the compiler-policy contracts, 105 Myelin library tests, 32 swactor-process tests, telemetry cancellation contracts, randomized stateful restart cases, cargo check, and formatting checks.
2026-08-19 21:38:14 +00:00
use swactor::runtime::{ExternalSender, RuntimeParts};
use swactor_engine::{ActorCompletion, Engine, EngineHandle, TokioBackend, TokioConfig};
use distribution::node::DistributedNodeConfig;
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
use iroh_driver::{IrohDriver, IrohDriverConfig, TELEMETRY_ALPN, spawn_pull_server};
use telemetry::{ChannelContent, TelemetryEndpoint, TelemetryProducer};
demo: rename xtask demo command; dashboard-established data-plane edges Rename `cargo xtask provisioning-reconciler-demo` to `cargo xtask demo` (CLI dispatch, help, child re-exec argv, launch spec strings, module dir xtask/src/provisioning_demo -> xtask/src/demo). Add iteration-1 data-plane edges, established from Fleet Control: - Fleet Control "edge" button -> POST /control/edge (new ControlCommand::EstablishEdge) -> supervisor actor resolves the node's advertised EndpointAddr (stashed in NodeRuntime by AnnounceActor) and provisions a real outbound EdgeRuntime (arena ring lease, recorder WorkerPort, EDGE_ALPN send pump) in a new edge pump thread. - Node gains EDGE_ALPN, an actor bridge decoding EdgeProvision gossip, and a NodeEdgeAgent that provisions its (single) inbound edge, polls it, mirrors observations onto the node.edge telemetry channel (render-only), and answers EdgeAck gossip which terminates the supervisor's provision retries. Node teardown replaces its inbound on re-provision; supervisor replaces sessions per node and tears them down on node exit/replacement/shutdown. - The edge pump runs on the engine's blocking pool with sole session ownership (commands in, state mirror + feed lines out): the connect handshake blocks its thread and must not run on a Tokio worker or share a lock with the actor. Connects are bounded (10s) so a dead node faults its session instead of wedging edge polling. - iroh-driver: retain_telemetry_connections() opts an application out of the driver-owned TELEMETRY_ALPN ingress so the node's pull server can drain those connections itself (the actor-bridge pump would otherwise claim them). - Dashboard: edges array in the reconciler snapshot, per-node edge badges and edge button in Fleet Control, node_edges render mirror.
2026-08-16 20:11:20 +00:00
use crate::demo::HEARTBEAT_PERIOD;
use crate::demo::edge;
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
/// Wire tag of the announce gossip frame (`CodecRegistry` decode key on the
/// supervisor side; raw tag bytes on the node side).
pub const ANNOUNCE_TAG: &str = "xtask_demo/NodeAnnounce/1";
/// A node's self-introduction on the supervisor's control plane. Sent right
/// after joining and then every heartbeat period; the supervisor routes it
/// by `attempt` to the owning bootstrap actor (first delivery = readiness,
/// later deliveries = liveness).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct NodeAnnounce {
/// Provision attempt token (`--demo-attempt`); correlates the announce
/// with the supervisor-side bootstrap actor that launched this node.
pub attempt: u64,
/// Logical node name (slot id) for dashboard labeling.
pub logical_node: String,
/// The node's transport key (iroh node id, hex).
pub key_hex: String,
/// Serde-serialized `iroh::EndpointAddr` the supervisor dials for
/// telemetry pulls.
pub endpoint_addr_json: String,
pub at_ms: u64,
}
/// How often the node polls its driver for accepted telemetry pull
/// connections.
const PULL_POLL_PERIOD: Duration = Duration::from_millis(200);
/// Idle sleep for the pull answer writer between subscription batches.
const WRITER_IDLE: Duration = Duration::from_millis(100);
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
/// Number of beat actors on the node — real actors whose message flow keeps
/// the `runtime.actors` roster visibly alive.
const BEAT_ACTORS: u64 = 2;
/// Run the node role. `supervisor_addr_json` is a serde-serialized
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
/// `iroh::EndpointAddr` of the supervisor's iroh endpoint; `attempt` is the
/// provision attempt token echoed back in every announce.
pub fn run_node_role(supervisor_addr_json: &str, attempt: u64) -> Result<(), String> {
install_parent_death_signal()?;
let supervisor_addr: iroh::EndpointAddr = serde_json::from_str(supervisor_addr_json)
.map_err(|error| format!("invalid supervisor endpoint address: {error}"))?;
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
let logical_node = std::env::var("DEMO_NODE_ID").unwrap_or_else(|_| "node".to_owned());
let parts = RuntimeParts::new(RuntimeConfig::default());
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
let runtime = parts.runtime().clone();
let engine = Engine::new(
parts,
TokioBackend::new(TokioConfig::default()).map_err(|error| format!("backend: {error}"))?,
)
.map_err(|error| format!("engine: {error}"))?;
// Bind the driver, then keep it alive for the process lifetime: dropping
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
// it closes the endpoint. The endpoint must advertise TELEMETRY_ALPN so
demo: rename xtask demo command; dashboard-established data-plane edges Rename `cargo xtask provisioning-reconciler-demo` to `cargo xtask demo` (CLI dispatch, help, child re-exec argv, launch spec strings, module dir xtask/src/provisioning_demo -> xtask/src/demo). Add iteration-1 data-plane edges, established from Fleet Control: - Fleet Control "edge" button -> POST /control/edge (new ControlCommand::EstablishEdge) -> supervisor actor resolves the node's advertised EndpointAddr (stashed in NodeRuntime by AnnounceActor) and provisions a real outbound EdgeRuntime (arena ring lease, recorder WorkerPort, EDGE_ALPN send pump) in a new edge pump thread. - Node gains EDGE_ALPN, an actor bridge decoding EdgeProvision gossip, and a NodeEdgeAgent that provisions its (single) inbound edge, polls it, mirrors observations onto the node.edge telemetry channel (render-only), and answers EdgeAck gossip which terminates the supervisor's provision retries. Node teardown replaces its inbound on re-provision; supervisor replaces sessions per node and tears them down on node exit/replacement/shutdown. - The edge pump runs on the engine's blocking pool with sole session ownership (commands in, state mirror + feed lines out): the connect handshake blocks its thread and must not run on a Tokio worker or share a lock with the actor. Connects are bounded (10s) so a dead node faults its session instead of wedging edge polling. - iroh-driver: retain_telemetry_connections() opts an application out of the driver-owned TELEMETRY_ALPN ingress so the node's pull server can drain those connections itself (the actor-bridge pump would otherwise claim them). - Dashboard: edges array in the reconciler snapshot, per-node edge badges and edge button in Fleet Control, node_edges render mirror.
2026-08-16 20:11:20 +00:00
// the supervisor's pull connection can negotiate it, and EDGE_ALPN so
// the supervisor's data-plane edges can dial in.
let mut driver = IrohDriver::with_engine(
engine.handle(),
IrohDriverConfig {
secret_key: None,
relay_mode: RelayMode::Disabled,
node: DistributedNodeConfig::default(),
peer_auth: None,
additional_alpns: vec![TELEMETRY_ALPN.to_vec(), iroh_driver::EDGE_ALPN.to_vec()],
demo: rename xtask demo command; dashboard-established data-plane edges Rename `cargo xtask provisioning-reconciler-demo` to `cargo xtask demo` (CLI dispatch, help, child re-exec argv, launch spec strings, module dir xtask/src/provisioning_demo -> xtask/src/demo). Add iteration-1 data-plane edges, established from Fleet Control: - Fleet Control "edge" button -> POST /control/edge (new ControlCommand::EstablishEdge) -> supervisor actor resolves the node's advertised EndpointAddr (stashed in NodeRuntime by AnnounceActor) and provisions a real outbound EdgeRuntime (arena ring lease, recorder WorkerPort, EDGE_ALPN send pump) in a new edge pump thread. - Node gains EDGE_ALPN, an actor bridge decoding EdgeProvision gossip, and a NodeEdgeAgent that provisions its (single) inbound edge, polls it, mirrors observations onto the node.edge telemetry channel (render-only), and answers EdgeAck gossip which terminates the supervisor's provision retries. Node teardown replaces its inbound on re-provision; supervisor replaces sessions per node and tears them down on node exit/replacement/shutdown. - The edge pump runs on the engine's blocking pool with sole session ownership (commands in, state mirror + feed lines out): the connect handshake blocks its thread and must not run on a Tokio worker or share a lock with the actor. Connects are bounded (10s) so a dead node faults its session instead of wedging edge polling. - iroh-driver: retain_telemetry_connections() opts an application out of the driver-owned TELEMETRY_ALPN ingress so the node's pull server can drain those connections itself (the actor-bridge pump would otherwise claim them). - Dashboard: edges array in the reconciler snapshot, per-node edge badges and edge button in Fleet Control, node_edges render mirror.
2026-08-16 20:11:20 +00:00
},
)
.map_err(|error| format!("iroh driver: {error}"))?;
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
let node_hex = swactor_transport::hex_encode(&driver.node_id().0);
// Telemetry endpoint: stream identity is the transport node key, so
// every provisioned process lands on its own dashboard stream.
let stream = telemetry::frame::StreamId::new(
telemetry::frame::NodeId::new(&node_hex),
telemetry::frame::Lifetime(1),
);
let endpoint = TelemetryEndpoint::with_descriptor(
telemetry::frame::StreamDescriptor {
stream,
label: Some(format!("demo node {logical_node} runtime")),
origin: telemetry::frame::StreamOrigin::RemoteNode,
},
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
256,
16,
);
let producer = endpoint.producer();
let actors_channel = endpoint.register_channel(
"runtime.actors",
ChannelContent::JsonRecord {
schema: Some("runtime.actors".to_owned()),
},
);
let status_channel = endpoint.register_channel(
"node.status",
ChannelContent::JsonRecord {
schema: Some("demo.node.status.v1".to_owned()),
},
);
let beats_channel = endpoint.register_channel(
"node.beat",
ChannelContent::JsonRecord {
schema: Some("demo.node.beat.v1".to_owned()),
},
);
runtime.set_stats_hook(producer.stats_hook_on(actors_channel));
demo: rename xtask demo command; dashboard-established data-plane edges Rename `cargo xtask provisioning-reconciler-demo` to `cargo xtask demo` (CLI dispatch, help, child re-exec argv, launch spec strings, module dir xtask/src/provisioning_demo -> xtask/src/demo). Add iteration-1 data-plane edges, established from Fleet Control: - Fleet Control "edge" button -> POST /control/edge (new ControlCommand::EstablishEdge) -> supervisor actor resolves the node's advertised EndpointAddr (stashed in NodeRuntime by AnnounceActor) and provisions a real outbound EdgeRuntime (arena ring lease, recorder WorkerPort, EDGE_ALPN send pump) in a new edge pump thread. - Node gains EDGE_ALPN, an actor bridge decoding EdgeProvision gossip, and a NodeEdgeAgent that provisions its (single) inbound edge, polls it, mirrors observations onto the node.edge telemetry channel (render-only), and answers EdgeAck gossip which terminates the supervisor's provision retries. Node teardown replaces its inbound on re-provision; supervisor replaces sessions per node and tears them down on node exit/replacement/shutdown. - The edge pump runs on the engine's blocking pool with sole session ownership (commands in, state mirror + feed lines out): the connect handshake blocks its thread and must not run on a Tokio worker or share a lock with the actor. Connects are bounded (10s) so a dead node faults its session instead of wedging edge polling. - iroh-driver: retain_telemetry_connections() opts an application out of the driver-owned TELEMETRY_ALPN ingress so the node's pull server can drain those connections itself (the actor-bridge pump would otherwise claim them). - Dashboard: edges array in the reconciler snapshot, per-node edge badges and edge button in Fleet Control, node_edges render mirror.
2026-08-16 20:11:20 +00:00
// Data-plane edge agent: owns this node's (single) inbound edge. The
// supervisor provisions it over the control plane (tagged gossip
// decoded by the actor bridge below); observations are mirrored onto
// the `node.edge` telemetry channel (render-only), and readiness
// acks travel back as gossip — never through telemetry.
let edge_channel = endpoint.register_channel(
edge::NODE_EDGE_CHANNEL,
ChannelContent::JsonRecord {
schema: Some("demo.node.edge.v1".to_owned()),
},
);
let driver_slot: Arc<std::sync::OnceLock<Arc<IrohDriver>>> =
Arc::new(std::sync::OnceLock::new());
let edge_agent = runtime
.spawn(edge::NodeEdgeAgent::new(
attempt,
logical_node.clone(),
supervisor_addr.clone(),
driver_slot.clone(),
producer.clone(),
edge_channel,
))
.map_err(|error| format!("spawn edge agent: {error}"))?;
{
use distribution::transport_bridge::{Outbox, RelayMirror, RouteView};
use swactor_transport::CodecRegistry;
let mut codec = CodecRegistry::new();
codec.register_decoder::<edge::NodeEdgeMsg>(edge::EDGE_PROVISION_TAG, |bytes| {
let provision: edge::EdgeProvision = serde_json::from_slice(bytes)
.map_err(|e| swactor::Error::from(format!("edge provision decode: {e}")))?;
Ok(edge::NodeEdgeMsg::Provision(provision))
});
let mut routes = std::collections::HashMap::new();
routes.insert(edge::EDGE_PROVISION_TAG.to_owned(), edge_agent);
let relay_mirror: RelayMirror =
Arc::new(std::sync::RwLock::new(std::collections::HashMap::new()));
let route_view: RouteView =
Arc::new(std::sync::RwLock::new(std::collections::HashMap::new()));
let outbox: Outbox = Arc::new(std::sync::Mutex::new(Vec::new()));
driver.enable_actor_bridge(
runtime.clone(),
Arc::new(codec),
routes,
edge_agent,
relay_mirror,
route_view,
outbox,
);
driver.install_actor_bridge_pump(Duration::from_millis(250));
}
let driver = Arc::new(driver);
let _ = driver_slot.set(Arc::clone(&driver));
// The node serves telemetry pulls itself (spawn_pull_server): keep
// TELEMETRY_ALPN connections out of the driver-owned ingress so the
// serve loop below can drain them.
driver.retain_telemetry_connections();
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
// Join, then announce identity + advertised address to the supervisor's
// bootstrap actor over the control plane (readiness + telemetry dial).
driver.join(&[supervisor_addr.clone()]);
let addr_json =
serde_json::to_string(&driver.endpoint_addr()).map_err(|e| format!("addr: {e}"))?;
// Beat actors: real actors with real message flow, so the node's
// runtime.actors roster (pulled by the supervisor) is visibly alive.
let mut beat_addrs = Vec::new();
for index in 0..BEAT_ACTORS {
let name = format!("beat-{index}");
let producer = producer.clone();
let addr = runtime
.spawn(BeatActor {
name: name.clone(),
producer,
channel: beats_channel,
})
.map_err(|error| format!("spawn beat actor: {error}"))?;
feat(myelin): enforce actor-owned control flow Architecture enforcement: - Install a repository-owned rustc wrapper for ordinary cargo check, build, and test commands. Resolve compiler item identities so renamed imports and helper wrappers cannot hide spawning, timing, blocking, polling, thread, or runtime-driving capabilities. - Define the execution-owner crates and reject dependencies from those substrates back into Myelin policy. Add compile-pass and compile-fail contracts for actor helpers, execution owners, test waits, forbidden capabilities, suppression attempts, and owner dependency inversions. Execution ownership: - Add engine-owned actor timers with cancellation and generation identity, then migrate lifecycle deadlines and protocol ticks off application tasks. Keep networking, process output, telemetry, and blocking provider calls in their approved I/O substrates. - Move process spawn, wait, signal, Unix listener, and output-following mechanics into swactor-process. Isolate Vast.ai blocking HTTP mechanics behind its adapter while actors retain retry, recovery, and provisioning decisions. Myelin control flow: - Rework manual control, worker lifecycle, provisioning, provider recovery, job deployment, distribution, edge orchestration, and shutdown as actor state transitions and typed effects. Preserve durable provider adoption and command outcomes across graceful and abrupt restarts. - Replace controller loops and timer-forwarding tasks with actor messages; leave substrate tasks as cancellable observation streams with no durable policy state. Properties and resource ownership: - Add deterministic engine and component properties, a stateful mock-VastAI lifecycle model, persisted regression cases, controlled fault injection, and a bounded nightly workflow covering restart and teardown behavior. - Terminate reply observers, cancel telemetry collectors, bound dashboard projections, and release child observers, file descriptors, process records, and inode-verified Unix sockets on every terminal path. Verified with the compiler-policy contracts, 105 Myelin library tests, 32 swactor-process tests, telemetry cancellation contracts, randomized stateful restart cases, cargo check, and formatting checks.
2026-08-19 21:38:14 +00:00
beat_addrs.push(addr);
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
}
// Serve telemetry pulls: accepted TELEMETRY_ALPN connections answer with
// this endpoint's subscription stream.
let telemetry_endpoint = Arc::new(endpoint);
feat(myelin): enforce actor-owned control flow Architecture enforcement: - Install a repository-owned rustc wrapper for ordinary cargo check, build, and test commands. Resolve compiler item identities so renamed imports and helper wrappers cannot hide spawning, timing, blocking, polling, thread, or runtime-driving capabilities. - Define the execution-owner crates and reject dependencies from those substrates back into Myelin policy. Add compile-pass and compile-fail contracts for actor helpers, execution owners, test waits, forbidden capabilities, suppression attempts, and owner dependency inversions. Execution ownership: - Add engine-owned actor timers with cancellation and generation identity, then migrate lifecycle deadlines and protocol ticks off application tasks. Keep networking, process output, telemetry, and blocking provider calls in their approved I/O substrates. - Move process spawn, wait, signal, Unix listener, and output-following mechanics into swactor-process. Isolate Vast.ai blocking HTTP mechanics behind its adapter while actors retain retry, recovery, and provisioning decisions. Myelin control flow: - Rework manual control, worker lifecycle, provisioning, provider recovery, job deployment, distribution, edge orchestration, and shutdown as actor state transitions and typed effects. Preserve durable provider adoption and command outcomes across graceful and abrupt restarts. - Replace controller loops and timer-forwarding tasks with actor messages; leave substrate tasks as cancellable observation streams with no durable policy state. Properties and resource ownership: - Add deterministic engine and component properties, a stateful mock-VastAI lifecycle model, persisted regression cases, controlled fault injection, and a bounded nightly workflow covering restart and teardown behavior. - Terminate reply observers, cancel telemetry collectors, bound dashboard projections, and release child observers, file descriptors, process records, and inode-verified Unix sockets on every terminal path. Verified with the compiler-policy contracts, 105 Myelin library tests, 32 swactor-process tests, telemetry cancellation contracts, randomized stateful restart cases, cargo check, and formatting checks.
2026-08-19 21:38:14 +00:00
let completion = ActorCompletion::new();
let runtime_actor = NodeRuntimeActor {
engine: engine.handle(),
sender: runtime.create_sender(),
edge_agent,
driver: Arc::clone(&driver),
attempt,
supervisor_addr,
logical_node,
node_hex,
endpoint_addr_json: addr_json,
endpoint: Arc::clone(&telemetry_endpoint),
producer,
status_channel,
beats: beat_addrs
.into_iter()
.enumerate()
.map(|(index, actor)| (actor, Duration::from_millis(1000 + 500 * index as u64)))
.collect(),
heartbeat_seq: 0,
completion: completion.clone(),
};
let runtime_actor = runtime
.spawn(runtime_actor)
.map_err(|error| format!("spawn node runtime actor: {error}"))?;
let stop_actor = runtime
.spawn(NodeStopForwarder {
sender: runtime.create_sender(),
runtime_actor,
})
.map_err(|error| format!("spawn node stop actor: {error}"))?;
#[cfg(target_os = "linux")]
swactor_process::spawn_os_stop_signal_wait(runtime.create_sender(), stop_actor);
// The actor owns lifecycle; the entrypoint waits only for its terminal signal.
completion.wait();
Ok(())
}
#[derive(Clone, Debug)]
enum NodeRuntimeMsg {
EdgeTick,
Announce,
Heartbeat,
Beat(usize),
PullTelemetry,
Stop,
}
struct NodeRuntimeActor {
engine: EngineHandle,
sender: ExternalSender,
attempt: u64,
edge_agent: ActorAddress,
driver: Arc<IrohDriver>,
supervisor_addr: iroh::EndpointAddr,
logical_node: String,
node_hex: String,
endpoint_addr_json: String,
endpoint: Arc<TelemetryEndpoint>,
producer: TelemetryProducer,
status_channel: telemetry::ChannelId,
beats: Vec<(ActorAddress, Duration)>,
heartbeat_seq: u64,
completion: ActorCompletion<()>,
}
impl NodeRuntimeActor {
fn schedule(&self, ctx: &Ctx, delay: Duration, message: NodeRuntimeMsg) {
self.engine
.send_after(delay, self.sender.clone(), ctx.self_addr(), message);
}
}
impl ActorInterface for NodeRuntimeActor {
type Incoming = NodeRuntimeMsg;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
self.schedule(ctx, Duration::from_millis(250), NodeRuntimeMsg::EdgeTick);
self.schedule(ctx, Duration::ZERO, NodeRuntimeMsg::Announce);
self.schedule(ctx, HEARTBEAT_PERIOD, NodeRuntimeMsg::Heartbeat);
self.schedule(ctx, PULL_POLL_PERIOD, NodeRuntimeMsg::PullTelemetry);
for (index, (_, period)) in self.beats.iter().enumerate() {
self.schedule(ctx, *period, NodeRuntimeMsg::Beat(index));
}
}
fn handle(&mut self, ctx: &Ctx, message: Self::Incoming) {
match message {
NodeRuntimeMsg::EdgeTick => {
let _ = self
.sender
.send_to(self.edge_agent, edge::NodeEdgeMsg::Tick);
self.schedule(ctx, Duration::from_millis(250), NodeRuntimeMsg::EdgeTick);
}
NodeRuntimeMsg::Announce => {
let announce = NodeAnnounce {
attempt: self.attempt,
logical_node: self.logical_node.clone(),
key_hex: self.node_hex.clone(),
endpoint_addr_json: self.endpoint_addr_json.clone(),
at_ms: unix_ms(),
};
if let Ok(bytes) = serde_json::to_vec(&announce) {
self.driver.send_tagged_gossip(
self.supervisor_addr.clone(),
ANNOUNCE_TAG.as_bytes(),
bytes,
);
}
self.schedule(ctx, HEARTBEAT_PERIOD, NodeRuntimeMsg::Announce);
}
NodeRuntimeMsg::Heartbeat => {
self.heartbeat_seq = self.heartbeat_seq.saturating_add(1);
let payload = json!({
"at_ms": unix_ms(),
"node": self.logical_node,
"key": self.node_hex,
"seq": self.heartbeat_seq,
"alive": true,
"pid": std::process::id(),
"beats": BEAT_ACTORS,
});
let bytes = serde_json::to_vec(&payload).expect("status serializes");
self.producer.submit_bytes(self.status_channel, bytes);
self.schedule(ctx, HEARTBEAT_PERIOD, NodeRuntimeMsg::Heartbeat);
}
NodeRuntimeMsg::Beat(index) => {
if let Some((actor, period)) = self.beats.get(index).copied() {
let _ = self.sender.send_to(actor, BeatMsg::Wake);
self.schedule(ctx, period, NodeRuntimeMsg::Beat(index));
}
}
NodeRuntimeMsg::PullTelemetry => {
let _ = self.endpoint.tick();
for (_node, connection) in self.driver.drain_accepted_for_alpn(TELEMETRY_ALPN) {
spawn_pull_server(
&self.engine,
connection,
Arc::clone(&self.endpoint),
WRITER_IDLE,
);
}
self.schedule(ctx, PULL_POLL_PERIOD, NodeRuntimeMsg::PullTelemetry);
}
NodeRuntimeMsg::Stop => {
assert!(
self.completion.complete(()).is_ok(),
"demo node completed twice"
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
);
feat(myelin): enforce actor-owned control flow Architecture enforcement: - Install a repository-owned rustc wrapper for ordinary cargo check, build, and test commands. Resolve compiler item identities so renamed imports and helper wrappers cannot hide spawning, timing, blocking, polling, thread, or runtime-driving capabilities. - Define the execution-owner crates and reject dependencies from those substrates back into Myelin policy. Add compile-pass and compile-fail contracts for actor helpers, execution owners, test waits, forbidden capabilities, suppression attempts, and owner dependency inversions. Execution ownership: - Add engine-owned actor timers with cancellation and generation identity, then migrate lifecycle deadlines and protocol ticks off application tasks. Keep networking, process output, telemetry, and blocking provider calls in their approved I/O substrates. - Move process spawn, wait, signal, Unix listener, and output-following mechanics into swactor-process. Isolate Vast.ai blocking HTTP mechanics behind its adapter while actors retain retry, recovery, and provisioning decisions. Myelin control flow: - Rework manual control, worker lifecycle, provisioning, provider recovery, job deployment, distribution, edge orchestration, and shutdown as actor state transitions and typed effects. Preserve durable provider adoption and command outcomes across graceful and abrupt restarts. - Replace controller loops and timer-forwarding tasks with actor messages; leave substrate tasks as cancellable observation streams with no durable policy state. Properties and resource ownership: - Add deterministic engine and component properties, a stateful mock-VastAI lifecycle model, persisted regression cases, controlled fault injection, and a bounded nightly workflow covering restart and teardown behavior. - Terminate reply observers, cancel telemetry collectors, bound dashboard projections, and release child observers, file descriptors, process records, and inode-verified Unix sockets on every terminal path. Verified with the compiler-policy contracts, 105 Myelin library tests, 32 swactor-process tests, telemetry cancellation contracts, randomized stateful restart cases, cargo check, and formatting checks.
2026-08-19 21:38:14 +00:00
ctx.stop_self();
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
}
}
feat(myelin): enforce actor-owned control flow Architecture enforcement: - Install a repository-owned rustc wrapper for ordinary cargo check, build, and test commands. Resolve compiler item identities so renamed imports and helper wrappers cannot hide spawning, timing, blocking, polling, thread, or runtime-driving capabilities. - Define the execution-owner crates and reject dependencies from those substrates back into Myelin policy. Add compile-pass and compile-fail contracts for actor helpers, execution owners, test waits, forbidden capabilities, suppression attempts, and owner dependency inversions. Execution ownership: - Add engine-owned actor timers with cancellation and generation identity, then migrate lifecycle deadlines and protocol ticks off application tasks. Keep networking, process output, telemetry, and blocking provider calls in their approved I/O substrates. - Move process spawn, wait, signal, Unix listener, and output-following mechanics into swactor-process. Isolate Vast.ai blocking HTTP mechanics behind its adapter while actors retain retry, recovery, and provisioning decisions. Myelin control flow: - Rework manual control, worker lifecycle, provisioning, provider recovery, job deployment, distribution, edge orchestration, and shutdown as actor state transitions and typed effects. Preserve durable provider adoption and command outcomes across graceful and abrupt restarts. - Replace controller loops and timer-forwarding tasks with actor messages; leave substrate tasks as cancellable observation streams with no durable policy state. Properties and resource ownership: - Add deterministic engine and component properties, a stateful mock-VastAI lifecycle model, persisted regression cases, controlled fault injection, and a bounded nightly workflow covering restart and teardown behavior. - Terminate reply observers, cancel telemetry collectors, bound dashboard projections, and release child observers, file descriptors, process records, and inode-verified Unix sockets on every terminal path. Verified with the compiler-policy contracts, 105 Myelin library tests, 32 swactor-process tests, telemetry cancellation contracts, randomized stateful restart cases, cargo check, and formatting checks.
2026-08-19 21:38:14 +00:00
}
}
struct NodeStopForwarder {
sender: ExternalSender,
runtime_actor: ActorAddress,
}
impl ActorInterface for NodeStopForwarder {
type Incoming = swactor_process::ProcessStopSignal;
type Response = ();
feat(myelin): enforce actor-owned control flow Architecture enforcement: - Install a repository-owned rustc wrapper for ordinary cargo check, build, and test commands. Resolve compiler item identities so renamed imports and helper wrappers cannot hide spawning, timing, blocking, polling, thread, or runtime-driving capabilities. - Define the execution-owner crates and reject dependencies from those substrates back into Myelin policy. Add compile-pass and compile-fail contracts for actor helpers, execution owners, test waits, forbidden capabilities, suppression attempts, and owner dependency inversions. Execution ownership: - Add engine-owned actor timers with cancellation and generation identity, then migrate lifecycle deadlines and protocol ticks off application tasks. Keep networking, process output, telemetry, and blocking provider calls in their approved I/O substrates. - Move process spawn, wait, signal, Unix listener, and output-following mechanics into swactor-process. Isolate Vast.ai blocking HTTP mechanics behind its adapter while actors retain retry, recovery, and provisioning decisions. Myelin control flow: - Rework manual control, worker lifecycle, provisioning, provider recovery, job deployment, distribution, edge orchestration, and shutdown as actor state transitions and typed effects. Preserve durable provider adoption and command outcomes across graceful and abrupt restarts. - Replace controller loops and timer-forwarding tasks with actor messages; leave substrate tasks as cancellable observation streams with no durable policy state. Properties and resource ownership: - Add deterministic engine and component properties, a stateful mock-VastAI lifecycle model, persisted regression cases, controlled fault injection, and a bounded nightly workflow covering restart and teardown behavior. - Terminate reply observers, cancel telemetry collectors, bound dashboard projections, and release child observers, file descriptors, process records, and inode-verified Unix sockets on every terminal path. Verified with the compiler-policy contracts, 105 Myelin library tests, 32 swactor-process tests, telemetry cancellation contracts, randomized stateful restart cases, cargo check, and formatting checks.
2026-08-19 21:38:14 +00:00
fn handle(&mut self, ctx: &Ctx, _message: Self::Incoming) {
let _ = self
.sender
.send_to(self.runtime_actor, NodeRuntimeMsg::Stop);
ctx.stop_self();
}
}
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
/// Message that keeps a beat actor's mailbox flowing (and therefore its
/// `runtime.actors` stats current).
#[derive(Clone, Debug)]
pub enum BeatMsg {
Wake,
}
/// A deliberately trivial actor whose only job is existing: every Wake it
/// submits one `node.beat` record, and the runtime stats hook records its
/// productive tick on `runtime.actors`.
pub struct BeatActor {
name: String,
producer: TelemetryProducer,
channel: telemetry::ChannelId,
}
fn unix_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
.unwrap_or(0)
}
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
impl ActorInterface for BeatActor {
type Incoming = BeatMsg;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: BeatMsg) {
let payload = json!({
"at_ms": unix_ms(),
"actor": self.name,
});
let bytes = serde_json::to_vec(&payload).expect("beat serializes");
self.producer.submit_bytes(self.channel, bytes);
}
}
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
/// Arm the kernel's parent-death signal so a hard-killed supervisor
/// (SIGKILL, crash, wedged teardown) cannot leave this node orphaned and
/// parked forever. The `parent_id` check closes the fork/prctl race: if the
/// supervisor died before the prctl landed, the signal would never fire.
/// Inside a container the node is PID 1 (parent 0), so the check passes and
/// the prctl is a harmless no-op — the docker bootstrap's `docker rm -f`
/// owns termination there.
#[cfg(target_os = "linux")]
fn install_parent_death_signal() -> Result<(), String> {
unsafe {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) != 0 {
return Err(format!(
"prctl(PR_SET_PDEATHSIG): {}",
std::io::Error::last_os_error()
));
}
}
provisioning-reconciler-demo: wire-announce readiness + --docker node kind Replace the per-attempt key-file side channel (DEMO_NODE_KEY_FILE, read_key_report, JoinCheck) with a control-plane announce: node roles send a tagged gossip frame {attempt, logical_node, key_hex, endpoint_addr_json} after joining and every heartbeat thereafter. - iroh-driver: IrohDriver::send_tagged_gossip — fire-and-forget tag-routed gossip egress for bridge-less clients (reuses cached/join connections, dials with backoff). - provisioning: BootstrapMsg::Announce — first delivery while bootstrapping completes the attempt (collector + exactly-once Bootstrapped report); duplicates, misrouted attempts, and terminal-phase announces drop. Unit-tested. - xtask demo: AnnounceActor decodes the tag-routed frame and forwards by attempt to the owning bootstrap actor; last_announce_ms is the wire heartbeat. LocalProcessLogic keeps only process lifecycle. - --docker: DockerProcessLogic (kind "docker") — attached "docker run --rm" child on a per-run labeled bridge network (foreign-node masking: per-container IPs, gateway-dialed supervisor). Standalone scratch image from the static-musl xtask binary (37MB), staged one-file build context. The container is force-removed on every terminal path so a SIGKILLed docker CLI cannot orphan a running container. - Cleanup: no volumes/mounts; label-filtered exit sweep (SIGINT and SIGTERM both drain first) + startup sweep of stale demo resources; images persist per run token. Verified live: process kind (kill -> replacement in 3.4s, provision/ remove/kill waves, zero orphans) and docker kind (8-node abuse waves across docker kill, mid-provision control kills, CLI SIGKILL orphans force-removed, SIGKILL-crash leftovers swept on restart, clean exits leave zero containers/networks/CLIs). provisioning 22 + iroh-driver 13 tests pass.
2026-08-16 16:30:52 +00:00
if std::os::unix::process::parent_id() == 1 {
return Err("supervisor exited before node startup".to_owned());
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn install_parent_death_signal() -> Result<(), String> {
// Non-Linux builds rely on the supervisor's graceful teardown path.
Ok(())
}
feat(myelin): enforce actor-owned control flow Architecture enforcement: - Install a repository-owned rustc wrapper for ordinary cargo check, build, and test commands. Resolve compiler item identities so renamed imports and helper wrappers cannot hide spawning, timing, blocking, polling, thread, or runtime-driving capabilities. - Define the execution-owner crates and reject dependencies from those substrates back into Myelin policy. Add compile-pass and compile-fail contracts for actor helpers, execution owners, test waits, forbidden capabilities, suppression attempts, and owner dependency inversions. Execution ownership: - Add engine-owned actor timers with cancellation and generation identity, then migrate lifecycle deadlines and protocol ticks off application tasks. Keep networking, process output, telemetry, and blocking provider calls in their approved I/O substrates. - Move process spawn, wait, signal, Unix listener, and output-following mechanics into swactor-process. Isolate Vast.ai blocking HTTP mechanics behind its adapter while actors retain retry, recovery, and provisioning decisions. Myelin control flow: - Rework manual control, worker lifecycle, provisioning, provider recovery, job deployment, distribution, edge orchestration, and shutdown as actor state transitions and typed effects. Preserve durable provider adoption and command outcomes across graceful and abrupt restarts. - Replace controller loops and timer-forwarding tasks with actor messages; leave substrate tasks as cancellable observation streams with no durable policy state. Properties and resource ownership: - Add deterministic engine and component properties, a stateful mock-VastAI lifecycle model, persisted regression cases, controlled fault injection, and a bounded nightly workflow covering restart and teardown behavior. - Terminate reply observers, cancel telemetry collectors, bound dashboard projections, and release child observers, file descriptors, process records, and inode-verified Unix sockets on every terminal path. Verified with the compiler-policy contracts, 105 Myelin library tests, 32 swactor-process tests, telemetry cancellation contracts, randomized stateful restart cases, cargo check, and formatting checks.
2026-08-19 21:38:14 +00:00
#[cfg(test)]
mod properties {
use proptest::prelude::*;
use swactor::config::RuntimeConfig;
use swactor::runtime::RuntimeParts;
use swactor_engine::{ActorCompletion, Engine, SteppingBackend};
use super::*;
#[derive(Clone, Debug)]
enum NodeAction {
Announce,
Heartbeat,
EdgeTick,
PullTelemetry,
Stop,
}
fn node_actions() -> impl Strategy<Value = Vec<NodeAction>> {
prop::collection::vec(
prop_oneof![
3 => Just(NodeAction::Announce),
4 => Just(NodeAction::Heartbeat),
2 => Just(NodeAction::EdgeTick),
1 => Just(NodeAction::PullTelemetry),
],
0..=28,
)
.prop_map(|mut generated| {
let mut actions = vec![NodeAction::Announce, NodeAction::Heartbeat];
actions.append(&mut generated);
actions.extend([NodeAction::Stop, NodeAction::Stop]);
actions
})
}
fn drive(backend: &SteppingBackend, steps: usize) {
for _ in 0..steps {
backend.step();
}
}
fn check_node_invariants(
expected_heartbeats: usize,
observed_heartbeats: usize,
active_actors: usize,
final_actors: usize,
worker_panics: u64,
) -> Result<(), String> {
if observed_heartbeats != expected_heartbeats {
return Err(format!(
"heartbeat mismatch: expected={expected_heartbeats} observed={observed_heartbeats}"
));
}
if active_actors != 2 {
return Err(format!(
"one node identity must own runtime+stop actors: observed={active_actors}"
));
}
if final_actors != 0 {
return Err(format!(
"node actors did not return to baseline: observed={final_actors}"
));
}
if worker_panics != 0 {
return Err(format!(
"node actor worker panicked {worker_panics} time(s)"
));
}
Ok(())
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 128,
max_shrink_iters: 2_000,
..ProptestConfig::default()
})]
#[test]
fn generated_node_runtime_transitions_emit_heartbeats_and_stop_once(
actions in node_actions(),
attempt in any::<u64>(),
) {
let mut config = RuntimeConfig::default();
config.worker_count = 1;
let parts = RuntimeParts::new(config);
let runtime = parts.runtime().clone();
let backend = SteppingBackend::new();
let engine =
Engine::new(parts, backend.clone()).expect("one-worker node stepping engine");
let driver = crate::demo::shared_test_driver();
let supervisor_addr = driver.endpoint_addr();
let endpoint = Arc::new(TelemetryEndpoint::with_descriptor(
telemetry::frame::StreamDescriptor {
stream: telemetry::frame::StreamId::new(
telemetry::frame::NodeId::new(format!("generated-node-{attempt}")),
telemetry::frame::Lifetime(1),
),
label: Some("generated demo node".to_owned()),
origin: telemetry::frame::StreamOrigin::RemoteNode,
},
128,
64,
));
let producer = endpoint.producer();
let status_channel = endpoint.register_channel(
"node.status",
ChannelContent::JsonRecord {
schema: Some("demo.node.status.v1".to_owned()),
},
);
let status_frames = endpoint.subscribe_all("generated-node-property");
let edge_inbox = runtime
.new_inbox::<edge::NodeEdgeMsg>()
.expect("create generated node edge inbox");
let completion = ActorCompletion::new();
let runtime_actor = runtime
.spawn(NodeRuntimeActor {
engine: engine.handle(),
sender: runtime.create_sender(),
attempt,
edge_agent: *edge_inbox.addr(),
driver: Arc::clone(&driver),
supervisor_addr,
logical_node: format!("node-{attempt}"),
node_hex: format!("{attempt:016x}"),
endpoint_addr_json: serde_json::to_string(&driver.endpoint_addr())
.expect("serialize generated node endpoint"),
endpoint: Arc::clone(&endpoint),
producer,
status_channel,
beats: Vec::new(),
heartbeat_seq: 0,
completion: completion.clone(),
})
.expect("spawn generated node runtime actor");
let stop_actor = runtime
.spawn(NodeStopForwarder {
sender: runtime.create_sender(),
runtime_actor,
})
.expect("spawn generated node stop actor");
drive(&backend, 8);
let active_stats = runtime.stats();
let mut expected_heartbeats = 0;
for action in actions
.iter()
.take(actions.len().saturating_sub(2))
{
let message = match action {
NodeAction::Announce => NodeRuntimeMsg::Announce,
NodeAction::Heartbeat => {
expected_heartbeats += 1;
NodeRuntimeMsg::Heartbeat
}
NodeAction::EdgeTick => NodeRuntimeMsg::EdgeTick,
NodeAction::PullTelemetry => NodeRuntimeMsg::PullTelemetry,
NodeAction::Stop => unreachable!("stop actions are the bounded suffix"),
};
runtime
.send_to(runtime_actor, message)
.expect("send generated node action");
drive(&backend, 4);
}
runtime
.send_to(stop_actor, swactor_process::ProcessStopSignal)
.expect("send first generated OS stop signal");
runtime
.send_to(stop_actor, swactor_process::ProcessStopSignal)
.expect("send repeated generated OS stop signal");
drive(&backend, 32);
prop_assert!(
completion.complete(()).is_err(),
"node completion remained pending; actions={actions:?} active={active_stats:?}"
);
completion.wait();
endpoint.tick();
let observed_heartbeats = status_frames
.drain_available()
.into_iter()
.filter(|event| {
matches!(
event,
telemetry::frame::TelemetryEvent::Frame(delivery)
if delivery.channel.channel == status_channel
)
})
.count();
let final_stats = runtime.stats();
let worker_panics = final_stats
.workers
.iter()
.map(|worker| worker.panics)
.sum::<u64>();
prop_assert!(
check_node_invariants(
expected_heartbeats,
observed_heartbeats,
active_stats.actors.len(),
final_stats.actors.len(),
worker_panics,
)
.is_ok(),
"node transition invariant failed; actions={actions:?} attempt={attempt} \
expected_heartbeats={expected_heartbeats} observed_heartbeats={observed_heartbeats} \
active={active_stats:?} final={final_stats:?}"
);
}
}
#[test]
fn node_transition_oracle_rejects_duplicate_resources() {
let rejected = check_node_invariants(3, 3, 3, 0, 0);
assert!(
rejected.is_err(),
"node property oracle accepted a controlled duplicate actor resource"
);
}
}