Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/linux-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ permissions:

jobs:
rust:
name: Linux
uses: ./.github/workflows/rust-ci-reusable.yml
with:
runner: ubuntu-latest
1 change: 1 addition & 0 deletions .github/workflows/macos-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ permissions:

jobs:
rust:
name: macOS
uses: ./.github/workflows/rust-ci-reusable.yml
with:
runner: macos-latest
5 changes: 3 additions & 2 deletions .github/workflows/release-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
- name: Cache cargo artifacts
uses: Swatinem/rust-cache@v2

- name: Build release binary
- name: Build release binaries
run: cargo build --release --target ${{ inputs.target }}

- name: Package release archive
Expand All @@ -54,7 +54,8 @@ jobs:
set -euo pipefail
mkdir -p dist
cp "target/${{ inputs.target }}/release/devloop" dist/devloop
tar -C dist -czf "${{ inputs.archive_name }}" devloop
cp "target/${{ inputs.target }}/release/devloop-process-guardian" dist/devloop-process-guardian
tar -C dist -czf "${{ inputs.archive_name }}" devloop devloop-process-guardian

- name: Extract release notes from changelog
if: inputs.publish_release_notes
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to `devloop` will be recorded in this file.

## [Unreleased]

## [0.10.1] - 2026-08-26

### Changed

- Gave Linux and macOS CI distinct required-check names so `main`
protection can require both platforms without an ambiguous status.

### Fixed

- Guard every managed process and hook with a pinned Rust companion and
parent-death channel, so an abrupt `devloop` exit kills children,
grandchildren, and deeper descendants that remain in the command's
process group. The companion has a distinct process identity and
stays consistent across in-place installation updates.

## [0.10.0] - 2026-07-23

### Added
Expand Down
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
[package]
name = "devloop"
version = "0.10.0"
version = "0.10.1"
edition = "2024"

[dependencies]
anyhow = "1.0.98"
axum = { version = "0.8.6", features = ["json", "tokio", "http1"] }
clap = { version = "4.5.39", features = ["derive"] }
globset = "0.4.16"
libc = "0.2.177"
notify = "8.0.0"
pulldown-cmark = "0.13.0"
rand = "0.9.2"
Expand All @@ -23,5 +24,8 @@ tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"] }
unicode-width = "0.2"

[target.'cfg(target_os = "macos")'.dependencies]
tempfile = "3.20.0"

[dev-dependencies]
tempfile = "3.20.0"
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ Each supported platform publishes its release asset independently, so a
failure on one platform does not block the other asset from being
attached to the GitHub release.

Each archive contains `devloop` and `devloop-process-guardian`. Install
both executables in the same directory. `cargo install` installs the
pair together.

Supported prebuilt release targets:

- `x86_64-unknown-linux-gnu`
Expand Down
22 changes: 19 additions & 3 deletions docs/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,23 @@ Managed processes are long-running child commands.

- `start_process` is a no-op if the named process is already running.
- `restart_process` stops the child, then starts it again.
- Managed processes are started in their own Unix process group, and
stop/restart/shutdown terminates that group so descendant processes do
not survive the supervisor.
- Every external command, including managed processes and hooks, is
launched in its own Unix process group through an internal Rust
companion process. At run startup, `devloop` opens and retains the
exact companion image, so an in-place installation update cannot
change the guardian protocol for later hooks or restarts. The guardian
remains outside the target group, ignores terminal-oriented signals,
and watches a private lifetime channel owned by `devloop`. Managed
targets restore ordinary signal handling before they start. Normal
stop/restart/shutdown terminates the target group, and abrupt
`devloop` disappearance closes the channel so the guardian kills the
group and reaps its direct target.
Children, grandchildren, and deeper descendants are covered while
they remain in the inherited process group.
- A descendant that deliberately creates a new session or process group
escapes portable Unix process-group containment. Such commands must
provide their own shutdown integration instead of daemonizing beneath
`devloop`.
- `wait_for_process` waits on the configured readiness probe, not just
on successful spawning.
- `restart = "always"` restarts a child after any exit unless
Expand Down Expand Up @@ -135,6 +149,8 @@ the process is restarted.
Hooks are one-shot commands executed inside workflows.

- Hooks run to completion before the workflow continues.
- Hooks use the same guarded process-group lifecycle as managed
processes, including cleanup after abrupt `devloop` termination.
- Hook stdout and stderr are captured fully, then rendered with a source
label if `hook.<name>.output.inherit` is enabled.
- Hook output defaults to `body_style = "dim"` so helper-command output
Expand Down
4 changes: 1 addition & 3 deletions scripts/ci-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ chmod +x "${tmp_dir}/scripts/emit-ready.sh"
state_path="${tmp_dir}/.devloop/state.json"
devloop_bin="${repo_root}/target/debug/devloop"

