Skip to content

perf(session): stop background load starving the focused shell [1] - #202

Draft
AbysmalBiscuit wants to merge 14 commits into
mathix420:masterfrom
AbysmalBiscuit:perf/focus-priority
Draft

perf(session): stop background load starving the focused shell [1]#202
AbysmalBiscuit wants to merge 14 commits into
mathix420:masterfrom
AbysmalBiscuit:perf/focus-priority

Conversation

@AbysmalBiscuit

@AbysmalBiscuit AbysmalBiscuit commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

TL;DR (human written)

DO NOT MERGE YET
I'm still testing this to make sure there are no weird edge cases. Opened the PR already to start setting up the perf pr stack. A UX/feature PR stack will come after that. ^^

This may be mainly a windows problem, where it doesn't manage process priorities as well as UNIX OSs. So when something would load CPU (e.g., several tsc typechecks, cargo build, etc.), it made alacritree unusable (see below for numbers).

This PR fixes the priority problems on Windows by setting a process group for the focused session to boost it's priority 1 level above the default. This fixes most of the UX problems under load.

There's a placeholder file other.rs if these kinds of issues happen on other OSs, and they would need their own implementation.

Perf improvements for opening a shell:

shell installed (before) perf build (after)
Windows nushell ~27s ~5s
WSL fish ~56s ~14s

Perf improvements for typing: 5-10s -> instant when typing slowly 1-2s lag when mashing keyboard

Closes AbysmalBiscuit#25

Claude summary below:


A build saturating every core starves the shell you are typing into. The line editor redrawing its prompt waits behind sixteen compilers at the same scheduling class, and the keystroke round trip stretches into seconds. Windows Terminal and Zed stay responsive under the same load.

Raising the shell's priority alone does not reach it, because a Windows priority class does not spread to the processes a raised process starts. A job object does: a process joins the job it is created in and comes up at the job's class, at any depth and with no scanning. The session takes its job at spawn rather than on focus, since anything started before the job exists escapes it permanently.

What this adds

[ui] focus_priority_boost, off by default, Windows only. Only the session on screen, and only while the window has focus, is raised. The GUI follows so it does not lose to the tree it draws. On Unix the platform module is a no-op: a nice value is already inherited and cannot be lowered back without privilege.

Releasing the boost lowers the members already running and then clears the limit, so an unfocused tab's build or agent owns its own class again. A job's priority class is a ceiling rather than a setting, so clearing it matters.

[ui] reap_descendants_on_close, also off by default, gives the job kill-on-close. The console reaps only the programs attached to it, so anything that leaves it outlives the terminal and piles up until reboot. The job object is the only thing holding every descendant at every depth, and because the kernel does the ending, a killed or crashed alacritree is covered as well as a closed tab. A process that means to outlive the terminal asks with CREATE_BREAKAWAY_FROM_JOB.

Both options share one job, created when either wants it. The lifetime limits travel with the priority one on every set, because SetInformationJobObject replaces LimitFlags whole.

Measured

Spawn to a usable prompt for nu.exe under 64 spinning burners went from ~15s to ~3s.

Tests

The reaping tests tear down real pseudoconsoles and check what outlived them. The escaping child is started with DETACHED_PROCESS, so it is nobody's console client, the shape of a completion helper an editor spawns. Two arms keep the claim falsifiable: unjobbed the child outlives teardown, jobbed it does not. An unjobbed baseline pins any failure on the harness rather than the feature.


Written by Claude Opus 5 in Claude Code.

AbysmalBiscuit and others added 4 commits August 31, 2026 14:57
A build saturating every core starves the shell the user is typing
into: the line editor redrawing its prompt waits behind sixteen
compilers at the same scheduling class, and the keystroke round trip
stretches into seconds.

Raising the shell alone does not reach that, because a Windows priority
class does not spread to the processes a raised process starts. A job
object does: a process joins the job it is created in and comes up at
the job's class, at any depth and with no scanning. The session takes
the job at spawn rather than on focus, since anything started before
the job exists escapes it for good.

Only the session on screen, and only while the window has focus, is
raised; the GUI follows so it does not lose to the tree it draws.
Behind `[ui] focus_priority_boost`, off by default, and Windows only.
A Unix nice value is already inherited and cannot be lowered back
without privilege, so the platform module there is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A job's priority class is a ceiling, not a setting.  Releasing the boost
named normal but left the limit standing, so every process an unfocused
session held was capped there and could not raise itself — a build or an
agent running under a background tab lost the class it asked for, and
kept losing it until that tab was focused again.

Release now names normal to lower the members already running, then
clears the limit so they own their class again.  Drop is the same two
steps, so it needs nothing beyond releasing the boost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The console reaps only the programs attached to it, so anything that
leaves it — an editor's background search, anything started detached —
outlives the terminal that asked for it and piles up until the machine
is rebooted.  Nothing else holds those processes: the job object is the
only thing that has every descendant at every depth.

`[ui] reap_descendants_on_close`, off by default, gives the job
kill-on-close, so the kernel ends its members when the last handle goes.
That covers a killed or crashed alacritree as well as a closed tab,
since no cleanup path has to run.  Breakaway rides with it: a process
that means to outlive the terminal asks with `CREATE_BREAKAWAY_FROM_JOB`
and is let go, which is a request the kernel refuses without the flag.

The lifetime limits travel with the priority one on every set, because
`SetInformationJobObject` replaces `LimitFlags` whole and a change of
focus would otherwise drop them.  One job serves both options, so it is
created when either wants it.

The tests tear down real pseudoconsoles and check what outlived them,
against an unjobbed baseline that pins any failure on the harness rather
than the feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reaping option shipped without a test that the console misses
anything, so nothing said whether the job was fixing a real leak.

