Development record
A bounded git commit whose hook never finishes overran its stop by far more than its design allows. In a preflight's race stage, TestAPickCommitAndASyncMergeWhoseHookNeverFinishesAreRefused (internal/core/implement/loop/pickgit_deadline_test.go:105) failed: the pick commit, under a 1s deadline with a pre-commit hook that execs sleep 60, came back after 49.7s against the test's 20s bound, while the worst stop the code allows is about 21s (the 1s limit, a process-table read under its 5s cap, the 5s WaitDelay, then the descendant sweep's 5s grace and its reads of up to 5s each). The plain test stage passed the same package in the same run. The five-minute load was about 280 to 340, mostly Spotlight indexing and a sync client rather than the tests, on 16 cores. An unproven hypothesis: under that load git forks the hook after Cancel has taken its snapshot of git's descendants, so the sleep is outside the set the sweep signals and kills, and something else holds the run until the sleep nears its end. The overrun is in a test bound; no user path that hangs has been shown yet, though abcd implement step runs pick commits through the same call.
A bounded git commit whose hook never finishes overran its stop by far more than its design allows. In a preflight's race stage, TestAPickCommitAndASyncMergeWhoseHookNeverFinishesAreRefused (internal/core/implement/loop/pickgit_deadline_test.go:105) failed: the pick commit, under a 1s deadline with a pre-commit hook that execs sleep 60, came back after 49.7s against the test's 20s bound, while the worst stop the code allows is about 21s (the 1s limit, a process-table read under its 5s cap, the 5s WaitDelay, then the descendant sweep's 5s grace and its reads of up to 5s each). The plain test stage passed the same package in the same run. The five-minute load was about 280 to 340, mostly Spotlight indexing and a sync client rather than the tests, on 16 cores. An unproven hypothesis: under that load git forks the hook after Cancel has taken its snapshot of git's descendants, so the sleep is outside the set the sweep signals and kills, and something else holds the run until the sleep nears its end. The overrun is in a test bound; no user path that hangs has been shown yet, though abcd implement step runs pick commits through the same call.
Evidence
Reproduced on an unloaded machine on 2026-10-29 with a copy of the code that times each phase and can pause the stop between the process-table snapshot and the signals. The hook in each case execs a long sleep.
- With nothing injected and a 1s limit, the call came back in 1.04s: the snapshot took about 19ms and found the hook, and the sweep took about 19ms. Nothing was left running.
- With a 6ms limit and a 2s pause after the snapshot, the snapshot found no descendant, git started the hook during the pause, and the call came back in 7.03s (the limit, the pause and the whole 5s WaitDelay). The hook's sleep was still running afterwards. So a child git starts after the snapshot does escape the stop, and it holds git's output until the WaitDelay closes it.
- That escape costs at most the WaitDelay. It cannot account for 49.7s: with the test's 1s limit it comes to about 6s.
- The escape is closed: the stop now freezes git with SIGSTOP before it reads git's descendants, and each descendant as it finds it, so nothing is started while the stop is under way, and continues every process it stopped once it has signalled it. The sweep freezes what is still running the same way before it kills it. Its reads can now take twice one read's 5s limit, so the worst stop the code allows is about 26s: the 1s limit, 10s of reads while the tree is frozen, the 5s grace (the WaitDelay runs inside it), a last sweep read of up to 5s, and the sweep's freeze of up to 5s. It is reached only when every ps run takes nearly its whole limit and still answers. The overrun this record reports stays open.
Three spans have no bound of their own and are the candidates for the overrun. Starting git is not covered by the deadline, since the context is consulted only once the process exists. The wait for git to exit after the kill blocks for as long as git cannot die, as in a long uninterruptible disk wait under heavy I/O. And the process-table read's own wait has no WaitDelay, so its 5s cap holds only if ps can die when it is killed. None has been shown to be the cause.
Frontmatter
| id | iss-2610292307181518 |
|---|---|
| type | issue |
| lifecycle | open |
| severity | minor |
.abcd/work/issues/open/iss-2610292307181518-bounded-commit-overran-its-stop-under.md
open on GitHub ↗ · commit history ↗