Skip to content

perf(runtime): stop reverse-resolving the bind address, and stop paying for idle waits - #236

Merged
gcko merged 9 commits into
mainfrom
perf/macos-test-runtime
Aug 28, 2026
Merged

perf(runtime): stop reverse-resolving the bind address, and stop paying for idle waits#236
gcko merged 9 commits into
mainfrom
perf/macos-test-runtime

Conversation

@gcko

@gcko gcko commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

What

The macOS test leg ran 415s against Ubuntu's 92s, and the whole quality gate waited on it.

The errors flooding that job's log were not the cause: they are 12 --host argparse refusals, the same 12 Ubuntu emits, and they cost no time. They are still worth killing, because a green run that reads like a failure is its own problem, and that is cause 5.

Five causes, found by measurement rather than inspection. The first is the big one, and it is a bug users hit.

1. A reverse DNS lookup on every bind

HTTPServer.server_bind sets server_name from socket.getfqdn() — a reverse DNS lookup, on the startup path, for a value nothing in this codebase reads. A resolver with no answer for 127.0.0.1 does not fail fast, it waits.

On the macOS runner that was ~17.5s per bind. A per-test timing probe pushed to the macOS leg found seven tests were 285s of that leg's 316s, while the other 1936 finished in 31s:

 70.04s  test_wildcard_bind_admits_a_remote_host_header_over_a_socket
 36.83s  test_copied_plugin_launches_without_repository_imports
 36.68s  test_cli_help_diagnose_status_stop_and_invalid_arguments_are_stable
 35.52s  test_daemon_outlives_its_caller_and_stops_on_request
 35.52s  test_copied_plugin_starts_when_one_next_font_is_missing
 35.05s  test_the_listener_takes_its_reuse_policy_from_the_config
 35.03s  test_wildcard_bind_serves_a_remote_host_header

The clustering at ~35s is what identified it — that is a fixed stall, not slowness. test_the_listener_takes_its_reuse_policy_from_the_config does nothing but construct two servers on 127.0.0.1 and assert a boolean, and it took 35s: two binds, ~17.5s each. Ubuntu answers the same lookup from /etc/hosts instantly, which is why this looked like a macOS problem rather than a lookup nobody needed.

This is a startup fix, not only a CI fix. It is the same stall a person starting the dashboard on a machine with a slow resolver sits through before the page comes up.

2. serve_forever() called bare

socketserver polls for shutdown every 0.5s by default, and shutdown() blocks until the accept loop next wakes and notices. The suite stands up about a hundred servers. 28s of test_http_api's 44s, all asleep. Test servers now poll at 5ms; the shipped serve path keeps the stock interval, because the trade is right in a test and wrong in a daemon.

3. One node process per page-JS check, 425 of them

Measured: node startup was 40ms of each check's 44ms — the spawn was the bill, not the 260KB of page script it ran. A single long-lived worker now runs every check in a fresh vm context, which is the same isolation a fresh process gave. The page script uses no require, no process and no node module machinery, which is what makes a bare context enough.

The worker is recycled every 150 checks: each check compiles the page into its own context and V8 reclaims those lazily, so it sat at 430MB after 425 checks and climbed to 592MB over three times that. Recycling costs two 40ms spawns and holds it flat at 261MB.

4. A rejected POST re-draining a body it already read

Every POST that read its body and then refused it re-drained bytes already gone: _drain_body reads Content-Length again, the peer has nothing left to send, and read1 blocks until REJECT_DRAIN_SECONDS gives up. A validation 400 on /api/ask cost 252ms of its handler thread — on the shipped route, not only under test. Now 0.4ms.

5. The log noise

Three tests refuse twelve --host values between them, and each refusal goes through
parser.error(), which writes a usage block plus the message to stderr. Nothing caught it, so a
green run buried the whole suite's log under seventy-odd lines that report nothing wrong and read
exactly like a failure. test_lifecycle already redirected stderr around the same argparse exit;
these three never did.