The escaping child is now a process started with `DETACHED_PROCESS`,
which has no console and so is nobody's console client — the shape of a
completion helper an editor spawns.  Only a process already inside the
session can start one, so the session runs this binary again and an
ignored test does the spawning; that also keeps the desktop clear, where
a new console would have opened a window.

Two arms make the claim falsifiable: unjobbed, the child outlives the
teardown, and a failure there would mean there is no leak to fix; jobbed,
it does not.  The session is jobbed before its tree is waited for, as a
real session does it, because a process joins a job when it is created
and anything already running stays outside it for good.

Members carry their name and their kernel-reported job membership, so a
survivor says on its own whether the model or the flag is at fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cmd.exe subjects and the taskkill sweep carried no CREATE_NO_WINDOW,
so each one flashed a console window during a test run, and the subjects
holding a `ping` open kept theirs for a minute. command_ext exists for
this; the tests pipe their stdio and assert on job membership and
priority, neither of which the flag touches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AbysmalBiscuit and others added 9 commits August 31, 2026 19:41
A test binary has no `main`, so hardening was left to each test that
opened a pseudoconsole. The focus-priority tests added one that does not,
and once any PTY opens first the module it loaded answers every later
`LoadLibraryW("conpty.dll")` in the process. That is the three-second
console-host stall the handshake test exists to catch, and it started
failing on CI for tests that never touched it.

Harden immediately before each `tty::new` instead, guarded by a `Once` so
the repeat costs nothing and `main` keeps its startup call. The per-test
calls go away with the convention they were upholding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pid came from a subject the test had already killed and reaped, so
Windows was free to hand that number to anything.  Under a parallel run
it handed it to another test's freshly spawned subject, which then read
ABOVE_NORMAL where it expected NORMAL.

Use a number the kernel never allocates, and assert that it names no
process rather than trusting the arithmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every subject was born at whatever Windows chose, and the boost tests
then asserted that choice was normal.  Windows hands a new process its
creator's job class when the creator is in a job, and half of these
tests build jobs, so under a parallel run a subject sometimes came up
above normal and the first assertion failed before the boost was even
applied.

Pass the class in the creation flags.  The one test whose subject exists
to report Windows' choice keeps making none, and says what to suspect
if it ever comes up raised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`tree_of` walked the process table by parent pid alone.  Windows frees a
pid for reuse the moment the last handle to it closes, so an orphan goes
on naming the number its dead parent had and whoever is given that number
next inherits it as a child.  The teardown arms taskkill every survivor
they find, which turned a wrong answer here into an unrelated process on
the machine being killed: a build's stray vctip.exe was reaped as part of
a session tree it never belonged to.

No child predates its parent, so a start time earlier than the parent's
rules the candidate out.  A process the snapshot cannot open reports no
start time at all, and those keep their place rather than being read as
born at the epoch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Font fixtures went into the shared system temporary directory under fixed
names.  Unique within one binary, but two binaries running at once write
the same paths, and a fixture one of them has mapped cannot be rewritten
by the other: Windows fails the write with ERROR_USER_MAPPED_FILE.  Five
fonts tests failed together in one run here, which is what a second
binary mapping its whole fixture set looks like.

Hang the fixtures off a directory named for the process instead, and
sweep the ones whose process has gone.  The names lose the prefix that
was disambiguating them inside the shared directory.

Closes #46

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The probe for a foreign console host asserted that a pane's child
exited inside two seconds, then ten. Both bounds are wall-clock, so a
Windows runner that descheduled the polling thread failed the test with
nothing wrong: once on each bound, both passing on re-run.

What the probe is there to catch is `LoadLibraryW("conpty.dll")`
resolving out of PATH, and that is observable directly. A file named
conpty.dll that is not a module tells the two loader outcomes apart: a
loader that reaches PATH finds it and reports ERROR_BAD_EXE_FORMAT, a
hardened one reports ERROR_MOD_NOT_FOUND. Both arms run as child
processes because SetDefaultDllDirectories is process-wide and cannot
be undone, so the parent cannot host the unhardened arm.

The control arm pins the unhardened code as well, so a plant the loader
never reaches fails the test rather than passing it by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`the_probe_hands_the_scan_to_the_refresher` asserted the call returned
within 5 ms. A bound that tight fails whenever the runner deschedules
the thread, and it cannot distinguish that from the failure it is
aiming at, since a probe that scanned inline would blow it for the same
reason a busy machine does.

`probe` reads the published map and registers interest; it never
scans. The assertion above it already pins the answer to the default,
which only an unscanned probe can return, and the cost the bound stood
for is what `report_process_probe_cost` reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`each_phase_measures_only_its_own_span` slept 20 ms to give the first
phase a span, then asserted the second one came in under 10 ms. The
bound is what fails when a runner deschedules the thread between two
adjacent statements, and 10 ms is close enough to be reachable.

Back-dating `since` gives the first phase five seconds without waiting
for them, and the claim is stated against it: a second phase still
dating from the frame's start would be at least as long as the first,
so `second < first` is the property, not an absolute ceiling. The test
also stops costing 20 ms of every run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The diff-pane title tests spawned a real `cmd /c exit` through ConPTY
purely to obtain a `Session`, then polled up to ten seconds for the
child to exit so its startup title could not race the injected one.  A
loaded runner reaches that bound with nothing wrong, which is what broke
CI.

The session under test needs no PTY: the tests inject their sequence
straight into the terminal and drain it.  Building it as a struct
literal removes the child, the wait and the racing title together.

Co-Authored-By: Claude Opus 5 (1M Context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: typing starves under CPU load, and the loop was never the cause

1 participant