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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to `devloop` will be recorded in this file.

## [Unreleased]

## [0.10.4] - 2026-09-01

### Fixed

- Made startup stop with a clear non-zero error when a managed process's local
readiness address is already occupied, without restarting the failed process
or disturbing the existing listener. Concurrent sessions remain supported
when they use different ports.

## [0.10.3] - 2026-08-26

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "devloop"
version = "0.10.3"
version = "0.10.4"
edition = "2024"

[dependencies]
Expand Down
13 changes: 12 additions & 1 deletion docs/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ When `devloop run` starts, it:
7. runs each workflow named in `startup_workflows` in order
8. starts watching the configured `root`

Before starting a managed process with a loopback HTTP readiness probe,
`devloop` checks whether it can bind that process-owned readiness address. An
occupied address, including one that is bound but not yet accepting
connections, stops startup with a non-zero error that names the process and
address. The colliding session does not enter its restart loop or disturb the
existing listener. This check is per configured address: concurrent sessions
and worktrees remain independent when they use different ports.

The in-memory session state is authoritative for the running process.
Edits made directly to the JSON file while `devloop` is running are not
merged back into the live session.
Expand Down Expand Up @@ -124,7 +132,10 @@ Managed processes are long-running child commands.
process stops or exits, so a failed dependency cannot leave a stale URL or
other process-derived value available to later workflows.
- `restart = "always"` restarts a child after any exit unless
`devloop` is shutting down.
`devloop` is shutting down. A child that exits while a startup workflow is
waiting for its first readiness result is treated as a failed start and is
not restarted before that startup failure terminates the session. Later
runtime workflows preserve the configured restart policy.
- `restart = "on_failure"` restarts only after unsuccessful exit.
- `restart = "never"` never restarts automatically.
- Restart policies use the managed command's exit status. A wrapper that
Expand Down
20 changes: 17 additions & 3 deletions src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ pub enum RuntimeEffect {
RunWorkflow {
workflow_name: String,
changed_files: Vec<String>,
origin: WorkflowRunOrigin,
},
StartWatching,
MaintainProcesses,
Expand All @@ -115,6 +116,12 @@ pub enum RuntimeEffect {
Exit,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkflowRunOrigin {
Startup,
Runtime,
}

#[derive(Debug, Clone)]
struct ObservedHookRuntimeState {
workflow_name: String,
Expand Down Expand Up @@ -371,6 +378,7 @@ impl RuntimeMachine {
self.pending_effects.push_back(RuntimeEffect::RunWorkflow {
workflow_name,
changed_files: Vec::new(),
origin: WorkflowRunOrigin::Startup,
});
}
self.pending_effects.push_back(RuntimeEffect::StartWatching);
Expand All @@ -384,6 +392,7 @@ impl RuntimeMachine {
self.pending_effects.push_back(RuntimeEffect::RunWorkflow {
workflow_name,
changed_files,
origin: WorkflowRunOrigin::Runtime,
});
}
}
Expand Down Expand Up @@ -413,6 +422,7 @@ impl RuntimeMachine {
self.pending_effects.push_front(RuntimeEffect::RunWorkflow {
workflow_name,
changed_files: Vec::new(),
origin: WorkflowRunOrigin::Runtime,
});
}
RuntimeEvent::CtrlC => {
Expand Down Expand Up @@ -827,14 +837,16 @@ mod tests {
runtime.next_effect(),
Some(RuntimeEffect::RunWorkflow {
workflow_name: "startup".into(),
changed_files: Vec::new()
changed_files: Vec::new(),
origin: WorkflowRunOrigin::Startup,
})
);
assert_eq!(
runtime.next_effect(),
Some(RuntimeEffect::RunWorkflow {
workflow_name: "publish".into(),
changed_files: Vec::new()
changed_files: Vec::new(),
origin: WorkflowRunOrigin::Startup,
})
);
assert_eq!(runtime.next_effect(), Some(RuntimeEffect::StartWatching));
Expand Down Expand Up @@ -890,7 +902,8 @@ mod tests {
runtime.next_effect(),
Some(RuntimeEffect::RunWorkflow {
workflow_name: "rust".into(),
changed_files: vec!["src/main.rs".into()]
changed_files: vec!["src/main.rs".into()],
origin: WorkflowRunOrigin::Runtime,
})
);
assert_eq!(
Expand Down Expand Up @@ -967,6 +980,7 @@ mod tests {
Some(RuntimeEffect::RunWorkflow {
workflow_name: "publish_post_url".into(),
changed_files: Vec::new(),
origin: WorkflowRunOrigin::Runtime,
})
);
assert_eq!(runtime.next_effect(), None);
Expand Down
95 changes: 87 additions & 8 deletions src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use anyhow::{Result, anyhow};
use anyhow::{Context, Result, anyhow};
use notify::{
Config as NotifyConfig, Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode,
Watcher,
Expand All @@ -18,9 +18,11 @@ use unicode_width::UnicodeWidthStr;

use crate::browser_reload::{BrowserReloadSender, BrowserReloadServer, notify_browser_reload};
use crate::config::{CompiledWatchGroup, CompiledWatchTarget, Config, LogStyle, WatcherKind};
use crate::core::{RuntimeEffect, RuntimeEvent, RuntimeMachine, WorkflowEffect, WorkflowMachine};
use crate::core::{
RuntimeEffect, RuntimeEvent, RuntimeMachine, WorkflowEffect, WorkflowMachine, WorkflowRunOrigin,
};
use crate::external_events::{ExternalEventMessage, ExternalEventServer};
use crate::processes::ProcessManager;
use crate::processes::{ManagedAddressInUse, ProcessManager};
use crate::session_log::SessionLog;
use crate::state::SessionState;
use devloop::process_guardian::GuardianExecutable;
Expand Down Expand Up @@ -54,7 +56,12 @@ trait RuntimeEffectAdapter {
async fn start_external_event_server(&mut self) -> Result<()>;
async fn start_browser_reload_server(&mut self) -> Result<()>;
async fn start_autostart_processes(&mut self) -> Result<()>;
async fn run_workflow(&mut self, workflow_name: &str, changed_files: &[String]) -> Result<()>;
async fn run_workflow(
&mut self,
workflow_name: &str,
changed_files: &[String],
origin: WorkflowRunOrigin,
) -> Result<()>;
async fn start_watching(&mut self) -> Result<()>;
async fn maintain_processes(&mut self) -> Result<()>;
async fn poll_observed_hook(&mut self, hook: &str) -> Result<bool>;
Expand All @@ -67,6 +74,7 @@ struct LiveWorkflowAdapter<'a, 'b> {
processes: &'a mut ProcessManager<'b>,
state: &'a SessionState,
browser_reload_sender: Option<BrowserReloadSender>,
restart_after_failed_start: bool,
}

struct LiveRuntimeAdapter<'a, 'b> {
Expand Down Expand Up @@ -223,7 +231,9 @@ impl WorkflowEffectAdapter for LiveWorkflowAdapter<'_, '_> {
}

async fn wait_for_process(&mut self, process: &str) -> Result<()> {
self.processes.wait_for_named(process, self.state).await
self.processes
.wait_for_named(process, self.state, self.restart_after_failed_start)
.await
}

async fn run_hook(
Expand Down Expand Up @@ -302,7 +312,12 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> {
self.processes.start_autostart(self.state).await
}

async fn run_workflow(&mut self, workflow_name: &str, changed_files: &[String]) -> Result<()> {
async fn run_workflow(
&mut self,
workflow_name: &str,
changed_files: &[String],
origin: WorkflowRunOrigin,
) -> Result<()> {
info!("running workflow {}", workflow_name);
let mut adapter = LiveWorkflowAdapter {
processes: self.processes,
Expand All @@ -311,6 +326,7 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> {
.browser_reload_server
.as_ref()
.map(BrowserReloadServer::sender),
restart_after_failed_start: origin == WorkflowRunOrigin::Runtime,
};
execute_workflow(self.config, &mut adapter, workflow_name, changed_files).await
}
Expand Down Expand Up @@ -409,12 +425,32 @@ async fn execute_runtime_effects<A: RuntimeEffectAdapter>(
RuntimeEffect::StartBrowserReloadServer => {
adapter.start_browser_reload_server().await?
}
RuntimeEffect::StartAutostartProcesses => adapter.start_autostart_processes().await?,
RuntimeEffect::StartAutostartProcesses => {
if let Err(error) = adapter.start_autostart_processes().await {
adapter
.stop_all_processes()
.await
.context("failed to clean up after autostart failure")?;
return Err(error);
}
}
RuntimeEffect::RunWorkflow {
workflow_name,
changed_files,
origin,
} => {
if let Err(error) = adapter.run_workflow(&workflow_name, &changed_files).await {
if let Err(error) = adapter
.run_workflow(&workflow_name, &changed_files, origin)
.await
{
if origin == WorkflowRunOrigin::Startup
&& error.downcast_ref::<ManagedAddressInUse>().is_some()
{
adapter.stop_all_processes().await.with_context(|| {
format!("failed to clean up after startup workflow '{workflow_name}'")
})?;
return Err(error);
}
error!(
workflow = %workflow_name,
error = %workflow_failure_chain(&error),
Expand Down Expand Up @@ -496,6 +532,7 @@ async fn run_workflow(
processes,
state,
browser_reload_sender,
restart_after_failed_start: true,
};
execute_workflow(config, &mut adapter, workflow_name, changed_files).await
}
Expand Down Expand Up @@ -1601,6 +1638,7 @@ mod tests {
calls: Vec<String>,
changed_hooks: BTreeMap<String, bool>,
workflow_errors: BTreeMap<String, String>,
workflow_address_conflicts: BTreeSet<String>,
stop_watching_error: Option<String>,
watching: bool,
}
Expand All @@ -1611,6 +1649,7 @@ mod tests {
calls: Vec::new(),
changed_hooks: BTreeMap::new(),
workflow_errors: BTreeMap::new(),
workflow_address_conflicts: BTreeSet::new(),
stop_watching_error: None,
watching: false,
}
Expand Down Expand Up @@ -1642,7 +1681,11 @@ mod tests {
&mut self,
workflow_name: &str,
changed_files: &[String],
_origin: WorkflowRunOrigin,
) -> Result<()> {
if self.workflow_address_conflicts.contains(workflow_name) {
return Err(ManagedAddressInUse::new("server", "127.0.0.1:8787").into());
}
if let Some(message) = self.workflow_errors.get(workflow_name) {
return Err(anyhow!(message.clone()));
}
Expand Down Expand Up @@ -2003,6 +2046,42 @@ mod tests {
assert_eq!(adapter.calls, vec!["persist:root", "autostart", "watch"]);
}

#[tokio::test]
async fn startup_address_collision_stops_and_cleans_up_before_watching() {
let config = Config {
root: PathBuf::from("."),
debounce_ms: 100,
watcher: crate::config::WatcherConfig::default(),
state_file: Some(PathBuf::from("./state.json")),
startup_workflows: vec!["startup".into()],
watch: BTreeMap::new(),
process: BTreeMap::new(),
hook: BTreeMap::new(),
event_server: crate::config::EventServerConfig::default(),
browser_reload_server: crate::config::BrowserReloadServerConfig::default(),
event: BTreeMap::new(),
workflow: BTreeMap::new(),
};
let mut runtime = RuntimeMachine::new(&config);
runtime.handle_event(RuntimeEvent::Start {
root_display: "/tmp/example".into(),
startup_workflows: vec!["startup".into()],
});
let mut adapter = MockRuntimeAdapter::new();
adapter.workflow_address_conflicts.insert("startup".into());

let error = execute_runtime_effects(&mut runtime, &mut adapter)
.await
.expect_err("startup collision must stop the runtime");

assert_eq!(
error.to_string(),
"process 'server' cannot start: address 127.0.0.1:8787 is already in use"
);
assert_eq!(adapter.calls, vec!["persist:root", "autostart", "stop_all"]);
assert!(!adapter.watching);
}

#[tokio::test]
async fn missing_runtime_workflow_returns_error() {
let config = Config {
Expand Down
29 changes: 24 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ mod test_support;

use std::io::{self, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;

use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand, ValueEnum};
use pulldown_cmark::{
CodeBlockKind, Event as MarkdownEvent, HeadingLevel, Parser as MarkdownParser, Tag, TagEnd,
};
use tracing::{Event, Subscriber, error};
use tracing::{Event, Subscriber};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::FmtContext;
use tracing_subscriber::fmt::MakeWriter;
Expand Down Expand Up @@ -77,7 +78,17 @@ enum DocsTopic {
}

#[tokio::main]
async fn main() -> Result<()> {
async fn main() -> ExitCode {
match run_cli().await {
Ok(exit_code) => exit_code,
Err(error) => {
eprintln!("devloop: {error:#}");
ExitCode::FAILURE
}
}
}

async fn run_cli() -> Result<ExitCode> {
let cli = Cli::parse();
match cli.command {
Command::Validate { config } => {
Expand All @@ -102,17 +113,25 @@ async fn main() -> Result<()> {
.run()
.await
{
error!(error = %format!("{error:#}"), "devloop run failed");
report_run_failure(&session_log, &error);
flush_session_log_before_exit(&session_log).await;
return Err(error);
return Ok(ExitCode::FAILURE);
}
flush_session_log_before_exit(&session_log).await;
}
Command::Docs { topic } => {
print!("{}", render_docs_text(topic));
}
}
Ok(())
Ok(ExitCode::SUCCESS)
}

fn report_run_failure(session_log: &SessionLog, error: &anyhow::Error) {
let message = format!("devloop run failed: {error:#}");
if let Err(log_error) = session_log.write_labeled_line("devloop", message.as_bytes()) {
eprintln!("devloop: failed to persist run failure: {log_error}");
}
eprintln!("devloop: {message}");
}

async fn flush_session_log_before_exit(session_log: &SessionLog) {
Expand Down
Loading