Captured now rather than discarded, because the text was worth something: each caller asserts the
refusal is about --host, where before any SystemExit from any parse error would have passed. The
suite's stderr goes from 64 lines to 5, and the macOS job's test step from 13.8KB to 3.9KB.

Verification

Measured on CI, same workflow, before vs after:

before after
Tests (macos-latest) 415.5s 73.7s
Tests (windows-latest) 184s 121s
Tests (ubuntu-latest) 92s 22.5s
whole quality gate, wall 7m25s 2m40s
macOS job log, test step 13.8KB 3.9KB
local macOS suite 135s 62s
test_http_api 44s 12s
subprocess spawns 930 89
rejected POST, /api/ask 252ms 0.4ms
page-JS worker peak RSS n/a 261MB, flat

macOS is no longer the long pole; Windows is. An intermediate CI run with causes 2-4 fixed but not
the bind stall measured 319s, which is what identified the bind stall as the dominant remainder.

Every new test was confirmed red against the unfixed code:

  • the bind test, by patching socket.getfqdn to raise — asserted by patching rather than by timing, because on a machine with a working resolver the call is instant and a duration assertion could not fail;
  • the drain tests, at 0.253s over a socket and by counting the read the drain attempts at the handler. The handler-level assertion counts attempts rather than bytes on purpose: BytesIO answers a read past the end immediately, so a byte-count assertion cannot see the bug that only a socket exhibits.

Full pre-PR suite run locally against the merged tree: ruff, ruff format --check, mypy --strict, lint_embedded, validate_plugins, bump_version --current, no version fields moved since the merge base, and both suites under coverage (90.8%, threshold 73). 1959 tests green.

What this does not touch

The remaining slow tests are the launcher characterization ones that genuinely spawn server.py. Cutting those would cut real coverage, so they stay.

🤖 Generated with Claude Code

The macOS test leg ran 415s against Ubuntu's 92s, and the whole quality gate
waited on it. Almost none of that was work: profiled locally, the suite burned
135s of wall clock for 60s of CPU.

Three things were charging for nothing, none of them macOS-specific in cause —
only in price, because process creation and scheduling are what macOS makes
dearest.

`serve_forever()` was called bare, so `socketserver` polled for shutdown every
0.5s and each `shutdown()` blocked until the accept loop next woke. The suite
stands up about a hundred servers; that alone was 28s of `test_http_api`'s 44s,
all of it asleep. Test servers now poll at 5ms, and the shipped serve path keeps
the stock interval — the trade is right in a test and wrong in a daemon.

`PageJsHarness` spawned one node per check, 425 of them. Measured, node startup
was 40ms of each check's 44ms: the spawn was the bill, not the 260KB of page
script it ran. One long-lived worker now runs every check in a fresh `vm`
context, which is the same isolation a fresh process gave.

And a real defect behind the third: every POST that read its body and then
refused it re-drained bytes already gone, so `read1` blocked until
REJECT_DRAIN_SECONDS gave up. A validation 400 on `/api/ask` cost 252ms of its
handler thread, on the shipped route, not only under test. It now costs 0.4ms.
Pinned both ways — at the handler, by counting reads the drain attempts, and
over a socket, against the deadline it used to wait out.

Local: 135s to 60s, 1943 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jared Scott <jared.scott@variable.team>
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Coverage

