Compare commits

...

7 commits

Author SHA1 Message Date
Niko Marmeladkov
548b22a55f
fix(daemon): prevent double-start and process-group leaks
- flock-lock the daemon pid file so a second daemon refuses to start,
  avoiding two supervisors spawning the same services
- put each service in its own process group (setpgid) and kill the whole
  group on stop/force-kill, so pipelines/background children of sh -c no
  longer leak as orphans
- SIGKILL leftover group members when a service exits unexpectedly
2026-08-18 18:22:17 +03:00
Niko Marmeladkov
21fc895b0e
fix(attach): reset attributes before drawing input line
When safe_truncate cuts off \x1b[0m from a colorized log line, the
foreground color leaks onto the > prompt. Emit RESET before GRAY_BG
to ensure the input line always starts with a clean slate.
2026-07-16 13:10:55 +03:00
Niko Marmeladkov
2ce7c3688c
fix input truncation: cols-3, remove scroll-past-end condition, drop min(cols-1) clamp 2026-07-15 23:13:20 +03:00
Niko Marmeladkov
b6f1850a24 simplify scroll to bash-like: cursor always at far right, no jump at 90% 2026-07-15 22:31:19 +03:00
Niko Marmeladkov
01d2c4f609
fix scroll at 90%: prevent third condition from undoing threshold scroll 2026-07-15 22:26:51 +03:00
Niko Marmeladkov
b161924bbc
fix cursor tracking: use char indices instead of byte for multi-byte utf-8 support 2026-07-15 22:23:24 +03:00
Niko Marmeladkov
66ca52c187
fix utf-8 backspace crash: use floor_char_boundary instead of byte decrement 2026-07-15 22:19:40 +03:00
3 changed files with 79 additions and 34 deletions

View file

@ -124,22 +124,22 @@ fn follow_log(path: &str, tx: mpsc::Sender<String>) {
}
}
fn adjust_scroll(input: &str, cursor: usize, scroll: &mut usize, cols: usize) {
let max_visible = cols.saturating_sub(2);
fn byte_at(s: &str, char_idx: usize) -> usize {
s.char_indices().nth(char_idx).map(|(i, _)| i).unwrap_or(s.len())
}
fn adjust_scroll(_input: &str, cursor: usize, scroll: &mut usize, cols: usize) {
let max_visible = cols.saturating_sub(3);
if max_visible == 0 {
return;
}
let threshold = (max_visible as f64 * 0.9) as usize;
if cursor > *scroll + threshold {
*scroll = cursor - threshold;
if cursor >= *scroll + max_visible {
*scroll = cursor + 1 - max_visible;
}
if cursor < *scroll {
*scroll = cursor;
}
if *scroll + max_visible > input.len() {
*scroll = input.len().saturating_sub(max_visible);
}
}
#[allow(clippy::too_many_arguments)]
@ -177,19 +177,14 @@ fn draw_all(
}
let prompt = "> ";
let max_visible = cols.saturating_sub(prompt.len());
let visible_start = scroll;
let visible_end = (scroll + max_visible).min(input.len());
let visible = if scroll < input.len() {
&input[visible_start..visible_end]
} else {
""
};
let max_visible = cols.saturating_sub(prompt.len() + 1);
let visible: String = input.chars().skip(scroll).take(max_visible).collect();
out.push_str(&format!("\x1b[{};1H", rows));
out.push_str(RESET);
out.push_str(GRAY_BG);
out.push_str(prompt);
out.push_str(visible);
out.push_str(&visible);
out.push_str("\x1b[K");
out.push_str(RESET);
@ -389,8 +384,8 @@ pub fn run_attach(name: &str, full: bool, stream: UnixStream) {
}
if let Some(idx) = history_idx {
input = history[idx].clone();
cursor = input.len();
scroll = input.len().saturating_sub(cols.saturating_sub(2));
cursor = input.chars().count();
scroll = input.chars().count().saturating_sub(cols.saturating_sub(3));
}
adjust_scroll(&input, cursor, &mut scroll, cols);
}
@ -399,7 +394,7 @@ pub fn run_attach(name: &str, full: bool, stream: UnixStream) {
if idx + 1 < history.len() {
history_idx = Some(idx + 1);
input = history[idx + 1].clone();
cursor = input.len();
cursor = input.chars().count();
} else {
history_idx = None;
input.clear();
@ -467,7 +462,10 @@ fn process_input_byte(
0x08 | 0x7f => {
if *cursor > 0 {
*cursor -= 1;
input.remove(*cursor);
let byte_start = byte_at(input, *cursor);
let c = input[byte_start..].chars().next().unwrap();
let n = c.len_utf8();
input.drain(byte_start..byte_start + n);
}
adjust_scroll(input, *cursor, scroll, cols);
return InputAction::None;
@ -478,7 +476,7 @@ fn process_input_byte(
return InputAction::None;
}
0x05 => {
*cursor = input.len();
*cursor = input.chars().count();
adjust_scroll(input, *cursor, scroll, cols);
return InputAction::None;
}
@ -489,7 +487,7 @@ fn process_input_byte(
return InputAction::None;
}
0x0b => {
input.truncate(*cursor);
input.truncate(byte_at(input, *cursor));
return InputAction::None;
}
_ => {}
@ -506,9 +504,9 @@ fn process_input_byte(
if Some(utf8_buf.len()) == *utf8_expected {
if let Ok(s) = std::str::from_utf8(&utf8_buf) {
for c in s.chars() {
let n = c.len_utf8();
input.insert(*cursor, c);
*cursor += n;
let byte_pos = byte_at(input, *cursor);
input.insert(byte_pos, c);
*cursor += 1;
}
adjust_scroll(input, *cursor, scroll, cols);
}
@ -520,7 +518,8 @@ fn process_input_byte(
*utf8_expected = None;
}
} else if b >= 0x20 {
input.insert(*cursor, b as char);
let byte_pos = byte_at(input, *cursor);
input.insert(byte_pos, b as char);
*cursor += 1;
adjust_scroll(input, *cursor, scroll, cols);
}
@ -550,7 +549,7 @@ fn process_escape(
}
if let Some(idx) = *history_idx {
*input = history[idx].clone();
*cursor = input.len();
*cursor = input.chars().count();
}
adjust_scroll(input, *cursor, scroll, cols);
InputAction::None
@ -561,7 +560,7 @@ fn process_escape(
if idx + 1 < history.len() {
*history_idx = Some(idx + 1);
*input = history[idx + 1].clone();
*cursor = input.len();
*cursor = input.chars().count();
} else {
*history_idx = None;
input.clear();
@ -573,7 +572,8 @@ fn process_escape(
}
b'C' => {
// Right arrow
if *cursor < input.len() {
let nchars = input.chars().count();
if *cursor < nchars {
*cursor += 1;
}
adjust_scroll(input, *cursor, scroll, cols);
@ -593,7 +593,7 @@ fn process_escape(
}
b'F' => {
// End
*cursor = input.len();
*cursor = input.chars().count();
adjust_scroll(input, *cursor, scroll, cols);
InputAction::None
}

View file

@ -39,6 +39,20 @@ pub fn run(config_path: String) {
let _ = nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGINT, &sig_action);
}
// Single-instance guard: fail if another daemon already holds the lock.
// This prevents two supervisors from double-starting the same services.
let _daemon_lock = match state::lock_daemon() {
Ok(f) => f,
Err(e) => {
eprintln!(
"error: another daemon is already running (lock {:?}): {}",
state::daemon_pid_path(),
e
);
return;
}
};
// Write daemon PID
let pid_path = state::daemon_pid_path();
if let Err(e) = state::write_pid(&pid_path) {
@ -185,6 +199,9 @@ fn spawn(name: &str, cfg: &ServiceConfig) -> Option<Proc> {
libc::_exit(1);
}
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
// Put the service in its own process group so we can kill the
// whole tree (sh + grandchildren) instead of leaking orphans.
libc::setpgid(0, 0);
Ok(())
});
}
@ -246,9 +263,10 @@ fn stop(name: &str, procs: &mut HashMap<String, Proc>) {
let _ = Command::new("sh").arg("-c").arg(&cmd).output();
}
// signal
// signal the whole process group (pid negative) so sh and any
// grandchildren all get it — otherwise background/pipeline children leak
let sig = crate::supervisor::parse_signal(&p.cfg.stop_signal);
let _ = nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as i32), sig);
let _ = nix::sys::signal::kill(nix::unistd::Pid::from_raw(-(pid as i32)), sig);
// wait
let deadline = std::time::Instant::now() + Duration::from_secs(p.cfg.timeout_stop);
@ -262,7 +280,7 @@ fn stop(name: &str, procs: &mut HashMap<String, Proc>) {
// force kill
if let Ok(None) = procs.get_mut(name).unwrap().child.try_wait() {
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(pid as i32),
nix::unistd::Pid::from_raw(-(pid as i32)),
nix::sys::signal::Signal::SIGKILL,
);
let _ = procs.get_mut(name).unwrap().child.wait();
@ -298,6 +316,13 @@ fn check_children(procs: &mut HashMap<String, Proc>) {
append_log(&log_path, &format!("O: exited with {}", code));
// Clean up anything still running in the dead service's process
// group (e.g. background/pipeline children of `sh -c`).
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(-(p.pid as i32)),
nix::sys::signal::Signal::SIGKILL,
);
if p.cfg.oneshot {
dead.push(name.clone());
continue;

View file

@ -1,3 +1,4 @@
use std::os::unix::io::AsRawFd;
use std::path::PathBuf;
pub fn runtime_dir() -> PathBuf {
@ -105,6 +106,25 @@ pub fn write_pid(path: &PathBuf) -> std::io::Result<()> {
std::fs::write(path, format!("{}", std::process::id()))
}
/// Acquire an exclusive lock on the daemon pid file.
/// Held for the lifetime of the returned `File`; a second daemon fails here.
pub fn lock_daemon() -> std::io::Result<std::fs::File> {
let path = daemon_pid_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let f = std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(&path)?;
let rc = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(f)
}
pub fn is_enabled(name: &str) -> bool {
enabled_path(name).exists()
}