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
4 changes: 4 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## 2026-07-20

- Made monitoring non-interfering and event-driven: passive Git commands disable optional locks, relevant filesystem events debounce after a quiet period, noisy `.git` paths are ignored, and periodic reconciliation moved from 30 seconds to five minutes.
- Separated passive and mutating Git execution so checkout and atomic deletion retain normal locks with a safer 30-second deadline, while timed-out commands receive a graceful TERM window before forced termination.
- Hardened shutdown by stopping and joining watcher/refresh/upstream workers and terminating every registered subprocess group instead of detaching active repository work.
- Verified formatting, strict offline Clippy, 176 all-target/all-feature tests, release build, and a live FactMachine-monorepo smoke test with 0.0% settled CPU, no index lock, and no process left after quit.
- Published the hardened source repository at `https://github.com/fieldsofland/stackmap`, protected `main` with required ARM64/Intel/stable/policy checks, protected prerelease tags, enabled security reporting and dependency updates, and configured the maintainer-approved prerelease environment.
- Installed `stackmap 0.1.0-alpha.1` from verified public `main` at `/Users/matt/.cargo/bin/stackmap`; version/help output and disposable-repository startup/quit smoke tests pass.
- Prepared Stackmap `0.1.0-alpha.1` for public open-source development with an MIT license, contribution and conduct policies, private vulnerability reporting guidance, issue forms, pull-request guidance, and feature/support/release documentation.
Expand Down
4 changes: 4 additions & 0 deletions memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
- Release benchmarks measured about 0.108 ms per 500-branch projection, 1.10 ms per 5,000-branch projection, and 0.94 ms per projection of one 5,000-branch deep stack. A 5,000-level/10,000-branch deep comb emits iteratively without call-stack recursion; broad attach-parent lookup is indexed.
- Public development lives at `https://github.com/fieldsofland/stackmap` on protected `main`. Native macOS ARM64/Intel CI, current-stable compatibility, and dependency policy pass at commit `0f01e4346448c700c0474061734005f08e767405`.
- `stackmap 0.1.0-alpha.1` is installed at `/Users/matt/.cargo/bin/stackmap`, resolves on `PATH`, and passes startup/quit smoke testing in a disposable Git repository.
- Passive Git monitoring runs with optional locks disabled, so inventory/status/diff commands cannot refresh or lock the index. Explicit checkout and deletion retain normal Git locking and use a separate 30-second mutation deadline.
- Repository notifications are path-filtered and quiet-period debounced; transient locks, object-store writes, logs, and temporary files do not trigger structural refreshes. Five-minute reconciliation is a dropped-event safety net rather than the primary monitor.
- Normal quit, terminal-close signals, and recoverable error exits stop the watcher, terminate registered subprocess groups, and join refresh/upstream workers. Timed-out children receive TERM before KILL.
- A release-build smoke test against the FactMachine monorepo settled at 0.0% CPU, created no `index.lock`, and left no Stackmap or Git child after `q`.

## Next steps

Expand Down
108 changes: 99 additions & 9 deletions src/adapters/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::io::{Read, Write};
use std::path::Path;
use std::process::{Command, ExitStatus, Stdio};
use std::sync::mpsc;
use std::sync::{Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};

Expand Down Expand Up @@ -44,7 +45,28 @@ where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
run_bounded_inner(program, args, cwd, timeout, output_limit, None)
run_bounded_inner(program, args, cwd, timeout, output_limit, None, false)
}

pub fn run_bounded_read_only_git<I, S>(
args: I,
cwd: &Path,
timeout: Duration,
output_limit: usize,
) -> Result<CommandOutput, CommandError>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
run_bounded_inner(
OsStr::new("git"),
args,
cwd,
timeout,
output_limit,
None,
true,
)
}

pub fn run_bounded_with_stdin<I, S>(
Expand All @@ -62,7 +84,15 @@ where
if stdin.len() > 64 * 1024 {
return Err(CommandError::InputTooLarge(stdin.len()));
}
run_bounded_inner(program, args, cwd, timeout, output_limit, Some(stdin))
run_bounded_inner(
program,
args,
cwd,
timeout,
output_limit,
Some(stdin),
false,
)
}