if [[ ! -x "${devloop_bin}" ]]; then
(cd "${repo_root}" && cargo build >/dev/null)
fi
(cd "${repo_root}" && cargo build --bins >/dev/null)

"${devloop_bin}" run --config "${tmp_dir}/devloop.toml" >"${log_path}" 2>&1 &
devloop_pid=$!
Expand Down
5 changes: 5 additions & 0 deletions src/bin/devloop-process-guardian.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
use anyhow::Result;

fn main() -> Result<()> {
devloop::process_guardian::run_and_exit(std::env::args_os().skip(1).collect())
}
33 changes: 26 additions & 7 deletions src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ use crate::external_events::{ExternalEventMessage, ExternalEventServer};
use crate::processes::ProcessManager;
use crate::session_log::SessionLog;
use crate::state::SessionState;
use devloop::process_guardian::GuardianExecutable;

pub struct Engine {
config: Config,
session_log: SessionLog,
guardian_executable: GuardianExecutable,
}

trait WorkflowEffectAdapter {
Expand Down Expand Up @@ -81,10 +83,15 @@ struct LiveRuntimeAdapter<'a, 'b> {
}

impl Engine {
pub fn new(config: Config, session_log: SessionLog) -> Self {
pub fn new(
config: Config,
session_log: SessionLog,
guardian_executable: GuardianExecutable,
) -> Self {
Self {
config,
session_log,
guardian_executable,
}
}

Expand All @@ -95,8 +102,8 @@ impl Engine {
.clone()
.ok_or_else(|| anyhow!("state file missing after config load"))?,
)?;
let mut processes =
ProcessManager::new(&self.config).with_session_log(self.session_log.clone());
let mut processes = ProcessManager::new(&self.config, self.guardian_executable)
.with_session_log(self.session_log.clone());
let watch_groups = self.config.compiled_watchers()?;
let watched_targets = self.config.compiled_watch_targets();
let ignored_watch_paths = vec![self.session_log.path().to_path_buf()];
Expand Down Expand Up @@ -1028,7 +1035,10 @@ mod tests {
},
);

let mut processes = ProcessManager::new(&config);
let mut processes = ProcessManager::new(
&config,
GuardianExecutable::open().expect("open test guardian"),
);
run_workflow(&config, &mut processes, &state, None, "compose", &[])
.await
.expect("run workflow");
Expand Down Expand Up @@ -1093,7 +1103,10 @@ mod tests {
},
);

let mut processes = ProcessManager::new(&config);
let mut processes = ProcessManager::new(
&config,
GuardianExecutable::open().expect("open test guardian"),
);
run_workflow(&config, &mut processes, &state, None, "content", &[])
.await
.expect("run workflow");
Expand Down Expand Up @@ -1153,7 +1166,10 @@ mod tests {
},
);

let mut processes = ProcessManager::new(&config);
let mut processes = ProcessManager::new(
&config,
GuardianExecutable::open().expect("open test guardian"),
);
run_workflow(&config, &mut processes, &state, None, "announce", &[])
.await
.expect("run workflow");
Expand Down Expand Up @@ -1941,7 +1957,10 @@ mod tests {
};
let state_path = unique_state_path();
let state = SessionState::load(state_path.clone()).expect("load state");
let mut processes = ProcessManager::new(&config);
let mut processes = ProcessManager::new(
&config,
GuardianExecutable::open().expect("open test guardian"),
);

let error = run_workflow(&config, &mut processes, &state, None, "missing", &[])
.await
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod process_guardian;
6 changes: 5 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ async fn main() -> Result<()> {
config.validate()?;
}
Command::Run { config } => {
let guardian_executable = devloop::process_guardian::GuardianExecutable::open()?;
let config = resolve_config_path(config)?;
let config = Config::load(&config)?;
config.validate()?;
Expand All @@ -97,7 +98,10 @@ async fn main() -> Result<()> {
let session_log = SessionLog::create(state_file)?;
init_logging(Some(session_log.clone()));
announce_session_log_path(&session_log).await?;
if let Err(error) = Engine::new(config, session_log.clone()).run().await {
if let Err(error) = Engine::new(config, session_log.clone(), guardian_executable)
.run()
.await
{
error!(error = %format!("{error:#}"), "devloop run failed");
flush_session_log_before_exit(&session_log).await;
return Err(error);
Expand Down
Loading