perf(tests): stop leaking a temp dir per test, and measure where the suite's CPU goes - #3604
perf(tests): stop leaking a temp dir per test, and measure where the suite's CPU goes#3604max-sixty wants to merge 21 commits into
Conversation
…TFLAGS build `isolated_test_cwd()` held a `TempDir` in a `LazyLock`. Statics don't run destructors at process exit and nextest runs one process per test, so every test leaked an empty directory into the system temp root: 704 per integration-suite run, and 454,016 had accumulated on the machine this was found on. Stale entries are cheap to ignore but expensive to enumerate, and `git::recover::recover_from_path` reads every ancestor directory of a deleted CWD. Its own unit test took 14.2s against that temp root and 0.27s against an empty one. One fixed directory replaces the per-process tempdir; nothing writes into it, so it never grows. The gate's insta step set `RUSTFLAGS='-D warnings'`, which is part of cargo's fingerprint, so it forked the whole 343-crate graph into a second artifact set: 261 CPU-seconds to prime plus a duplicate of `target/debug/deps` to carry. It was redundant too. The pre-commit step runs `cargo clippy --all-targets --all-features -- -D warnings`, which fails on any rustc warning in every target the insta step builds, over a superset of its features. The only extra coverage RUSTFLAGS bought was warning-denial on third-party dependencies. In CI it also shadowed the workflow-level `RUSTFLAGS: -C debuginfo=0`, so the largest build in the run was the one carrying full debug info. `step_promote::test_promote_bare_repo_with_worktrees` drove git without `configure_git_env`, so the host's config applied. A conditional `includeIf "gitdir:/Users/"` enabling commit signing failed its commit whenever TMPDIR sat inside the matched tree; it passed only because macOS puts TMPDIR under /var/folders. `task profile-tests` runs the suite under CPU accounting with TMPDIR pointed at a fresh directory, reports every test over 1s, and lists what survives the run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removing it was wrong. The claim was that the pre-commit step's `cargo clippy --all-targets --all-features -- -D warnings` already covers it, but that job runs on ubuntu only. The cross-platform matrix runs `wt hook pre-merge --yes insta`, so this RUSTFLAGS is the only thing denying warnings on macOS and Windows: it is what makes `test (windows)` fail on a helper whose every use sits behind `#[cfg(unix)]`, exactly as tests/CLAUDE.md describes. `[lints.rust] warnings = "deny"` in Cargo.toml would keep that coverage on every platform without forking the dependency graph (verified: it fails the build on a planted unused variable, recompiles only worktrunk and wt-perf, and the three feature-check commands still pass). It also makes plain local builds fail on warnings and newly exposes `cargo msrv verify` and the minimal-versions job, whose older toolchains can warn where the current one doesn't. That is a workflow call, not a cleanup, so it stays out of this PR. The duplication is real and measured: RUSTFLAGS is part of cargo's fingerprint, so it forks all 343 crates into a second artifact set, ~261 CPU-seconds to prime plus a duplicate of target/debug/deps. It is a one-time cost per artifact set rather than a per-edit one, so it is second-order next to the ~600 CPU-seconds each suite run costs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
Reviewing as a draft — flagging anything that looks worth a quick fix. Mark ready for a full review.
The core change reads well: swapping the LazyLock<TempDir> for one fixed empty dir cleanly kills the per-test leak, and because nothing writes into it the shared-across-processes access is race-safe (create_dir_all is idempotent). The step_promote fix to route git through configure_git_env matches the established pattern in bare_repository.rs, and dropping the now-unused Repository import is correct.
One portability note on task profile-tests inline. Nothing blocking.
…ed temp dir The fixtures created their temp directories directly in the system temp dir, so each one sat among however many entries the machine had put there. That is cheap to ignore but expensive to walk, and `git::recover::recover_from_path` reads every ancestor directory of a deleted CWD looking for the repo a removed worktree belonged to. Its own unit test ran 14.2s against a temp root holding 454k entries; rooted under `test_temp_root()` it runs 0.06s. `test_tempdir()` is the fixture-side replacement for `TempDir::new()`, used by every `TestRepo` constructor, the mock-command helper, the `temp_home` fixture and the recovery tests. The ~240 tests that reach for `tempfile` directly still land in the system temp dir; those are transient, and a clean run now leaks no directories at all, so converting them is tidiness rather than a fix. The root's short name is load-bearing. A unix socket path cannot exceed `sun_path` — 104 bytes on macOS including the NUL — and macOS's canonicalized per-user `$TMPDIR` is already 56 of them, with `test_copy_ignored_skips_non_regular_files` binding a listener 89 bytes in. `worktrunk-tests` overflowed by one byte; `wt` costs 3 of the 14 that were spare. Staying under the system temp dir is also deliberate. A cache-dir root would give a smaller ancestor chain still, but it puts fixtures under `$HOME`, within reach of a conditional `includeIf "gitdir:<home>"` in the developer's git config: 16 picker tests then fail their commits under `commit.gpgsign`, because they drive git through `Repository::run_command` rather than `configure_git_env`. That the suite is hermetic against host git config only because macOS puts `$TMPDIR` outside `/Users/` is a real gap, but the fix belongs in `TestRepo`, not in where its temp files live. `add_os_temp_dir_filter` now derives its redactions from both live roots rather than a hardcoded list of platform temp paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`TestRepo` exposes a `repo` field of the production `Repository` type, and
`Repository::run_command` builds a plain `Cmd::new("git")` with no
`GIT_CONFIG_*` of its own. Every test that drives git through it — 280
`Repository` handles across 31 test files, 156 of them running git directly —
resolved config from the developer's `~/.gitconfig`. The suite passed only
because fixtures sit under `$TMPDIR`, outside the reach of a conditional
`includeIf "gitdir:/Users/"`; rooted under `$HOME` instead, 20 of the 38
picker tests fail on `gpg failed to sign the data`.
Signing was the symptom. The leak is unbounded and includes arbitrary code:
`core.hooksPath` runs the developer's hooks, and `core.fsmonitor`,
`credential.helper`, `filter.*` and `diff.external` run programs of their
choosing, while `worktree.guessremote`, `branch.sort`, `merge.conflictstyle`,
`rebase.autostash` and `help.autocorrect` each change what the code under test
observes. That rules out hardening each fixture's local config: a denylist over
an arbitrary-code surface is not a guarantee, and local config cannot unset an
inherited `[include]`, alias, or `credential.helper` in any case.
Git reads `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` and nothing else to find
its out-of-repo config, so isolating in-process git means setting two
environment variables, and an environment variable can only be set before the
process starts (`std::env::set_var` is `unsafe`; the crate forbids `unsafe`).
`.cargo/config.toml` is the one layer that reaches every runner the project
uses: `cargo nextest run`, the `cargo llvm-cov` coverage job, the `cargo test`
in the Nix test derivation, and `cargo bench`. It already carries `COLUMNS` for
the same reason — in-process tests needing process env. A `[env]` block in
`.config/nextest.toml` was the alternative; nextest has no such key, and a
setup script emitting `$NEXTEST_ENV` would leave the three other runners
uncovered.
Routing test setup through `TestRepo::run_git()` was the other candidate. It
would have touched 156 call sites and still left the production code under test
spawning git from the unisolated process environment.
The hermetic config carries no identity. Identity belongs to the fixture repo's
local config, which every `TestRepo` constructor already writes, and
`user.useConfigOnly` there turns a fixture that forgot one into an error rather
than a commit authored from the host's username and hostname. It also means a
`cargo run -- merge` — which now runs against this config too, the mechanism's
one cost — fails rather than writing an author the developer didn't pick.
`write_test_gitconfig` embeds the same file and appends the test identity, so
the per-`TestRepo` copy cannot drift from the process-wide floor, and
`isolate_subprocess_env` passes the two variables through instead of scrubbing
them, so `wt` children inherit it. Embedding is what puts the file in `dev/`
rather than beside the other fixtures: it becomes a compile-time input to the
library, and `dev/` is the directory the crates.io archive and the `flake.nix`
source filter already carry, as `embedded_assets_ship_in_package` checks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both sides reworked the test environment, so the two conflicting hunks in `tests/CLAUDE.md` are additive rather than competing: main's PTY env-layer section documents where a *variable* goes, this branch's Git Config Isolation section documents where the developer's *config* is denied. Kept both, with main's `###` staying under its parent section. main's `pty_env_vars` points a PTY child at the fixture's own `GIT_CONFIG_GLOBAL` copy and `/dev/null` for the system config, which sits on top of this branch's floor rather than against it; noted it alongside `isolate_subprocess_env` in the same list.
`mktemp -d` honors `$TMPDIR`, so the profiling run rooted its temp tree one root below the default per-user one. A fixture binds a unix socket 33 bytes into its temp root and `sun_path` holds 104 including the NUL, so under the 56-byte canonicalized `$TMPDIR` there are 14 spare and `mktemp` spends 15: `test_copy_ignored_skips_non_regular_files` failed on every run of the one task whose job is to tell you what the suite costs. /private/tmp is 30 bytes, leaving 41 spare. Verified both directions: the test fails under a `mktemp -d` root and passes under this one.
Five runners execute this suite and CI uses several on the same commit, so a test's result must not depend on which one started it. That rules out the `$NEXTEST_ENV` setup script this branch considered for the git-config floor, and rules it out standing rather than case by case. The failure mode is what makes it worth a rule: a nextest-only knob doesn't error when another runner misses it, it produces a different result — and the runner that disagrees is typically `cargo llvm-cov`, so it surfaces as a coverage regression rather than as missing configuration. `.config/nextest.toml` points at the rule from the place someone would be tempted, and names why `build-bins` is a build step rather than precedent.
`config_path()`'s third priority is the real `~/.config/worktrunk/config.toml`, and a lib-crate test has no way to set the second for itself — `set_var` is `unsafe` and this crate forbids `unsafe`. So a test that reached priority 3 got the developer's own config: `prewarm_user_config` read it on every run of the two `prewarm_*` repository tests, and `set_skip_shell_integration_prompt` / `set_skip_commit_generation_prompt` reach the same resolver to *write*. Priority 3 is now absent under `#[cfg(test)]`. That's compiled out of the real binary, so `cargo run -- <cmd>` is unaffected. `None` rather than the `panic!` `approvals_path()` uses: that one guards a mutation target, where a silent absence would let a test believe it saved something. This is a lookup whose absent state is already meaningful and handled — `require_config_path()` turns it into an error, so a config write still fails loudly, while a best-effort read like the prewarm cache preloads nothing, which is the contract prewarm already documents. The guard fires for lib-crate tests only; a bin-crate test links the lib in non-test mode. Nothing under `src/commands/` or `src/output/` exercises the fall-through today — 968 bin-crate tests create no config under a scratch `$HOME` — so that gap is a requirement on new tests, recorded in tests/CLAUDE.md. `system_config_path()` stays unguarded on purpose: it resolves a machine-wide file rather than the developer's, and `config::deprecation`'s `PendingDefault` rules need the lookup. The one test asserting the fall-through is dropped — it existed to check the behavior this now forbids.
Both sides went after the same hole — in-process git reading the developer's config — and arrived by different routes, so this is a design merge rather than a textual one. main (#3620) writes `LOCAL_TEST_CONFIG` into each fixture's own `.git/config`; this branch points `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` at a checked-in hermetic file for every process cargo starts. They compose rather than compete: the floor denies the host's config to every git, and the local config supplies what a hermetic in-process git still needs — an identity, which the floor deliberately withholds so a fixture that forgot one fails instead of authoring from the host's username. Kept main's structure (`test_gitconfig_path`, `TestConfigPaths`, `TestRepo::bare`) and layered this branch's guarantees on it. Two corrections the merge forces, both of statements that were true on their own side and false together: - `test_gitconfig_path` restated the gitconfig inline. It now embeds `HERMETIC_GITCONFIG` plus the identity, because that file *overrides* the process-wide floor — a restatement would let the two drift, and a test would resolve different config from the in-process git it is testing. - `LOCAL_TEST_CONFIG`'s docstring, and the matching passage in tests/CLAUDE.md, said in-process git reads the developer's `~/.gitconfig`. That is what the floor now prevents; the real remaining gaps are `GIT_ALLOW_PROTOCOL` and a per-test `GIT_CONFIG_GLOBAL`. `test_gitconfig_path` also held its `TempDir` in a `LazyLock`, which is the leak this branch removed: a static runs no destructor, so under nextest's process-per-test model that is one stray directory per test. It now writes to a fixed path under `test_temp_root()`. Every process writes identical bytes and `write_atomically` renames over the target, so concurrent writers cannot produce a torn read.
…ritten one `test_gitconfig_path()` wrote one fixed path with `write_atomically`, which renames a temp file over the target. On Windows a rename cannot replace a file another process holds open, and under nextest every test is a process holding that path open through git — so the whole Windows suite failed at the write. tests/CLAUDE.md documents the same hazard for `NamedTempFile`, and `write_atomically` builds its temp the same way. The content is constant, and the file was already shared and read-only, so there is nothing to generate: `dev/test-gitconfig` is checked in and `test_gitconfig_path()` is a compile-time path with no I/O. Nothing to create, nothing to race on, nothing to leak. It `[include]`s `dev/hermetic-gitconfig` rather than restating it, so the file that overrides the `.cargo/config.toml` floor cannot drift from it — git's own include mechanism replacing the `include_str!` that did that job before. Verified a harness git resolves the identity and the floor's settings through the include, and still resolves nothing from the host. Rejected: reverting to a `TempDir` in a `LazyLock`, which is Windows-safe but leaks one directory per test — worse than the leak this branch removes; and publishing via `hard_link`, which is leak-free but a concurrency mechanism no local run can exercise, the same gap that produced this failure.
worktrunk-bot
left a comment
There was a problem hiding this comment.
Reviewed the now-ready state end to end — the isolation story holds together and the docs are unusually thorough. The two changes since the draft pass read well: the #[cfg(test)] guard in config_path() returning None (write callers still fail loudly via require_config_path, best-effort reads preload nothing) is the right shape, and checking dev/test-gitconfig / dev/hermetic-gitconfig in as files — the former [include]-ing the latter so they can't drift — cleanly removes the per-process write race and the per-test temp-dir leak. Nothing blocking.
The one red check, affected tests (windows, advisory), is not this PR — it's cargo affected failing to decode git diff output as UTF-8, and the affected workflow itself concluded green (the job is continue-on-error). Safe to ignore.
Why the advisory-windows failure is tooling noise, not your change
The job errored with git diff stdout was not valid UTF-8: invalid utf-8 sequence of 1 bytes from index 118096 — cargo affected reads git diff over the affected range through String::from_utf8 and chokes on binary content. The repo carries non-UTF-8 fixtures (tests/fixtures/template-repo/_git/index, _git/objects/**); when a binary blob lands in the affected range, the decode fails.
Two things confirm it's not caused by this change:
- The earlier draft commit
6fcb8ea8already carried the identical.cargo/config.tomlGIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEMenv change and passed this same advisory job — so the hermetic-config env isn't reachingcargo affected's git in a way that breaks it. - Whether it fires depends on which files sit in the affected-vs-collect range (the run noted "16 commit(s) since collect"), so it's non-deterministic across runs rather than a property of this diff.
Required test (windows) / (linux) / (macos), lint, and code-coverage are all green.
The two `dev/` gitconfigs read as an arbitrary split. Which one carries the identity follows from who reads each. Cargo installs the floor for every process it starts, a developer's own `cargo run -- <cmd>` included, where a name would author their commits as Test User instead of failing. A harness-built git needs one because it commits into repos it has just created, before `LOCAL_TEST_CONFIG` reaches their local config: pointing `test_gitconfig_path()` at the floor fails 17 tests on `Author identity unknown`. Also drops two stale claims and one dead filter: - `configure_git_env` installs a checked-in read-only file, not "a per-TestRepo copy" a test may edit. The old wording invited a test to write to a file that is now checked in. - The `[TEST_GIT_CONFIG]` insta filter matched a `.tmp*/test-gitconfig` path shape that no longer exists; `test_temp_root()` is `temp_dir()/wt`. All 985 snapshots redact the variable structurally through `.env.GIT_CONFIG_GLOBAL`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add/add conflict in `tests/CLAUDE.md`: this branch added a Git Config Isolation section where main added the `WORKTRUNK_TEST_*` naming rule. Both kept. Main's paragraph continues the environment-variable discussion above it, so it stays in that section and the new heading follows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite's git isolation was two checked-in config files: a floor that `.cargo/config.toml` installed as `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM`, and a sibling that `[include]`d it and added a test identity. Denial never needed a file (those two variables are the denial), and neither do the settings: `GIT_CONFIG_COUNT` with numbered keys and values is git's environment spelling of `-c`. Both files are gone. `dev/hermetic-gitconfig` and `dev/test-gitconfig` are deleted. The floor now lives entirely in `.cargo/config.toml`, the identity in `git_test_env`, and `configure_git_env`/`configure_git_cmd` lose their config-path argument, which no caller needed once there was no file to name. Two consequences worth knowing: - `GIT_CONFIG_COUNT` is `-c`, so it outranks a repository's own config where a global file would yield to it. `init.defaultBranch` therefore cannot live in the floor: `default_branch.rs` sets that key in a repo to prove `wt` reads it, and an entry would silently win. The three harness `git init` calls that relied on it now name their branch, as the other two already did. - A PTY child is `env_clear`ed, so it inherits nothing and previously got the floor through the file. `pty_env_vars` copies the `GIT_CONFIG_*` family across, pinned by `pty_env_vars_carry_the_git_config_floor`. The settings only quiet advice and refuse a guessed identity, so no PTY assertion would have caught their loss. Four tests wrote their own gitconfig to get `init.defaultBranch` plus an identity; the harness supplies both, so those writes are gone too. The isolation meta-test now asserts the origin of every resolved setting rather than listing the global file, which a nonexistent path cannot be asked for. Net 45 lines lighter, and no snapshot changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed the environment-vs-file refactor end to end. Moving the isolation floor into .cargo/config.toml env is a clean improvement — it's the one layer that reaches in-process Repository::run_command git, the GIT_CONFIG_COUNT vs. repo-config precedence reasoning is right, and the isolate_subprocess_env pass-through plus pty_env_vars copy are both pinned by their own tests. Keeping identity out of the floor (so cargo run can't author Test User commits) and gating config_path() to None under #[cfg(test)] are both the right shape. The docs are thorough.
One consistency note, not a breakage. Dropping init.defaultBranch = main from the floor and adding explicit -b main to every harness constructor is correct — but three git init calls elsewhere in the suite were left un-updated and now silently create master instead of main:
repo.run_git_in(&sub_source, &["init"])intest_remove_foreground_with_submodules(tests/integration_tests/remove.rs)repo.run_git_in(&sub_source, &["init"])intest_remove_worktree_submodule_dirty_fails_closed(tests/integration_tests/remove.rs)- the bare
["init", "--bare", <remote_dir>]intest_complete_excludes_remote_branches(tests/integration_tests/completion.rs)
None of the three break — the first two repos are opaque submodule sources and the third is an origin remote whose default HEAD is never consulted (branches arrive via explicit refspec pushes), so no assertion touches the branch name and the tests are green. But they drift from the invariant you just documented ("every git init in the harness names its branch"), and the two non-bare ones now emit a hint: Using 'master' … line on stderr. They're outside this PR's diff, so I can't inline-suggest; happy to push a one-line -b main fix to all three if you'd like, or you may prefer to leave the opaque sources as-is.
Nothing blocking.
Four of the floor's five settings predated the denial and were doing nothing once it landed: `commit.gpgsign`, `advice.mergeConflict`, `advice.resolveConflict` and `rerere.enabled`. Denial already puts git on its own defaults for all four. Disabling the set and running 4,611 tests, PTY included, failed nothing but the assertion on the floor's own contents. `user.useConfigOnly` stays. It is the one setting with a job after denial: a default git *guesses* an identity from the OS username and hostname rather than failing, which is the only remaining way a hermetic suite could author a commit as the developer. Nothing exercises it, because every path now sets an identity, so it is a backstop rather than a behavior. Without it a future gap would go silent instead of failing. `.cargo/config.toml` goes from eleven `GIT_CONFIG_*` lines to three, and `tests/CLAUDE.md` now says why the floor cannot move into a fixture: every other test variable is set on a child, whereas in-process git reads the test process's own environment, which a test cannot set for itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
The floor trim reads cleanly — useConfigOnly is the right one to keep (it's the only setting with a job once the host config is denied), and the reasoning that git's own defaults already cover gpgsign / advice.* / rerere under the deny pair holds. The tests/CLAUDE.md rewrite explaining why the floor can't move into a fixture (in-process git reads the test process's own env, which a test can't set for itself) is a nice tightening.
One thing that slipped in with this commit, though: src/testing/.mod.rs.pending-snap is an insta pending-snapshot artifact that got committed by accident. It's not a snapshot you want tracked — its recorded new snapshot is "", which doesn't even match the inline snapshot you accepted (user.useconfigonly=true), so it's stale transient state left over from the cargo insta run, not review-worthy content.
The reason it wasn't caught is that .gitignore only has *.snap.pending (line 14), which is insta's external-snapshot pending format. Inline-snapshot pending files use the .pending-snap suffix instead (.mod.rs.pending-snap), which that pattern doesn't match — so nothing ignored it and git add picked it up.
Happy to push a one-commit fix: git rm src/testing/.mod.rs.pending-snap plus adding *.pending-snap to .gitignore so the inline-snapshot form is covered going forward. Just say the word (or grab it yourself — it's a two-line change).
Nothing else blocking.
…ixture The previous commit removed `rerere.enabled` on the strength of a local measurement that was wrong. Disabling the floor's settings and running the suite failed only the assertion on the floor's own contents, so all four looked dead. Three were. `rerere` was not, and CI failed on all three platforms: `merge_rebase_conflict` expects git's `Recorded preimage` line. The local run lied because the standard fixture is built once into `target/debug/wt-test-fixtures/` and copied per test. That cached copy already held an `rr-cache` directory, left by a rebase run while the floor still enabled rerere, and git turns rerere on by itself whenever `rr-cache` exists. So every local test kept the behavior the change had just removed, while CI built the fixture fresh and lost it. `rerere.enabled = false` is now set rather than left unset, which is stronger than restoring the old `true`: the suite's rerere state no longer depends on what a fixture happens to carry. The snapshot loses its `Recorded preimage` line accordingly, and rebases in tests stop resolving conflicts from recorded state. `commit.gpgsign`, `advice.mergeConflict` and `advice.resolveConflict` stay removed. Denial leaves git on its own default for the first, and `tests/fixtures/template-repo/gitconfig` carries the other two into the fixtures that need them. Also: `.gitignore` covered `*.snap.new` and `*.snap.pending` but not `*.pending-snap`, the name insta gives a pending *inline* snapshot, so one had been committed. Ignored, and the stray file removed. `tests/CLAUDE.md` records the trap: before trusting a local measurement of a git-config change, clear the fixture cache, because CI always builds fresh. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the runner A test cannot set its own process environment, but its binary can: a pre-main constructor in the new wt-test-env helper crate installs the GIT_CONFIG_* floor and COLUMNS while the process still has one thread, which is the moment std::env::set_var is sound. Each test and bench target links it with one line; assert_hermetic_floor in the fixture constructors turns a forgotten line into an immediate failure. This replaces cargo's [env] table (.cargo/config.toml, now deleted). In the binary, every runner agrees by construction — cargo test, nextest, llvm-cov, bench, a debugger, a directly executed test binary — where the runner-level floor covered only processes cargo started, and taxed a developer's own cargo run with the denied config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…md choke point Setting env on a child is safe — it was setting the test process's own env that wasn't. The harness flips an atomic latch (shell_exec::enable_hermetic_test_env) from the fixture constructors, and Cmd, the choke point every production spawn passes through, applies HERMETIC_TEST_GIT_ENV to each child while the latch is set. Spawn sites the harness owns (configure_git_cmd, isolate_subprocess_env, pty_env_vars) apply the same floor per child directly, and isolate_subprocess_env now scrubs an inherited GIT_CONFIG_* instead of passing it through. This deletes the wt-test-env constructor crate, its 12 activation lines, and the one unsafe block; .cargo/config.toml returns to exactly main's version (COLUMNS only). The latch is a test-serving switch in production code, accepted deliberately over the alternatives — TODO(hermetic-env) records the structural fix, threading an explicit env value through Repository. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the floor as denial's only writer, the copies riding other transports served no guarantee: git_test_env drops the deny pair (the floor's value is now uniform across every transport, retiring the platform-dependent NULL_DEVICE constant), LOCAL_TEST_CONFIG drops commit.gpgsign (denial guarantees git's own default), and the .env.GIT_CONFIG_GLOBAL snapshot redaction goes (the recorded value is one cross-platform constant, nothing volatile to redact). Also renames the latch static to HERMETIC_TEST_ENV_LATCHED so the bool stops reading as data, and notes the GNU time dependency of task profile-tests in tests/CLAUDE.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The statusline underlined its PR reference but not the dev-server port, so nothing marked the port as clickable: ``` ~/w/worktrunk.test-suite-cpu ↕|💬 ↑17 ↓11 ^+585 -258 #3604 :11486 Fable 5 🌕 30% ``` Both links now route through a shared `worktrunk::styling::hyperlink(url, text)`, which emits the OSC 8 sequence and the underline together. It closes with `[24m` rather than a full reset, so a wrapping color (the CI verdict) or dim (a port nothing answers on) survives the link. The rule is recorded as policy in the `writing-user-outputs` skill. Link text is sized to fit a column (`#3604`, `:11486`) and reads as ordinary content, and color already carries state, so the underline is the only thing marking text as clickable. Text that is not a link stays plain: on a terminal without OSC 8 support, `wt list` still prints the dev-server URL in full, unadorned. Tests: a unit test pins the helper output, and a statusline test asserts both segments carry a helper-built link. Snapshots updated for the reordered escapes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) > _This was written by Claude Code on behalf of Maximilian Roos_ Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The RUN_TMP comment restated tests/CLAUDE.md's socket-path arithmetic in a second framing (bytes past the per-test root vs. bytes under the default one), so the two could only drift. The Taskfile now carries the operational fact and a pointer; the quantified story stays in tests/CLAUDE.md → Profiling the Suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The picker rendered its rows with hyperlinks enabled and then stripped the OSC 8 escapes afterwards, keeping the underline. A row showed an underlined `#3604` and `:11486` with nothing behind either. This is the follow-up to #3643, which established the "every link is underlined" policy; the picker was the one place the policy was being asserted about text that wasn't a link. The cause was one boolean answering two questions. `supports_hyperlinks(Stream::Stdout)` decided both whether the terminal draws OSC 8 (which is what lets a dev-server URL collapse to `:port`, since the URL rides inside the escape) and whether this render's output delivers the escape intact (which the picker answers differently, because skim mangles it). Each cell called the probe independently, so a row's cells could disagree, and the fix for one cell wouldn't reach the other. `LinkStyle` now answers both once per render: `Linked` (underlined OSC 8 link), `Unlinked` (the same short text, no escape and no underline) and `Expanded` (no OSC 8 in the terminal, so a dev-server URL prints in full and stays copyable). It lives on `LayoutConfig` and rides into `ColumnGrid`, so `--prs` rows rendered off-thread present references exactly as the worktree rows beside them do rather than hardcoding `false`. `Destination` pairs it with the width because one fact decides both: `wt list` writes to the terminal and gets the full width plus intact escapes, while the picker gets a narrower list (skim keeps the rest for the preview pane) and no escapes. The four post-hoc `strip_osc8_hyperlinks` calls in the picker are gone, since nothing emits links there to strip. Two things fell out. `render_cell` was carrying four `LayoutConfig` fields as separate arguments; it now takes the layout, which is also how it got back under clippy's 7-argument cap without a suppression. And the "collapse to `:port`" decision stayed tied to the terminal probe rather than to link emission, so the picker's URL column keeps its narrow form instead of expanding to the full URL. ## Reviewer notes `src/commands/list/layout.rs` holds `LinkStyle` and `Destination` with the rationale; `src/commands/list/collect/mod.rs` is where the picker and `wt list` diverge, keyed on the same `progressive_handler.is_some()` the surrounding picker forks already use. ## Testing `test_link_markup_is_uniform_across_a_row` renders one item under both styles and asserts the CI reference and the dev-server port agree: both underlined links under `Linked`, both plain with no escape and no underline under `Unlinked`. `test_format_cell` additionally pins that `Expanded` and `Unlinked` read the same for a PR reference, which has no URL to fall back on. Beyond the suite, checked against a scratch repo with a URL template: `wt list` emits the dim-plus-underline-plus-link form closing with `[24m` so the dim survives, and the picker's rows render `:12107` with no escape and no underline. The port still collapses in the picker, which was the regression risk worth confirming. > _This was written by Claude Code on behalf of max_ Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scratch-TMPDIR leak listing was a second goal riding the profiling task, and it carried all the machinery: the /tmp rooting, the sun_path byte budget, and the comments justifying both. As a tripwire it was weak anyway — it fired only when someone happened to profile. The task is now build, /usr/bin/time, junit pointer; the leak-check recipe survives as one sentence in tests/CLAUDE.md, and the fixed-directory convention (no TempDir in a static) keeps the leak class dead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task runs commands under mvdan/sh, which implements the time reserved word, so no external binary is involved — the hardcoded path existed to dodge a shell-keyword ambiguity that resolves the other way. This also removes the GNU-time dependency on minimal Linux that review flagged, by deletion rather than documentation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
One correctness issue in the /usr/bin/time → shell-time switch: the time keyword in Task's interpreter (mvdan/sh) only measures wall time — it prints user/sys as a hardcoded 0m0.000s, with a // TODO: can we do these? in interp/runner.go right above the two lines that emit them. So the new time cargo nextest … produces:
real 1m23.456s
user 0m0.000s
sys 0m0.000s
That undercuts the task's whole premise. tests/CLAUDE.md still says wall time is unreliable here (sibling worktrees building/testing) and to track user/sys instead — but those are now exactly the two numbers that are always zero. GNU /usr/bin/time reported them including children; the shell keyword can't. So this trades a portability caveat for the loss of the only reliable metric the task documents.
Two defensible resolutions, depending on what you're after — flagging rather than pushing since it's your call:
- Keep GNU
/usr/bin/time(restore the portability note), ifuser/sysaccounting is the point. - Keep the shell keyword but rewrite the docs to stop promising
user/sysand to treatrealas the (noisy) metric — which weakens the CPU-accounting rationale.
Nothing else in the incremental diff is a concern; the pending-snap removal and simplification read fine.
| # Build first so the measurement covers the test run alone. | ||
| - cargo nextest run --no-run --features shell-integration-tests | ||
| # `time` is task's interpreter's own keyword (mvdan/sh), so no external | ||
| # binary is involved; `user`/`sys` include every child process. |
There was a problem hiding this comment.
mvdan/sh's time keyword doesn't include child user/sys — it hardcodes both to 0m0.000s (// TODO: can we do these? in interp/runner.go, just above the user/sys outf calls). Only real (wall) time is real here, so this comment's claim that user/sys "include every child process" is inverted — they're always zero.
| task profile-tests # CPU accounting, per-test timings, temp-leak check | ||
| ``` | ||
|
|
||
| The integration binary dominates: ~2,200 tests averaging ~0.6s, each spawning `wt` and `git` against a fresh fixture copy. Two thirds of the suite's CPU is kernel time, so the cost is process creation and filesystem churn rather than computation, and it sits in a broad middle rather than in a few outliers. Track `user`/`sys` from the `time` line; wall time is unreliable whenever a sibling worktree is building or testing, which on this project is most of the time. Per-test durations land in `target/nextest/perf/junit.xml`. |
There was a problem hiding this comment.
This line still tells the reader to track user/sys because wall time is unreliable — but after the switch to the shell time keyword, user/sys are always 0m0.000s (mvdan/sh doesn't collect child rusage). So the guidance now points at the two zeroed columns and leaves only the wall time it warns against. Whichever way you resolve the Taskfile side, this sentence needs to match.
Measured where the test suite's CPU actually goes, fixed what was doing real extra work, and added
task profile-testsso the measurement is repeatable.Baseline
cargo nextest run --features shell-integration-testson an 18-core M-series machine: 4,570 tests, ~95s wall, 988 CPU-seconds (332 user + 655 sys).Two thirds is kernel time, so the cost is process creation and filesystem churn rather than computation. The integration binary is 86% of summed self-time: 2,184 tests at ~0.6s each, each spawning
wt(a 65 MB debug binary, ~11ms CPU per spawn against 2.6ms for a trivial process) andgitagainst a fresh fixture copy. It sits in a broad middle, not a few outliers: 73% of self-time is in tests taking 0.25 to 2.0s.One leaked temp directory per test
isolated_test_cwd()held aTempDirin aLazyLock. Statics don't run destructors at process exit and nextest runs one process per test, so every test leaked an empty directory into the system temp root. Measured withTMPDIRpointed at a fresh directory: 704 per integration-suite run. This machine had accumulated 454,907 entries, 353,268 of them empty strays older than a day.Stale entries are cheap to ignore but expensive to enumerate, and
git::recover::recover_from_pathreads every ancestor directory of a deleted CWD:test_recover_from_path_returns_none_for_unrelated_pathOne fixed directory replaces it. Leaks per run: 704 to 0, verified across full suite runs, and the directory is still empty after ~9,000 test executions.
I also checked whether a crowded temp root slows ordinary temp operations. It does not: create, populate and delete of a fixture-sized tree ran at 21ms/iter in a 454k-entry parent against 40 to 60ms in an empty one. The leak's cost is concentrated entirely in code that enumerates.
Fixture temp dirs no longer sit in the shared temp root
The fixtures created their temp directories directly in the system temp dir, among however many entries the machine had put there.
test_temp_root()($TMPDIR/wt) roots them one level in, andtest_tempdir()replacesTempDir::new()across everyTestRepoconstructor, the mock-command helper, thetemp_homefixture and the recovery tests. That test again: 14.2s to 0.06s.Two constraints worth knowing, both found by trying the more aggressive version first:
~/Library/Cachesis under/Users/, which a conditionalincludeIf "gitdir:/Users/"matches, so 16 picker tests fail their commits undercommit.gpgsign— they drive git throughRepository::run_command, the production API with no isolated config.step_promoteabove was not a one-off: the suite was hermetic against host git config only because macOS puts$TMPDIRoutside/Users/. That is the hole the next section closes — at the layer that covers in-process git, not by choosing where temp files live.sun_path, 104 bytes on macOS. The canonicalized per-user$TMPDIRis 56, andtest_copy_ignored_skips_non_regular_filesbinds a listener 89 bytes in.worktrunk-testsoverflowed by one byte;wtcosts 3 of the 14 spare.The ~240 tests that call
tempfiledirectly still use the system temp dir. They are transient and a clean run leaks nothing, so converting them is tidiness rather than a fix.A test that passed by accident of TMPDIR location
step_promote::test_promote_bare_repo_with_worktreesdrove git through bareCmd::new("git")instead ofconfigure_git_env, so the host's config applied. A conditionalincludeIf "gitdir:/Users/"enablingcommit.gpgsignfails its commit, but only whenTMPDIRsits inside the matched tree. macOS putsTMPDIRunder/var/folders, so it passed; pointingTMPDIRanywhere under the home directory failed it.The suite was not actually isolated from the developer's git config
Chasing the temp-root change turned up a real hole.
TestRepoexposes the productionRepositorytype, andRepository::run_commandbuilds a plainCmd::new("git")with noGIT_CONFIG_GLOBAL, so it inherits the test process's own environment. Separately, a barewt_command()had noGIT_CONFIG_GLOBALat all and fell through to~/.gitconfig. 280Repository::at/current/discoverconstructions across 31 test files and 156 directrun_command*calls in test code sat on that path.Signing was only the symptom that surfaced. Confirmed leaking from a real developer config:
commit.gpgsign,core.fsmonitor(spawns a daemon per fixture repo),worktree.guessremote(changesgit worktree add, directly under test),help.autocorrect = prompt(a mistyped git command blocks). Structurally:core.hooksPath,credential.helper,filter.*clean/smudge anddiff.externalall execute arbitrary programs;url.*.insteadOfrewrites remotes;merge.conflictstyle,diff.context,rebase.autostash,fetch.prune,push.defaultall change what the code under test observes. That set is unbounded, which is why hardening each fixture's local config was rejected — a denylist can't cover it, and local config cannot unset an inherited[include]orcredential.helperat all.The floor is one constant,
shell_exec::HERMETIC_TEST_GIT_ENV— the deny pair pointingGIT_CONFIG_GLOBALandGIT_CONFIG_SYSTEMat a path that does not exist, plus the settings the suite needs throughGIT_CONFIG_COUNT/GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n— applied to every child at its spawn site. There is no git-config file anywhere in the repo. For the spawn sites the harness owns (git_test_env,configure_git_cmd,isolate_subprocess_envforwtchildren,pty_env_varsforenv_cleared PTY children) that's ordinary per-child env. For the git that production code spawns while a test drives it in-process, the test never holds the command — so the harness flips an atomic latch (shell_exec::enable_hermetic_test_env, called from the fixture constructors), andCmd, the choke point every production spawn passes through, applies the floor to each child while the latch is set. Setting env on a child is safe; it was setting the test process's own env that wasn't (set_varraces the other test threads), and the latch dissolves the need for it — nounsafe, no pre-mainconstructor, no cargo[env]. Because the latch lives in the binary, every runner agrees by construction:cargo test, nextest,cargo llvm-cov,cargo bench, an IDE, a debugger, a directly executedtarget/debug/deps/integration-*..config/nextest.tomlwas tested and rejected, and is now ruled out standing: nextest 0.9.132 has no[env]key, and a$NEXTEST_ENVsetup script would miss the three non-nextest runners CI uses (cargo llvm-cov, the Nixcargo testderivation,cargo bench). A runner-specific knob doesn't fail loudly when another runner misses it — it yields a different result, usually in the coverage job whose numbers gate a merge.tests/CLAUDE.md→ One Result Per Test, Whatever Runs It records the rule;.config/nextest.tomlpoints at it from the place someone would be tempted.Acceptance test, since the suite passed before only by accident of
$TMPDIRsitting outside/Users/: withtest_temp_root()temporarily pointed under$HOMEso a conditionalincludeIf "gitdir:/Users/"fires, the picker tests go from 20 of 38 failing to 38 of 38 passing.The cost: the latch is a test-serving switch compiled into
shell_exec— one static, one relaxed load per spawn, markedTODO(hermetic-env)with the structural alternative (threading an explicit env value throughRepository).cargo run -- <cmd>is untouched: nothing in production latches it, so a developer's own invocations keep their aliases, credential helper, and identity. The one production git spawn that bypassesCmd(the fsmonitor daemon launch) re-applies the floor by hand. (Two earlier shapes were tried and replaced: cargo's[env], which taxed everycargo runand vanished whenever a test binary ran outside cargo, and a pre-mainconstructor crate, which every test target had to link and which put the floor on developers'cargo bench-adjacent runs too.)In-process tests were reading the developer's worktrunk config too
config_path()'s third priority is the real~/.config/worktrunk/config.toml, and a lib-crate test cannot set the second for itself —set_varisunsafeand this crate forbidsunsafe. So a test reaching priority 3 got the developer's own config. Apanic!build of the guard named the live callers immediately:git::repository::tests::prewarm_*read it on every run, andset_skip_shell_integration_prompt/set_skip_commit_generation_promptreach the same resolver to write.Priority 3 is now absent under
#[cfg(test)], which is compiled out of the real binary — so unlike the git floor above, this one costscargo runnothing. It returnsNonerather than panicking likeapprovals_path(): that guards a mutation target where silent absence would let a test believe it saved something, whereas this is a lookup whose absent state is already handled —require_config_path()turns it into an error, so a write still fails loudly while a best-effort read preloads nothing.The guard covers lib-crate tests only;
src/commands/andsrc/output/link the lib in non-test mode. Nothing there exercises the fall-through today (968 bin-crate tests create no config under a scratch$HOME), so that is a requirement on new tests, recorded intests/CLAUDE.md.system_config_path()stays unguarded on purpose — machine-wide file, andconfig::deprecation'sPendingDefaultrules need the lookup.Merging main's parallel fix
#3620 attacked the same in-process hole from the other side, writing
LOCAL_TEST_CONFIGinto each fixture's own.git/config. The two compose rather than compete and both are kept: the floor denies the host's config to every git, and the local config supplies what a hermetic in-process git still needs — an identity, which the floor deliberately withholds because it has per-command homes already (git_test_env,LOCAL_TEST_CONFIG) anduseConfigOnlyfails loudly if a path misses both. main's structure (TestConfigPaths,TestRepo::bare) is kept as-is.Two docstrings were true on each side and false together:
test_gitconfig_pathrestated the gitconfig inline, andLOCAL_TEST_CONFIGsaid in-process git reads the developer's~/.gitconfig, which is what the floor prevents.test_gitconfig_pathalso held itsTempDirin aLazyLock, the leak this PR removes. Both are moot now: the function is gone with the files.Why there is no gitconfig file
The isolation went through two file-based shapes before this one, and neither earned its keep. Denial never needed a file, because
GIT_CONFIG_GLOBALandGIT_CONFIG_SYSTEMare the denial; the file existed only to set things, andGIT_CONFIG_COUNTis git's environment spelling of-c. Deleting both files also deletes the[include]that kept them from drifting, the per-process write that broketest (windows)on a shared path, and the config-path argument threaded through 22 call sites.The floor that remains is the deny pair plus two settings.
user.useConfigOnlyis a backstop: denial alone leaves git guessing an identity from the OS username and hostname rather than failing, which is the one way a hermetic suite could still author a commit as the developer. Nothing exercises it, and that is the reason to keep it.rerere.enabled = falseis set rather than left unset, so the suite's rerere state cannot depend on what a fixture happens to carry.commit.gpgsign,advice.mergeConflictandadvice.resolveConflictare gone: denial leaves git on its own default for the first, andtests/fixtures/template-repo/gitconfigcarries the other two into the fixtures that need them.Once the latch made denial universal, the redundant copies went too:
git_test_envno longer restates the deny pair per command (the floor is denial's only writer, so its value is uniform across every transport),LOCAL_TEST_CONFIGdropped itscommit.gpgsign(denial guarantees the default), the platform-dependentNULL_DEVICEconstant is deleted, and the.env.GIT_CONFIG_GLOBALsnapshot redaction is gone — the recorded value is one cross-platform constant, so there is nothing volatile to redact.I removed
rerere.enabledfirst, on a local measurement that was wrong, and CI failed on all three platforms. The standard fixture is built once intotarget/debug/wt-test-fixtures/and copied per test, and that cached copy held anrr-cachedirectory left by a rebase run while the floor still enabled rerere. Git turns rerere on by itself wheneverrr-cacheexists, so every local test kept the behavior the change had just removed, while CI built the fixture fresh and lost it.tests/CLAUDE.mdnow records the trap: clear the fixture cache before trusting a local measurement of a git-config change.Why the floor can't live in a fixture: every other test variable is set on a child —
git_test_envon a git command,configure_cli_commandon awtsubprocess. In-process git is not a child the test configures:Repository::run_commandis production code building a plainCmd::new("git"), and the test never holds that command, so there is no place to set env on it — while setting the test process's own environment is the one thing a test can't do safely (set_varraces the other test threads). The identity did move to the fixtures and the per-command env this way; the denial reaches production's children through the latch atCmd, the choke point they all pass through.Two things fall out of
-csemantics, both pinned:init.defaultBranchcannot live in the floor:default_branch.rssets that key in a repo to provewtreads it, and an entry would silently win. The three harnessgit initcalls that relied on the floor now name their branch, as the other two already did.env_cleared, so it inherits nothing and used to get the floor through the file.pty_env_varscopies the family across, pinned bypty_env_vars_carry_the_git_config_floor— the settings only quiet advice and refuse a guessed identity, so no PTY assertion would have caught their loss.Four tests wrote their own gitconfig to get
init.defaultBranchplus an identity; the harness supplies both, so those writes are gone too. Net 45 lines lighter, and no snapshot changed.Measurement
task profile-testsbuilds first, then runs the suite under CPU accounting, printing every test over 1s with its duration (aperfnextest profile that lowers slow-timeout purely as a reporting threshold, with noterminate-after). It began with a scratch-TMPDIRleak check bolted on, which dragged asun_pathbyte budget into the Taskfile; the profiling goal never needed it, so the task is now three lines and the leak-check recipe is one sentence intests/CLAUDE.md. Method and how to read the numbers:tests/CLAUDE.mdunder Profiling the Suite.Found, measured, not changed
The gate keeps a duplicate cargo artifact set.
RUSTFLAGS='-D warnings'on the insta step is part of cargo's fingerprint, so it forks all 343 crates into a second artifact set: 261 CPU-seconds to prime plus a duplicate oftarget/debug/deps(target/debughere is 35 GB, in a 52 GBtarget/). I removed it and then put it back: the clippy step that would cover it runs on ubuntu only, while the cross-platform matrix runswt hook pre-merge --yes insta, so this RUSTFLAGS is the only thing denying warnings on macOS and Windows.[lints.rust] warnings = "deny"would keep that coverage everywhere without forking the graph (verified locally: fails on a planted unused variable, recompiles only worktrunk and wt-perf, feature-check commands still pass), but it also makes plain local builds fail on warnings and newly exposescargo msrv verifyand the minimal-versions job. That is a workflow call. The duplication is a one-time cost per artifact set rather than per-edit, so it is second-order next to the ~600 CPU-seconds each suite run costs.recover_from_pathenumerates every ancestor up to/. At each ancestor it reads the directory and stats.gitin every child. Bounding the child scan to the first existing ancestor would preserve both documented layouts (sibling and nested) and both of that module's regression tests, but it would break a customworktree-pathlayout where the repo is a child of a higher ancestor, so it needs a decision about which layouts recovery must support.Nothing in the gate is the biggest cost; concurrency is. The five
[[pre-merge]]keys are one table, so they run concurrently, and each is a cargo command that wants the whole machine. They serialize on cargo's build-directory lock (Blocking waiting for file lock on build directoryappears in every run) while test execution overlaps another step's build. Thelockfilecomment says it "must be first", which concurrent execution does not provide. Separately, agent worktrees run whole gates at once: during this work a second worktree ran its ownwt hook pre-mergealongside mine, load average hit 154 on 18 cores, and the same suite took 147.7s instead of 92.9s.Tried and rejected
[profile.dev] debug = "line-tables-only"first measured 14% less CPU, but per-spawn CPU was unchanged, which did not fit the proposed mechanism. Re-measuring both configurations on a quiet machine gave 602.6s (baseline) against 606.5s (line-tables). The original delta was contention from a sibling worktree running its own suite.macOS Gatekeeper (
syspolicyd) looked like a candidate at 230% CPU, but 300 spawns of the freshly builtwtcost it 0s, and 0.2s after a relink, against 0.4s during a 10s idle baseline.Verification
cargo run -- hook pre-merge --yesgreen, 4,611 tests passed, run against a freshly built fixture cache after the switch to the latch. The latch was verified to be the only source of the variables: the invoking shell carries noGIT_CONFIG_*, and the meta-tests (in_process_git_reads_only_the_hermetic_configasserting the origin of every resolved setting,pty_env_vars_carry_the_git_config_floor, and theisolate_subprocess_envscrub test asserting the floor is re-set after the scrub) pin each transport. Across earlier runs one hit a single intermittent PTY failure inshell_wrapper(exit 127) that passes 3/3 in isolation and whose code path never toucheswt_command()or the shared cwd; a later run was green under heavier load than the one that failed.