fn run_bounded_inner<I, S>(
Expand All @@ -72,6 +102,7 @@ fn run_bounded_inner<I, S>(
timeout: Duration,
output_limit: usize,
stdin: Option<&[u8]>,
disable_optional_locks: bool,
) -> Result<CommandOutput, CommandError>
where
I: IntoIterator<Item = S>,
Expand All @@ -89,12 +120,16 @@ where
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if disable_optional_locks {
command.env("GIT_OPTIONAL_LOCKS", "0");
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let mut child = command.spawn().map_err(CommandError::Spawn)?;
let active_child = ActiveChild::register(child.id());

let stdin_result = if let Some(input) = stdin {
let mut stream = child
Expand Down Expand Up @@ -144,6 +179,7 @@ where
None => thread::sleep(Duration::from_millis(10)),
}
};
drop(active_child);

let mut stdout = (Vec::new(), false);
let mut stderr = (Vec::new(), false);
Expand Down Expand Up @@ -205,17 +241,71 @@ fn terminate(child: &mut std::process::Child) {
// Each subprocess owns its process group, so a timeout also stops hooks
// or helpers that inherited its pipes instead of leaving reader threads
// waiting for descendants after the direct child exits.
let group = format!("-{}", child.id());
let _ = Command::new("/bin/kill")
.args(["-KILL", &group])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
signal_process_group(child.id(), "-TERM");
let grace_started = Instant::now();
while grace_started.elapsed() < Duration::from_millis(250) {
if child.try_wait().ok().flatten().is_some() {
return;
}
thread::sleep(Duration::from_millis(10));
}
signal_process_group(child.id(), "-KILL");
}
let _ = child.kill();
}

fn active_children() -> &'static Mutex<std::collections::HashSet<u32>> {
static ACTIVE: OnceLock<Mutex<std::collections::HashSet<u32>>> = OnceLock::new();
ACTIVE.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
}

struct ActiveChild(u32);

impl ActiveChild {
fn register(pid: u32) -> Self {
if let Ok(mut active) = active_children().lock() {
active.insert(pid);
}
Self(pid)
}
}

impl Drop for ActiveChild {
fn drop(&mut self) {
if let Ok(mut active) = active_children().lock() {
active.remove(&self.0);
}
}
}

pub fn terminate_active_commands() {
let pids = active_children()
.lock()
.map(|active| active.iter().copied().collect::<Vec<_>>())
.unwrap_or_default();
for &pid in &pids {
signal_process_group(pid, "-TERM");
}
thread::sleep(Duration::from_millis(100));
for pid in pids {
signal_process_group(pid, "-KILL");
}
}

#[cfg(unix)]
fn signal_process_group(pid: u32, signal: &str) {
let group = format!("-{pid}");
let _ = Command::new("/bin/kill")
.args([signal, &group])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}

#[cfg(not(unix))]
fn signal_process_group(_pid: u32, _signal: &str) {}

