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 .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