diff --git a/Cargo.lock b/Cargo.lock index d1e1c1e..1e7fdc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4346,8 +4346,6 @@ dependencies = [ "crossbeam-queue", "datastream", "libc", - "proptest", - "proptest-state-machine", "serde", "serde_json", "serde_yaml", diff --git a/crates/datastream/src/emit.rs b/crates/datastream/src/emit.rs index 290b22b..80e1aec 100644 --- a/crates/datastream/src/emit.rs +++ b/crates/datastream/src/emit.rs @@ -89,6 +89,7 @@ impl DatastreamEmitter { self.mux.submit(channel, text.into_bytes()) } + /// Build a legacy/custom process-output observer that writes chunks into this mux. pub fn process_observer_with(&self, channel_for: F) -> Arc where F: Fn(&str, bool) -> ChannelId + Send + Sync + 'static, diff --git a/crates/datastream/src/endpoint.rs b/crates/datastream/src/endpoint.rs index c317402..5ca8721 100644 --- a/crates/datastream/src/endpoint.rs +++ b/crates/datastream/src/endpoint.rs @@ -718,7 +718,7 @@ fn register_channel( Ok(id) } -/// Managed-process output observer that submits stdout/stderr chunks as frames. +/// Legacy/custom process-output observer adapter that submits stdout/stderr chunks as frames. pub struct DatastreamProcessObserver { producer: DatastreamProducer, channel_for: Arc ChannelId + Send + Sync>, diff --git a/crates/process/Cargo.toml b/crates/process/Cargo.toml index fc1da20..9bce661 100644 --- a/crates/process/Cargo.toml +++ b/crates/process/Cargo.toml @@ -5,12 +5,9 @@ edition = "2024" [dependencies] swactor = { path = "../..", default-features = false, features = ["no_random"] } +datastream = { path = "../datastream" } crossbeam-queue = "0.3.12" libc = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" - -[dev-dependencies] -proptest = "1" -proptest-state-machine = "0.3" diff --git a/crates/process/SWACTOR_MANAGED_PROCESS_SPEC.md b/crates/process/SWACTOR_MANAGED_PROCESS_SPEC.md new file mode 100644 index 0000000..c715f93 --- /dev/null +++ b/crates/process/SWACTOR_MANAGED_PROCESS_SPEC.md @@ -0,0 +1,327 @@ +# Swactor Managed Process Specification + +**Status:** current contract for `swactor-process`. + +`swactor-process` provides a Swactor actor interface for launching, supervising, +stopping, and observing one operating-system child process per process actor. + +The crate owns process lifecycle/control only. Child stdin/stdout/stderr are not +managed or observed by this crate. + +--- + +## 1. Public API + +The crate exports: + +```text +ProcessSpec +ProcessLifecycleObservability +ProcessOutputConfig +ProcessCommand +ProcessOutput +ExitStatus +spawn_local_process +send_process_command +``` + +Pipeline and YAML exports are separate crate features and are not part of the +managed-process protocol described here. + +### 1.1 ProcessSpec + +```text +ProcessSpec { + command: String, + args: Vec, + env: HashMap, + working_dir: Option, + label: Option, +} +``` + +`command` is passed directly to `std::process::Command::new`. + +`args` are passed as direct argv entries. The crate does not split, quote, +unquote, expand, or shell-parse argument strings. + +`env` contains child environment overrides. + +`working_dir`, when present, is passed as the child current working directory. + +`label`, when present, is the lifecycle datastream label source. When `label` is +absent, the label source is the basename of `command`. + +### 1.2 ProcessOutputConfig + +```text +ProcessOutputConfig::disabled(upstream: ActorAddress) -> ProcessOutputConfig +ProcessOutputConfig::datastream_mirror( + upstream: ActorAddress, + producer: DatastreamProducer, +) -> ProcessOutputConfig +ProcessOutputConfig::upstream(&self) -> ActorAddress +ProcessOutputConfig::observability(&self) -> ProcessLifecycleObservability +``` + +`upstream` is the actor address that receives every public `ProcessOutput`. + +`disabled` sends only upstream `ProcessOutput`. + +`datastream_mirror` sends upstream `ProcessOutput` and also mirrors each +lifecycle/control output to one datastream channel. + +### 1.3 ProcessLifecycleObservability + +```text +ProcessLifecycleObservability::Disabled +ProcessLifecycleObservability::DatastreamMirror +``` + +This setting controls lifecycle/control mirroring only. It does not enable child +stdin/stdout/stderr handling. + +### 1.4 ProcessCommand + +```text +ProcessCommand::Stop { + kill_after: Option, +} +``` + +`Stop` asks the supervisor to terminate the child process. `kill_after`, when +present, is the grace duration before kill escalation. + +### 1.5 ProcessOutput + +```text +ProcessOutput::Started { pid: u32 } +ProcessOutput::SpawnFailed { error: String } +ProcessOutput::Exited { status: ExitStatus } +ProcessOutput::Error { error: String } +``` + +`Started` means the OS child spawned and `pid` is the child process id. + +`SpawnFailed` means the process actor was created but the OS child did not spawn. + +`Exited` means the OS child reached a terminal status. + +`Error` means the process supervisor or process actor hit an operational failure +other than OS spawn failure. + +### 1.6 ExitStatus + +```text +ExitStatus::Code(i32) +ExitStatus::Signal(i32) +ExitStatus::Unknown +``` + +### 1.7 Spawn and command helpers + +```text +spawn_local_process( + ctx: &Ctx, + sender: &ExternalSender, + spec: ProcessSpec, + output: ProcessOutputConfig, +) -> Result +``` + +`spawn_local_process` validates lifecycle output configuration, creates one +process actor, wires its private supervisor wake path, and returns the process +actor address. + +Success from `spawn_local_process` means the process actor was created. It does +not mean the OS child spawned successfully. OS spawn success or failure is +reported later as `ProcessOutput`. + +```text +send_process_command( + sender: &ExternalSender, + process: ActorAddress, + command: ProcessCommand, +) -> Result<(), Error> +``` + +`send_process_command` is the public helper for sending process commands. It +wraps the public `ProcessCommand` in the actor's private mailbox type. + +--- + +## 2. Runtime topology + +One process actor owns one OS child process lifecycle. + +The actor owns: + +- lifecycle state; +- the configured upstream output address; +- optional lifecycle datastream mirror state; +- a private supervisor thread handle. + +The private supervisor thread owns: + +- the child process handle and pid; +- blocking-prone child exit polling; +- terminate/kill signal delivery; +- the stop kill deadline. + +The child process owns its own execution. + +The supervisor thread is not a public actor and not a public extension point. + +--- + +## 3. Child spawn behavior + +The supervisor starts the child with: + +```text +Command::new(&spec.command) +cmd.args(&spec.args) +cmd.env(key, value) for each spec.env entry +cmd.current_dir(dir) when spec.working_dir is Some(dir) +cmd.stdin(Stdio::null()) +cmd.stdout(Stdio::null()) +cmd.stderr(Stdio::null()) +cmd.spawn() +``` + +The crate does not invoke a shell unless the caller explicitly sets `command` to +a shell executable and supplies shell arguments. + +Child stdin/stdout/stderr are connected to null handles. The managed-process +protocol does not expose stdin writes, stdout/stderr output events, PTY resize, +or arbitrary signal commands. + +If `cmd.spawn()` fails, the actor emits exactly one terminal +`ProcessOutput::SpawnFailed { error }` and does not emit `Started`, `Exited`, or +`Error` for that spawn failure. + +--- + +## 4. Lifecycle output delivery + +The actor sends each public `ProcessOutput` to the configured upstream actor. + +When `ProcessOutputConfig::datastream_mirror` is used, the actor also mirrors +each output to the configured datastream producer. Datastream submit failure is +ignored and does not suppress upstream output or emit `ProcessOutput::Error`. + +Public lifecycle/control output order follows observed lifecycle: + +- successful spawn emits `Started` before any terminal `Exited`; +- spawn failure emits `SpawnFailed` without `Started` or `Exited`; +- supervisor failure emits `Error`; +- after a terminal output, later public stop attempts emit no additional + `ProcessOutput`. + +--- + +## 5. Lifecycle datastream labels and records + +The label source is: + +1. `ProcessSpec::label`, when present; +2. otherwise, the final non-empty path segment of `ProcessSpec::command`; +3. otherwise, the full `command` string. + +The label sanitizer: + +- trims source whitespace; +- lowercases ASCII alphanumeric characters; +- preserves `_` and `-`; +- replaces every other character with `_`; +- collapses repeated `_`; +- trims leading and trailing `_`; +- rejects an empty sanitized result with: + +```text +invalid process lifecycle label: empty segment +``` + +The lifecycle channel name is: + +```text +proc.