chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Diffs are read four at a time, highlighted off the async threads
Merged Approved
This pull adds a four-permit semaphore (DIFFING) around files, so at most four diffs are read at once. It also moves the row drawing and highlighting into spawn_blocking. The change is small and matches how BLAMING and ARCHIVING already work. I found no bug.
- Permit scope: the permit is held across git, the blob reads and the drawing, so the bound covers the whole diff.
filestakes it once and nothing inside it takes it again, so I see no way for it to deadlock itself. - Docs:
docs/design/serve.mdis updated as AGENTS.md asks. - Changeset:
Changeset: noneis right, since the change is server-side only. - Checks: I did not read the run results. The author says nothing was run locally, so CI has to confirm the build and the tests.
One note, not a reason to hold the pull: expect("drawing a diff does not panic") also fires if the blocking task is cancelled while the runtime shuts down. That only matters during shutdown.
Some checks were cancelled
1 cancelled, 4 passed, 6 skipped
1 cancelled check
deployCancelled in 4m 56sMerged as b1fd56b
On 2026-11-08 at 03:11 UTC a crawler posing as browsers asked for about a hundred commit and pull diffs in a minute, and every answer of the site, a profile or a tree as much as a commit, took seconds to half a minute. Diffs are now read four at a time, their highlighting off the server's async threads, so such a burst waits on itself. A look at the reasoning in Why is what it needs.
Claude Opus 5.5 · high
What changes
At most four diffs are read at once.
filesincrates/knotless-serve/src/web.rs, whichgetCommit,getThread(a pull's files),getPullDiffandgetCompareall read a diff through, takes a turn of a semaphore of four (DIFFING) before it runs git, as blames (BLAMING) and archives (ARCHIVING) already do. The rest wait their turn.A diff's rows are highlighted off the async threads. Drawing the files with their rows, which parses the whole of each side with tree-sitter, runs in
spawn_blocking, asgetLastCommits' walk and the cards' drawing do.docs/design/serve.mdsays both, under Highlighting and diffs, and the Crawlers section points there.Why
From
admin_logs, 2026-11-08 03:11:20 to 03:12:25 UTC (the bulk of the burst; four stragglers to 03:46):getCommitbehind them went on (trace 57252dba638aa0b4ff7133f8699280e1: getRepo, then getCommit 29.8 s, no page line).cat-file807 s (186 lines, 136 of them undergetCommit),git -c … show/diff201 s (57, about half eachgetCommitandgetThread), thendiff,log,rev-list,rev-parsefrom a pull's range, 16 to 32 s each.getCommit?firstRows=true: 71 slow, median 9.8 s, max 29.8 s;getThread?filesOnly=true: 29, median 5.5 s, max 23.2 s.getTreeandgetLastCommits2.3 to 2.9 s,getProfileandgetActorRepos2.4 to 5.8 s, an outgoing call answered with a 404 in 2.3 s (trace 03d75dc8e4eda2e38631de293988b04a). None of those run git per request.The
cat-filelines are the tell:textsreads a handful of blobs withcat-file --batch, which takes milliseconds, yet took 1 to 26 s. Its output is drained through a pipe by the runtime, and serve's runtime has as many threads as the container has cores, two.fileshighlighted every drawn file inline on those threads, "a good part of a second for a long file" by our own notes, for dozens of requests at once; meanwhile 60-odd git processes shared the same two cores. So git's output went unread, every request's future went unpolled, and the whole site stalled behind the crawler's diffs.Holding diffs to four at a time keeps the burst's git processes and highlighting to what the cores can do, and moving the highlighting off the runtime keeps the two async threads free for every other answer. A crawler of a hundred commit pages then queues on itself: each diff takes its uncontended time, a few hundred milliseconds, four at a time, and its own pages wait in turn, while trees, profiles, pushes and pulls' conversations answer as before.
Others considered:
knotless::git. Mirror syncs, the gateway's pushes and runner checkouts use the sameGit; a crawl would queue a push. Bounding what a crawler can ask for, as blames and archives are, keeps it to the burst.knotless-pageswhoever asked, and the burst spread over five agents at a page or two a second. A limit tight enough to stop it would catch someone opening a pull's tabs, and there is nothing yet to measure it against.getCommit's answer by sha. It never changes, but the crawler asked for 49 different commits in its 71 slowgetCommitlines, so a cache would have spared at most a third, and none of a crawler walking new pages. Highlighting, the dearest part, is already kept by text for the last 128 texts, so a repeat of the same commit parses nothing again.Under a repeat of the same burst: its diffs run four at a time and its pages wait for their turn; nothing else waits on them but the CPU the four take, and a person's commit page waits behind at most the crawler diffs already queued, each taking its uncontended time.
How it was checked
Nothing was run here, the build, clippy and tests are left to CI. Behaviour is unchanged but for when a diff runs; the existing diff tests cover
fileswith and without rows.Shortcomings
A pull's other git (
log,rev-list,rev-parse,difffor its range) is not bounded, nor is a blob page's highlighting; neither was slow but for the stall. A person's commit page waits behind a crawler's queued diffs, as no request says who asked. A crawler's page abandoned by its client still has its diff read when its turn comes.Changeset: none