Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 .typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ ba = "ba" # Part of commit hashes like e28e0ba
ede = "ede" # Part of commit hashes like ede2fd3
# ANSI escape codes in snapshots make is_main/is_current/is_previous look like "mis"
mis = "mis"
# `lsof -F pn` field spec (PID + name) in the fsmonitor sweep; typos maps the
# lowercase token `pn` to "on", which silently breaks the field request (`on`
# is offset+name, dropping the `p<pid>` record opener the parser splits on).
pn = "pn"
# Intentional typos in tests for "did you mean?" suggestions
deplyo = "deplyo"
comit = "comit"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ done

Sockets listed as bare `fsmonitor--daemon.ipc` (no resolved path) belong to deleted worktrees. Any `wt remove` cleans these up: it terminates the removed worktree's own daemon and sweeps daemons whose worktree no longer exists, including ones orphaned by `git worktree remove` or `rm -rf` (mechanism details: [What can Worktrunk delete?](https://worktrunk.dev/faq/#what-can-worktrunk-delete)).

The residual case both paths deliberately leave is a wedged daemon on a *live* worktree that is never removed: `git status` in that worktree blocks on the unresponsive IPC, but the daemon still serves a real worktree, so reaping it implicitly is out of scope. Terminate it manually: kill the daemon whose socket path matches the worktree, or `pkill -9 -f 'git fsmonitor--daemon'` and let the next `wt list` respawn the live ones. Disabling fsmonitor globally (`git config --global core.fsmonitor false`) avoids the class of problem entirely at the cost of some `git status` speed on large repos.
The residual case both paths deliberately leave is a wedged daemon on a *live* worktree that is never removed: `git status` in that worktree blocks on the unresponsive IPC, but the daemon still serves a real worktree, so reaping it implicitly is out of scope. Terminate it manually: kill the daemon whose socket path matches the worktree, or `pkill -9 -f 'git fsmonitor--daemon'` and let the next `wt list` respawn the live ones.

## PowerShell on Windows

Expand Down
2 changes: 1 addition & 1 deletion skills/worktrunk/reference/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ done

Sockets listed as bare `fsmonitor--daemon.ipc` (no resolved path) belong to deleted worktrees. Any `wt remove` cleans these up: it terminates the removed worktree's own daemon and sweeps daemons whose worktree no longer exists, including ones orphaned by `git worktree remove` or `rm -rf` (mechanism details: [What can Worktrunk delete?](https://worktrunk.dev/faq/#what-can-worktrunk-delete)).

The residual case both paths deliberately leave is a wedged daemon on a *live* worktree that is never removed: `git status` in that worktree blocks on the unresponsive IPC, but the daemon still serves a real worktree, so reaping it implicitly is out of scope. Terminate it manually: kill the daemon whose socket path matches the worktree, or `pkill -9 -f 'git fsmonitor--daemon'` and let the next `wt list` respawn the live ones. Disabling fsmonitor globally (`git config --global core.fsmonitor false`) avoids the class of problem entirely at the cost of some `git status` speed on large repos.
The residual case both paths deliberately leave is a wedged daemon on a *live* worktree that is never removed: `git status` in that worktree blocks on the unresponsive IPC, but the daemon still serves a real worktree, so reaping it implicitly is out of scope. Terminate it manually: kill the daemon whose socket path matches the worktree, or `pkill -9 -f 'git fsmonitor--daemon'` and let the next `wt list` respawn the live ones.

## PowerShell on Windows