Name                                                                  Stmts   Miss Branch BrPart  Cover
-------------------------------------------------------------------------------------------------------
cargento/skills/cargento/agy_hook.py                                     79     14     28      7  78.5%
cargento/skills/cargento/cargento_runtime/__init__.py                     0      0      0      0 100.0%
cargento/skills/cargento/cargento_runtime/aggregate.py                  212      1     64      0  99.6%
cargento/skills/cargento/cargento_runtime/asks.py                       110      0     28      0 100.0%
cargento/skills/cargento/cargento_runtime/claude_data.py                305     33    142     16  89.0%
cargento/skills/cargento/cargento_runtime/cli.py                        126     14     26      3  87.5%
cargento/skills/cargento/cargento_runtime/collectors/__init__.py          0      0      0      0 100.0%
cargento/skills/cargento/cargento_runtime/collectors/antigravity.py     410     41    166     25  87.2%
cargento/skills/cargento/cargento_runtime/collectors/claude.py          261     19     94     12  90.7%
cargento/skills/cargento/cargento_runtime/collectors/codex.py           101      7     38      7  89.9%
cargento/skills/cargento/cargento_runtime/collectors/copilot.py         148      6     50      2  96.0%
cargento/skills/cargento/cargento_runtime/collectors/cursor.py          277     20    106     17  89.8%
cargento/skills/cargento/cargento_runtime/collectors/droid.py            32      3      6      1  89.5%
cargento/skills/cargento/cargento_runtime/collectors/gemini.py           53      7     16      4  84.1%
cargento/skills/cargento/cargento_runtime/collectors/goose.py            89     11     28      4  87.2%
cargento/skills/cargento/cargento_runtime/collectors/opencode.py         78      6     26      2  92.3%
cargento/skills/cargento/cargento_runtime/collectors/pi.py              326     34    152     20  88.7%
cargento/skills/cargento/cargento_runtime/config.py                     187      1     20      1  99.0%
cargento/skills/cargento/cargento_runtime/diagnostics.py                 84      4     26      4  92.7%
cargento/skills/cargento/cargento_runtime/dismissals.py                 113      2     28      2  97.2%
cargento/skills/cargento/cargento_runtime/events.py                     162      0     62      0 100.0%
cargento/skills/cargento/cargento_runtime/http_api.py                   525     36    176     10  93.2%
cargento/skills/cargento/cargento_runtime/io.py                         126      2     28      0  98.7%
cargento/skills/cargento/cargento_runtime/lifecycle.py                  323     15    104      6  95.1%
cargento/skills/cargento/cargento_runtime/notifications.py              174     14     60      4  91.5%
cargento/skills/cargento/cargento_runtime/observation.py                235      2     64      0  99.3%
cargento/skills/cargento/cargento_runtime/observer.py                   249     34    110     13  84.7%
cargento/skills/cargento/cargento_runtime/probe.py                       44      0     18      1  98.4%
cargento/skills/cargento/cargento_runtime/quota.py                      333      2    112      1  99.3%
cargento/skills/cargento/cargento_runtime/records.py                    256      5    114      9  96.2%
cargento/skills/cargento/cargento_runtime/sessions.py                   101      0     44      0 100.0%
cargento/skills/cargento/cargento_runtime/snapshot.py                    36      0      4      0 100.0%
cargento/skills/cargento/cargento_runtime/spacedock.py                  456     48    238     26  89.0%
cargento/skills/cargento/cargento_runtime/state.py                       65      0      2      0 100.0%
cargento/skills/cargento/cargento_runtime/stream.py                      57      0      8      0 100.0%
cargento/skills/cargento/cargento_runtime/transcripts.py                509     29    266     26  92.9%
cargento/skills/cargento/cargento_runtime/turns.py                      197     14    104     12  90.7%
cargento/skills/cargento/cargento_runtime/web/__init__.py                 0      0      0      0 100.0%
cargento/skills/cargento/cargento_runtime/web/page.py                    54      0     14      0 100.0%
cargento/skills/cargento/event_hook.py                                   86      4     28      3  93.9%
cargento/skills/cargento/mcp_server.py                                  377     22    112     14  92.2%
cargento/skills/cargento/notify_hook.py                                  49     15      6      1  67.3%
cargento/skills/cargento/server.py                                        3      0      2      1  80.0%
cargento/skills/cargento/statusline_hook.py                             131     13     46      8  87.0%
scripts/bench_collect.py                                                211     10     54      6  94.0%
scripts/bench_event_latency.py                                           67     67     14      0   0.0%
scripts/bump_version.py                                                  60     12     24      5  77.4%
scripts/capture_hook.py                                                 287     30     86     11  88.5%
scripts/derive_prompt_shapes.py                                         210     16     88     14  89.3%
scripts/lint_embedded.py                                                 92      3     28      2  95.8%
scripts/validate_plugins.py                                             663    186    386     58  69.3%
-------------------------------------------------------------------------------------------------------
TOTAL                                                                  9129    802   3446    358  89.7%