fn read_limited(
mut stream: Box<dyn Read + Send>,
limit: usize,
Expand Down
28 changes: 19 additions & 9 deletions src/adapters/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ use anyhow::{Context, Result, anyhow, bail};

#[cfg(test)]
use super::command::CommandError;
use super::command::{CommandOutput, run_bounded};
use super::command::{CommandOutput, run_bounded, run_bounded_read_only_git};
#[cfg(test)]
use super::graphite::raw_branch_metadata_presence;
use super::graphite::{raw_branch_metadata_has_child, read_topology};
use crate::model::{BranchId, ConfiguredUpstream, DiffStat, GraphiteProvenance, RepositoryState};

const GIT_TIMEOUT: Duration = Duration::from_secs(3);
const GIT_MUTATION_TIMEOUT: Duration = Duration::from_secs(30);
const UPSTREAM_GIT_TIMEOUT: Duration = Duration::from_millis(250);
const OUTPUT_LIMIT: usize = 16 * 1024 * 1024;
const UPSTREAM_OUTPUT_LIMIT: usize = 256 * 1024;
Expand Down Expand Up @@ -166,8 +167,7 @@ impl GitAdapter {

pub fn containing_remote_ref(&self, oid: &str) -> Result<Option<Arc<str>>> {
let contains = format!("--contains={oid}");
let output = run_bounded(
OsStr::new("git"),
let output = run_bounded_read_only_git(
[
OsStr::new("for-each-ref"),
OsStr::new("--sort=refname"),
Expand Down Expand Up @@ -270,7 +270,7 @@ impl GitAdapter {
{
bail!("branch {branch} is checked out at {}", path.display());
}
let output = self.git_os(
let output = self.git_mutation(
&[
OsStr::new("switch"),
OsStr::new("--"),
Expand Down Expand Up @@ -394,7 +394,7 @@ impl GitAdapter {
);
}
let reference = format!("refs/heads/{}", request.branch);
let output = self.git_os(
let output = self.git_mutation(
&[
OsStr::new("update-ref"),
OsStr::new("-d"),
Expand Down Expand Up @@ -560,8 +560,7 @@ impl GitAdapter {
}

fn git_upstream(&self, args: &[&str]) -> Result<CommandOutput> {
run_bounded(
OsStr::new("git"),
run_bounded_read_only_git(
args.iter().map(OsStr::new),
&self.start_dir,
UPSTREAM_GIT_TIMEOUT,
Expand All @@ -571,9 +570,20 @@ impl GitAdapter {
}

fn git_os(&self, args: &[&OsStr], limit: usize) -> Result<CommandOutput> {
run_bounded(OsStr::new("git"), args, &self.start_dir, GIT_TIMEOUT, limit)
run_bounded_read_only_git(args, &self.start_dir, GIT_TIMEOUT, limit)
.map_err(|error| anyhow!(error))
}

fn git_mutation(&self, args: &[&OsStr], limit: usize) -> Result<CommandOutput> {
run_bounded(
OsStr::new("git"),
args,
&self.start_dir,
GIT_MUTATION_TIMEOUT,
limit,
)
.map_err(|error| anyhow!(error))
}
}

fn ensure_success(output: &CommandOutput, action: &str) -> Result<()> {
Expand All @@ -589,7 +599,7 @@ fn ensure_success(output: &CommandOutput, action: &str) -> Result<()> {

fn discover_paths(start_dir: &Path) -> Result<(PathBuf, PathBuf, PathBuf)> {
let run = |args: &[&str], action| -> Result<CommandOutput> {
let output = run_bounded(OsStr::new("git"), args, start_dir, GIT_TIMEOUT, 64 * 1024)
let output = run_bounded_read_only_git(args, start_dir, GIT_TIMEOUT, 64 * 1024)
.map_err(|error| anyhow!(error))?;
ensure_success(&output, action)?;
Ok(output)
Expand Down
11 changes: 7 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use stackmap::runtime::{
RefreshHandle, github, platform, render,
};

const RECONCILE_INTERVAL: Duration = Duration::from_secs(30);
const RECONCILE_INTERVAL: Duration = Duration::from_secs(300);
const GITHUB_TTL: Duration = Duration::from_secs(30);
const CONFIG_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);

Expand Down Expand Up @@ -205,7 +205,13 @@ fn run() -> Result<()> {
let mut redraw = true;

loop {
if shutdown.load(Ordering::Acquire) {
break;
}
while let Some(event) = refresh.try_event() {
if shutdown.load(Ordering::Acquire) {
break;
}
redraw = true;
match event {
RefreshEvent::Structural {
Expand Down Expand Up @@ -304,9 +310,6 @@ fn run() -> Result<()> {
refresh.request();
last_reconcile = Instant::now();
}
if shutdown.load(Ordering::Acquire) {
break;
}
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
Expand Down
12 changes: 7 additions & 5 deletions src/refresh/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub struct RefreshHandle {
diff_state: Arc<(Mutex<DiffCoordinator>, Condvar)>,
upstream: UpstreamCoordinator,
shutdown: Arc<AtomicBool>,
_watcher: Option<notify::RecommendedWatcher>,
watcher: Option<watcher::RepositoryWatcher>,
}

#[derive(Clone)]
Expand Down Expand Up @@ -200,7 +200,7 @@ impl RefreshHandle {
diff_state,
upstream,
shutdown,
_watcher: watcher,
watcher,
};
handle.request();
Ok(handle)
Expand Down Expand Up @@ -235,16 +235,18 @@ fn push_event(events: &Mutex<VecDeque<RefreshEvent>>, event: RefreshEvent) {

impl Drop for RefreshHandle {
fn drop(&mut self) {
self.watcher.take();
self.shutdown.store(true, Ordering::Release);
let (state, wake) = &*self.diff_state;
if let Ok(mut state) = state.lock() {
state.shutdown = true;
}
wake.notify_all();
let _ = self.requester.requests.try_send(Request::Shutdown);
// A running bounded Git command may still be finishing. Dropping its
// JoinHandle detaches it so terminal restoration is never delayed.
self.workers.clear();
crate::adapters::command::terminate_active_commands();
for worker in self.workers.drain(..) {
let _ = worker.join();
}
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/refresh/upstream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,9 @@ impl Drop for UpstreamCoordinator {
}
self.revision.fetch_add(1, Ordering::AcqRel);
wake.notify_all();
// A bounded Git command may still be finishing. Detach instead of
// delaying terminal restoration.
self.worker.take();
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}

Expand Down
Loading