Expand Down
166 changes: 143 additions & 23 deletions src/git/fsmonitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,10 +299,22 @@ impl ProcessSignaller for NixSignaller {
/// Enumerate running `git fsmonitor--daemon` processes and resolve each one's
/// IPC socket via `lsof`.
///
/// `pgrep -f 'git fsmonitor--daemon'` lists candidate PIDs; `lsof` resolves
/// each PID's Unix socket. Both run through [`crate::shell_exec::Cmd`] with a
/// short timeout. Any failure (tool missing, non-zero exit, unparsable
/// output) is treated as "no daemons" — the sweep is best-effort.
/// `pgrep -f 'git fsmonitor--daemon'` lists candidate PIDs; a single `lsof`
/// call resolves all of their Unix sockets at once. Both run through
/// [`crate::shell_exec::Cmd`] with a short timeout. Any failure (tool missing,
/// unparsable output) is treated as "no daemons" — the sweep is best-effort.
///
/// # Why one batched `lsof`
///
/// `lsof -p` accepts a comma-separated PID list and emits `-F` records grouped
/// under a `p<pid>` line per process, so N daemons cost one process spawn
/// instead of N. This matters because the candidate set is MACHINE-wide, not
/// repo-scoped: a machine with many worktrees accumulates a daemon per repo
/// ever touched (100+ is routine), and every `wt remove` paid one spawn each.
/// The cost was superlinear in practice, not merely linear — a fork storm
/// makes macOS `syspolicyd`/`XProtect` assess each new image under contention,
/// which inflates per-spawn cost for every other spawn on the box, sweeps
/// included. Idle, a spawn is ~2 ms; under a storm of them, ~50 ms.
#[cfg(unix)]
fn enumerate_daemons() -> Vec<DaemonProcess> {
use crate::shell_exec::Cmd;
Expand All @@ -325,31 +337,77 @@ fn enumerate_daemons() -> Vec<DaemonProcess> {
.lines()
.filter_map(|l| l.trim().parse::<u32>().ok())
.collect();
// The per-PID `lsof` resolution below is the expensive part of the whole
// sweep (~50ms each on macOS), and it scales with the MACHINE-wide daemon
// count, not this repo — surface the count so a slow sweep is explainable
// from the trace.
if pids.is_empty() {
return Vec::new();
}
// The candidate count scales with the MACHINE-wide daemon population, not
// this repo — surface it so a slow sweep is explainable from the trace.
tracing::debug!(
count = pids.len(),
"fsmonitor sweep: resolving sockets for {} daemon(s) via lsof",
"fsmonitor sweep: resolving sockets for {} daemon(s) via one lsof",
pids.len()
);

pids.into_iter()
.filter_map(|pid| {
let out = Cmd::new("lsof")
.args(["-a", "-p", &pid.to_string(), "-U", "-F", "n"])
.timeout(timeout)
.run()
.ok()?;
// lsof exits non-zero when a PID vanished mid-scan; skip it
// (a gone process is not an orphan we need to reap).
if !out.status.success() {
return None;
let pid_list = pids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let Ok(out) = Cmd::new("lsof")
.args(["-a", "-p", &pid_list, "-U", "-F", "pn"])
.timeout(timeout)
.run()
else {
return Vec::new();
};
// Deliberately NOT gated on exit status. `lsof` exits non-zero when ANY
// requested PID has vanished mid-scan, while still printing full records
// for every PID that survived. Treating that as failure would drop the
// whole sweep whenever one daemon happened to exit — the common case on a
// busy machine. Vanished PIDs simply emit no record and are never
// classified, which is the same outcome the per-PID path produced.
daemons_from_batched_lsof(&String::from_utf8_lossy(&out.stdout))
}

/// Split batched `lsof -F pn` output into per-PID records and classify each
/// via [`daemon_from_lsof_stdout`].
///
/// `lsof -F` streams field-prefixed lines; a `p<pid>` line opens a process
/// record and every line up to the next `p` line belongs to it. Splitting on
/// that boundary hands each PID exactly the output the per-PID `lsof` call
/// used to produce, so classification — and with it the whole data-safety
/// contract in the module docs — is unchanged.
///
/// A PID that produced no record (it exited between `pgrep` and `lsof`) is
/// absent from the result rather than classified as an orphan. That direction
/// matters: inventing a socket-less entry for a missing PID would make it look
/// like orphan class 1 and get an unrelated, possibly recycled PID signalled.
#[cfg(unix)]
fn daemons_from_batched_lsof(stdout: &str) -> Vec<DaemonProcess> {
let mut daemons = Vec::new();
let mut open: Option<(u32, String)> = None;

for line in stdout.lines() {
if let Some(pid_field) = line.strip_prefix('p') {
if let Some((pid, record)) = open.take() {
daemons.extend(daemon_from_lsof_stdout(pid, &record));
}
daemon_from_lsof_stdout(pid, &String::from_utf8_lossy(&out.stdout))
})
.collect()
// An unparsable `p` line leaves no record open, so its lines are
// discarded rather than misattributed to the previous PID.
open = pid_field
.trim()
.parse::<u32>()
.ok()
.map(|pid| (pid, String::new()));
} else if let Some((_, record)) = open.as_mut() {
record.push_str(line);
record.push('\n');
}
}
if let Some((pid, record)) = open {
daemons.extend(daemon_from_lsof_stdout(pid, &record));
}
daemons
}

/// Classify the lsof output for one PID into an [`Option<DaemonProcess>`].
Expand Down Expand Up @@ -486,6 +544,68 @@ mod tests {
assert_eq!(parse_lsof_socket_path(lsof), None);
}

/// Verbatim `lsof -a -p <pids> -U -F pn` output shape (macOS 26, lsof
/// 4.99.5): each process opens with `p<pid>`, then `f`/`n` pairs. Covers
/// all three classifications in one batch — a resolved socket, a bare
/// name (orphan class 1), and a `pgrep` false positive holding no
/// fsmonitor socket at all.
#[cfg(unix)]
#[test]
fn batched_lsof_splits_records_per_pid() {
let batched = concat!(
"p2337\n",
"f15\n",
"n->0xf3491abb08452a68\n",
"f18\n",
"n/Users/me/repo/.git/fsmonitor--daemon.ipc\n",
"p2497\n",
"f24\n",
"nfsmonitor--daemon.ipc\n",
"p3068\n",
"f3\n",
"n->0xdeadbeef\n",
);
let daemons = daemons_from_batched_lsof(batched);
assert_eq!(
daemons,
vec![
DaemonProcess {
pid: 2337,
socket: Some(PathBuf::from("/Users/me/repo/.git/fsmonitor--daemon.ipc")),
},
// Bare name → unresolvable socket, orphan class 1.
DaemonProcess {
pid: 2497,
socket: None,
},
// pid 3068 holds no fsmonitor socket → not a daemon, dropped
// rather than misclassified as class 1.
],
"each p-record must classify exactly as the per-PID call did"
);
}

/// A PID that exits between `pgrep` and `lsof` emits no record at all.
/// It must be absent from the result — never synthesized as a
/// socket-less entry, which would read as orphan class 1 and signal a
/// PID that no longer belongs to the daemon we enumerated.
#[cfg(unix)]
#[test]
fn batched_lsof_omits_pids_that_produced_no_record() {
let batched = "p2337\nf18\nn/Users/me/repo/.git/fsmonitor--daemon.ipc\n";
let daemons = daemons_from_batched_lsof(batched);
assert_eq!(daemons.len(), 1);
assert_eq!(daemons[0].pid, 2337);
}

#[cfg(unix)]
#[test]
fn batched_lsof_handles_empty_output() {
// Every requested PID vanished: lsof exits non-zero with no stdout.
// The sweep must find nothing rather than panic or invent orphans.
assert!(daemons_from_batched_lsof("").is_empty());
}

#[cfg(unix)]
#[test]
fn canonicalize_socket_resolves_symlinked_git_dir_and_falls_back_when_gone() {
Expand Down
18 changes: 18 additions & 0 deletions src/testing/mock_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,24 @@ impl MockConfig {
}
}

/// Every invocation of mock command `name`, in call order, one entry per
/// spawn with the arguments space-joined.
///
/// Lets a test assert on the *number of spawns*, not just the response —
/// the property under test wherever a command is batched (one `lsof` for N
/// PIDs) rather than called in a loop. Returns empty when the command was
/// never invoked, so a "was not called" assertion reads naturally.
///
/// `log_dir` is the directory passed to the command as `MOCK_CALL_LOG_DIR`.
/// It must be OUTSIDE the repo under test: mocks spawned by hooks would
/// otherwise dirty the working tree mid-run and change the behavior being
/// measured.
pub fn mock_calls(log_dir: &Path, name: &str) -> Vec<String> {
fs::read_to_string(log_dir.join(format!("{}.calls", name)))
.map(|s| s.lines().map(str::to_string).collect())
.unwrap_or_default()
}

/// Create mock binary in bin_dir with the given name.
/// Uses symlinks on Unix (instant, works across filesystems).
/// Uses hard links on Windows (symlinks require admin privileges).
Expand Down
23 changes: 23 additions & 0 deletions src/testing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,29 @@ impl TestRepo {
check_git_status(&output, &args.join(" "));
}

/// Create many branches at `HEAD` in a single `git update-ref --stdin`
/// spawn, panicking on failure.
///
/// A fixture that needs N branches to cross a threshold cares about the
/// ref count, not about how the refs got there. Looping `git branch`
/// turns that into N process spawns, and integration tests run at full
/// core parallelism — so a fixture loop is multiplied by every other test
/// running beside it. One spawn keeps the fixture's cost proportional to
/// what it is actually setting up.
pub fn create_branches(&self, names: &[String]) {
let stdin: String = names
.iter()
.map(|n| format!("create refs/heads/{n} HEAD\n"))
.collect();
let output = self
.git_command()
.args(["update-ref", "--stdin"])
.stdin_bytes(stdin)
.run()
.unwrap();
check_git_status(&output, "update-ref --stdin");
}

/// Run a git command in a specific directory, panicking on failure.
///
/// Thin wrapper around `git_command()` that runs in `dir` and checks status.
Expand Down
29 changes: 29 additions & 0 deletions tests/helpers/mock-stub/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,40 @@ fn config_dir() -> PathBuf {
PathBuf::from(env::var_os("MOCK_CONFIG_DIR").expect("mock: MOCK_CONFIG_DIR not set"))
}

/// Append this invocation's argv to `<MOCK_CALL_LOG_DIR>/<command>.calls` so
/// a test can assert *how many times* and *with what arguments* a command was
/// spawned, not just what it returned. Needed wherever the spawn count is the
/// behavior under test — e.g. the fsmonitor sweep resolving every daemon in
/// one batched `lsof` rather than one call per PID.
///
/// Opt-in, and deliberately NOT written next to the JSON config. Tests
/// routinely place `MOCK_CONFIG_DIR` inside the repo under test
/// (`<repo>/.bin`), so logging there would create an untracked file mid-run
/// and change what the command being tested observes — a hook-spawned mock
/// dirties the working tree, and `wt merge` then stashes. Observability must
/// not perturb the system under test, so the log goes wherever the test says
/// and nowhere by default.
///
/// One line per invocation, arguments space-joined. Best-effort: a log-write
/// failure must not change what the mock returns, or a test would fail on the
/// logging rather than on its actual assertion.
fn log_invocation(cmd_name: &str, args: &[String]) {
let Some(dir) = env::var_os("MOCK_CALL_LOG_DIR") else {
return;
};
let path = PathBuf::from(dir).join(format!("{}.calls", cmd_name));
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "{}", args.join(" "));
}
}

fn main() {
let cmd_name = command_name();
let config_dir = config_dir();
let config_path = config_dir.join(format!("{}.json", cmd_name));

log_invocation(&cmd_name, &env::args().skip(1).collect::<Vec<_>>());

let content = fs::read_to_string(&config_path).unwrap_or_else(|e| {
eprintln!("mock: failed to read {}: {}", config_path.display(), e);
exit(1);
Expand Down
28 changes: 16 additions & 12 deletions tests/integration_tests/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1710,18 +1710,22 @@ fn test_complete_switch_excludes_remote_branches_when_over_threshold(mut repo: T
repo.commit("initial");
repo.setup_remote("main");

// Create 50 local branches
for i in 0..50 {
repo.run_git(&["branch", &format!("local/branch-{i}")]);
}

// Create 60 remote-only branches (push then delete locally)
for i in 0..60 {
let name = format!("remote/branch-{i}");
repo.run_git(&["branch", &name]);
repo.run_git(&["push", "origin", &name]);
repo.run_git(&["branch", "-D", &name]);
}
// Only the ref COUNT matters here, so the whole fixture is built in five
// git spawns rather than one per branch (was ~230, and every one of them
// competes with the rest of the suite running at full core parallelism).
let local: Vec<String> = (0..50).map(|i| format!("local/branch-{i}")).collect();
let remote: Vec<String> = (0..60).map(|i| format!("remote/branch-{i}")).collect();
repo.create_branches(&local);
repo.create_branches(&remote);

// Push all 60 in one call, then delete all 60 locally in one call, so
// they survive only as remote-tracking refs.
let mut push = vec!["push", "origin"];
push.extend(remote.iter().map(String::as_str));
repo.run_git(&push);
let mut delete = vec!["branch", "-D"];
delete.extend(remote.iter().map(String::as_str));
repo.run_git(&delete);
repo.run_git(&["fetch", "origin"]);

// Total branches: 1 (main worktree) + 50 local + 60 remote = 111 > 100
Expand Down
Loading
Loading