Threshold: fail_under in pyproject.toml · label coverage-exception to bypass (visible in PR timeline).

gcko and others added 6 commits August 28, 2026 13:15
Every check compiles the 260KB page script into its own vm context, and V8
reclaims those lazily: the worker sat at 430MB after the suite's 425 checks and
climbed to 592MB when the same checks ran three times over. Nothing leaks that a
restart cannot clear, so the process is replaced every 150 checks — two extra
40ms spawns, and a flat 261MB however many page tests the suite comes to hold.

A recycled worker's pipes were left to the finalizer, which reported them as an
unclosed-file ResourceWarning in the middle of an unrelated test. Closed on the
way out instead, after joining the stderr drain.

The drain thread is now handed its pipe rather than reading `self._proc`, which
a restart rebinds — it would otherwise have ended up reading the replacement's
stderr alongside its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jared Scott <jared.scott@variable.team>
Attributes the macOS/Ubuntu gap to specific tests instead of guessing at it
from a wall of progress dots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jared Scott <jared.scott@variable.team>
`HTTPServer.server_bind` sets `server_name` from `socket.getfqdn()` — a reverse
DNS lookup, on the startup path, for a value nothing in this codebase reads. A
resolver with no answer for 127.0.0.1 does not fail fast, it waits.

Measured on the macOS CI runner at roughly 17.5s per bind. Seven tests that bind,
or spawn something that binds, were 285s of that leg's 316s; the other 1936
finished in 31s. Two of them do nothing but construct servers on 127.0.0.1 and
still took 35s each, which is what identified the call. Ubuntu answers the same
lookup from /etc/hosts instantly, which is why the leg looked like a macOS
problem rather than a lookup nobody needed.

It is the same stall a person starting the dashboard on a machine with a slow
resolver would sit through before the page came up, so this is a startup fix
that happens to also be a CI fix.

`server_name` and `server_port` are still set, because the base class promises
them; the name is the host as given, and the port is read off the bound socket
so a requested 0 reports what the OS actually handed out.

Pinned by patching the lookup rather than by timing it: on a machine with a
working resolver the call is instant, so a duration assertion would be a test
that cannot fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jared Scott <jared.scott@variable.team>
@gcko gcko changed the title perf(tests): stop paying for idle waits and node spawns perf(runtime): stop reverse-resolving the bind address, and stop paying for idle waits Aug 28, 2026
gcko and others added 2 commits August 28, 2026 13:43
`docs/design-daemon.md` gains a rejected entry for inheriting
`HTTPServer.server_bind`, because the override looks like something to tidy away
and restoring the `super()` call reinstates a 17.5s-per-bind stall. It cost a CI
probe round-trip to find; nobody should have to find it twice.

`CONTRIBUTING.md`'s known-flake note described page tests shelling out to node
per check with a 30-second subprocess timeout. They now share one worker, so the
failure it told contributors to re-run past no longer exists, and the two
failures that replace it look nothing like it. The harness bullet gains what a
fresh vm context does not give back: the process no longer restarts, so anything
a check leaves on a timer outlives it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jared Scott <jared.scott@variable.team>
Three tests refuse twelve `--host` values between them, and each refusal goes
through `parser.error()`, which writes a usage block plus the message to stderr.
Nothing was catching it, so a green run buried the CI log for the whole suite
under seventy-odd lines that report nothing wrong and read exactly like a
failure. The suite's stderr goes from 64 lines to 5.

`test_lifecycle` already redirects stderr around the same argparse exit; this
brings the three stragglers to that convention, via one helper.

Captured rather than discarded, because the text was worth something: each
caller now asserts the refusal is about `--host`, where before any SystemExit
from any parse error would have passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jared Scott <jared.scott@variable.team>
@gcko
gcko merged commit 13f28c3 into main Aug 28, 2026
12 checks passed
@gcko
gcko deleted the perf/macos-test-runtime branch August 28, 2026 05:58
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.

1 participant