Skip to content

Cut relay hot-path overhead - #142

Merged
D0n9X1n merged 15 commits into
mainfrom
perf/hot-path
Oct 3, 2026
Merged

D0n9X1n merged 15 commits into
mainfrom
perf/hot-path

Conversation

@D0n9X1n

@D0n9X1n D0n9X1n commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Summary

Cuts the per-request overhead found in the #141 performance review. Each fix is its own commit with a regression test.

  1. undici 7.30 (package.json, package-lock.json). undici 7.28 checked an idle keep-alive socket with an unref'd setTimeout(0) before reusing it; on Windows that timer can wait for the next system timer tick. 7.29.1 and later use a ref'd setImmediate. Test: a second request over the same idle connection schedules no zero-delay timer (fails on 7.28.0).
  2. Upstream keep-alive 50 s (src/copilot/client.ts). Copilot sends no Keep-Alive hint, so undici's 4 s default closed every idle upstream connection and the next request paid for a new TCP and TLS handshake. In the [Perf]: Cut relay hot-path overhead: log writes, idle-socket timer, request copies, schema rebuilds, upstream keep-alive #141 probe Copilot reused a connection idle for 60 s and had closed one idle for 120 s. Test: a connection idle for 5.5 s is reused (fails before). Internals states the cost: a NAT or proxy that silently drops connections idle for less than 50 s can now leave a request waiting for an operating system timeout or the upstreamTimeoutSeconds deadline.
  3. Log writer (src/lib/log.ts). Each entry ran its own directory checks, open, stat, chmod, append and close, and read its timestamp and dated file after those awaits, so a burst could reach the file out of order. Entries are now stamped and assigned their dated file when logged, and appended in call order by one drain. The file stays open while entries keep coming and is closed a second after the last one. The handle is reused only while the path still names the same private file with one link (mode 0600 on POSIX) and the app and logs directories are still the ones checked when it was opened (not links; mode 0700 on POSIX); otherwise the next batch reopens with the full checks. Tests that fail on main: a 50-entry burst keeps call order through one open; an entry logged at 23:59:30 local time and written after midnight keeps its stamp and dated file; batches written within a second share one open. Further tests: a renamed or replaced file, a second hard link, a logs folder replaced by a link, and a loosened file or folder mode (POSIX) are not written through the reused handle; the file is closed a second after the last entry; concurrent flushLogs calls resolve; the idle timer does not use the global setTimeout.
  4. Request trace (src/lib/request-trace.ts). Every upstream request body was copied into a Buffer to count its bytes, even with capture off. It is now counted with Buffer.byteLength and encoded only when recorded. Test fails before.
  5. Tool schemas (src/copilot/tool-schema.ts). normalizeResponsesToolSchema rebuilt every schema on every Responses request. It is now copy-on-write: a schema that needs no change comes back as the same object, and an omitted pattern copies only its path. Tests: same object and path-only copy (both fail before); a __proto__ property name stays data; a copied array keeps its holes and length.
  6. Tokenizer preload (src/start.ts, src/lib/tokenizer.ts). The first count_tokens request loaded the gpt-tokenizer module and built the encoder while Claude Code waited. After preflight, start loads o200k_base and each supported tokenizer the configured models report, logs Tokenizers loaded: ..., then listens. A failure is logged and loading falls back to first use. Tests: the preload unit test fails when the preload loads nothing, and the packed smoke (scripts/package-smoke.mjs) checks that start logs Tokenizers loaded before its server listens; a build that preloads after startServer fails it.

Docs: wiki Internals, Architecture, Logging and troubleshooting, and Development (EN and 中文), and CLAUDE.md.

Closes #141

Scope

Relay internals only. No route, config key, request or response shape changes. One new startup log line: Tokenizers loaded: <names>.

Left unchanged, as listed in #141: per-delta JSON parse and serialize, WebSearch retained chunks, catalog discovery after a base-URL reload, the consola reporter, keep-alive toward Claude Code, and synchronous counting after the preload.

Review follow-ups

Opus and GPT-6 Astra reviewed the first seven commits; Opus also checked efdd1e9. Neither found a blocker. Each finding below has its own commit, test first:

  • 7fa8a0d (GPT-6 Astra). Two concurrent flushLogs calls never resolved: each waited for the close the other had just queued. Each call now waits for its own close and closes again only while entries are queued. Test: two concurrent calls in a child process with a timeout; it hung before.
  • 410d0c3 (GPT-6 Astra). The reused handle kept appending after the logs folder was replaced by a link to where the open file had been moved; main refused that write. The writer now records the app and logs directories when it opens the file and reuses the handle only while both are unchanged. Tests: a logs folder replaced by a link (failed before), and on POSIX a loosened logs folder mode is restored before the next append.
  • 1645930 (Opus). On Windows the kept-open handle blocked renaming or moving the logs folder for as long as the relay ran. The file is now closed a second after the last entry. Test: one entry, then the handle is closed 1.5 s later (failed before). Logging and troubleshooting (EN and 中文) describe the remaining window.
  • 78eec3d (both). The tests pinned neither handle reuse nor the file identity check, and the stamp test crossed midnight only when run in the last minute of a local day. New tests: batches within a second share one open, and an entry logged after the file is replaced by a new private file goes to the new file; the stamp test now starts at 23:59:30. The tokenizer test checks membership instead of the exact cache contents.
  • 9f91c80 (GPT-6 Astra; also noted by Opus). A copied schema array turned holes after the first changed element into own undefined elements. JSON request bodies have no holes, so requests were unaffected. The copy now keeps holes and the length. Test failed before.
  • a0ffef2 (Opus). Internals (EN and 中文) states the cost of the 50 s keep-alive.
  • 25d3ccf (local gate and CI, not review). The idle close from 1645930 used the global setTimeout, which auth-recovery.test.ts replaces to capture the token refresh timer. The test captured two timers and failed on all six legs of CI run 37094710202. The idle timer now comes from node:timers. Test: the global setTimeout records nothing across a logged entry and a flush; it recorded [1000] before.

Both reviewers then re-checked 25d3ccf. GPT-6 Astra found its four findings resolved and no new merge-blocking defect in f53b1c1..25d3ccf, and recommended merging. Opus found its four findings and the schema holes resolved and no new defects in efdd1e9..25d3ccf, and gave a merge verdict with three optional nits: Logging and troubleshooting names only the logs folder, though renaming ~/.copilot-relay also fails while the file is open; the idle-close test sleeps a fixed 1500 ms, where polling for the closed handle would leave more margin; and node:test MockTimers enabled for setTimeout also replaces node:timers' setTimeout, which no test enables today. The PR was merged after GPT-6 Astra's verdict and before Opus's report arrived.

Validation

Local, Windows, Node v24.19.0, on the tree of 25d3ccf:

  • npm run typecheck
  • npm run test:unit (872 pass, 0 fail, 30 skipped)
  • npm run test:integration (389 pass, 0 fail)
  • npm run build

The gate ran on 8cb8a91, which has the same tree as 25d3ccf; only two commit messages differ (see Notes).

Every test marked above as failing before its fix was run against the code before that fix and failed. The tests in 78eec3d were also checked by breaking src/lib/log.ts: a reuse check that never passes fails the shared-open test (3 opens, expected 1), and removing the file identity check fails the replacement test. Removing the hole check or the length assignment from mapArray fails the array test.

Measurements

main (78eb4f2, undici 7.28.0) against this branch at 25d3ccf, run back to back on one Windows laptop (Node v24.19.0) against a mocked Copilot upstream. Each figure is from one run unless the row says otherwise; timings vary between runs. The bench scripts are the ones from the #141 review and are not part of this PR.

Relay in a child process, quick mode:

main branch
File system calls per request at log level info (gpt / chat / native routes) 72.0 / 72.0 / 72.0 12.1 / 11.2 / 11.3
Relay CPU per request, ms (gpt / chat / native) 22.40 / 21.37 / 19.80 9.93 / 6.77 / 6.23
Time to the first streamed marker, p50 ms (gpt / chat / native) 7.8 / 6.0 / 5.3 6.0 / 4.9 / 4.5
setTimeout(0) from undici's idle-socket check, per request 1.00 0.00
Time to the first streamed marker, p50 ms, at log level error / info 14.85 / 7.24 5.22 / 5.40
Of 32 concurrent requests, file log order differs from call order 28 0
New upstream connections after 6 s idle, no Keep-Alive hint 1 0
New upstream connections with Keep-Alive: timeout=5, after 3.5 s / 6 s idle 1 / 1 1 / 1

In process, per call, at request sizes of 204800 / 1048576 / 4194304 bytes:

main branch
Tool schema normalization, wall p50 ms 0.473 / 0.463 / 0.463 0.119 / 0.12 / 0.118
Responses payload build, wall p50 ms 0.485 / 0.596 / 0.928 0.136 / 0.226 / 0.577
Responses payload build, heap rise p50 bytes 764296 / 1170280 / 2789664 253536 / 659584 / 2279056

Instrumented relay, 50 requests with 204800-byte bodies at log level info. The instrumentation wraps JSON, Buffer.from and three fs methods to count calls, so its CPU figures include that wrapping. Each mode ran twice; the call and byte counts are from the first run:

main branch
mkdir / lstat / open calls, non-streaming 700 / 1400 / 350 2 / 571 / 1
mkdir / lstat / open calls, streaming 800 / 1600 / 400 2 / 559 / 1
Bytes passed to Buffer.from, non-streaming / streaming 10040586 / 10049930 147934 / 157206
Relay CPU per request, non-streaming, two runs, ms 19.06, 21.26 10.28, 7.8
Relay CPU per request, streaming, two runs, ms 33.44, 25 13.74, 10.3

Token counting in a fresh process, three runs each: on main the first count took 602.5, 605.7 and 607.8 ms. On the branch the startup preload took 579.5, 575.4 and 584.3 ms before the server listens, and the first count took 5.6, 5.9 and 5.7 ms.

Notes

  • Log file handle. The relay keeps the active log file open while it writes and closes it a second after the last entry. Moving or deleting the file while the relay runs makes the next entry create a new file at the dated path. On Windows, renaming or moving the logs folder fails with access denied while the file is open; Logging and troubleshooting says to wait a second after the last entry or stop the relay first. On POSIX the app and logs directory modes are checked before each reuse; a loosened mode reopens the file, which re-tightens it, as does each retention pass. A failed retention pass no longer drops the queued entries.
  • Keep-alive is not a config key. 50 s follows Copilot's measured idle behavior; Internals states the cost. A server that sends a Keep-Alive: timeout=N hint still gets undici's hint-based timeout.
  • Lockfile. This machine installs through a mirror; the resolved URL for undici was set back to registry.npmjs.org with the same integrity hash. npm ci passed on every CI leg against that hash.
  • Commit messages. After pushing, the messages of the last two commits were reworded and the branch force-pushed with a lease. The keep-alive docs commit, now a0ffef2, had attributed the request to both reviews instead of Opus, and the timer fix, now 25d3ccf, named the docs commit's previous hash. The trees are unchanged.
  • CI. Run 37094710202 failed on all six legs in tests/unit/auth-recovery.test.ts (2 !== 1); 25d3ccf fixes the timer collision behind it, and run 37095824238 on 25d3ccf passed on all six legs. On an earlier push, Windows Node 26 failed once in tests/unit/release-pipeline.test.ts, where a Git Bash utility probe hit its 10 s timeout with no output; a rerun of that job passed, and the test does not touch the code changed here.
  • No token values are logged; the new startup line names tokenizers only.

Checklist

  • Public API remains Claude Code-only unless intentionally changed.
  • Config changes are reflected in config.default.yaml, README, and wiki/ (EN and ZH). No config changes.
  • Logs do not expose tokens.
  • Integration tests mock upstream Copilot; they do not call real Copilot services.

After merge

  • Remove inactive local/remote feature branches and temporary worktrees, prune stale refs, and report preserved work; follow the Development Wiki cleanup rules.

perf/hot-path was deleted on origin and locally, and stale refs were pruned. The only worktree is the main checkout; nothing was preserved. The wiki publish run on 5e16372 succeeded, scripts/publish-wiki.py verify passed on a fresh clone of the wiki (19 pages), and each of the 18 English and 中文 links on the live Home page opened its page, including the new Internals and Logging and troubleshooting text.

🤖 Generated with Claude Code

@D0n9X1n D0n9X1n added this to the v0.4.4 milestone Oct 3, 2026
D0n9X1n and others added 7 commits October 2, 2026 19:38
undici 7.28 checked an idle keep-alive socket with an unref'd
setTimeout(0) before writing the next request on it. On Windows that
timer can wait for the next system timer tick when no other I/O wakes
the event loop. undici 7.29.1 and later run the check with
setImmediate.

The new test sends a second request over the same idle connection and
fails if it schedules a zero-delay timer. It fails on 7.28.0 and passes
on 7.30.0.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot sends no Keep-Alive hint, so undici closed every upstream
connection idle for longer than its 4 s default, and the next request
paid for a new TCP and TLS handshake. In the #141 probe Copilot reused a
connection idle for 60 s and had closed one idle for 120 s. The
dispatcher now keeps idle connections for 50 s.

The new test reuses a connection after 5.5 s idle against an upstream
that, like Copilot, sends no hint.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each log entry ran its own directory checks, open, stat, chmod, append
and close, and read its timestamp and dated file only after those
awaits. A burst of entries could reach the file out of order, and an
entry logged just before local midnight could be stamped late or filed
under the next day (#141).

wrapFileLog now stamps each entry and resolves its dated file when it
is logged, then queues it. One drain at a time appends queued entries
in call order, in writes of at most 256 KiB that end on entry
boundaries, through a handle kept open between batches. The handle is
reused only while its path still names the same private file with one
link, and on POSIX mode 0600; a rename, deletion, replacement, second
hard link or loosened mode reopens it with the existing checks.
Retention runs once per batch, and its failure no longer drops the
entries. A failed write still never fails a request: it drops that
batch and closes the handle. flushLogs waits for queued writes and
closes the file.

New tests: a burst of 50 entries keeps call order through one open, an
entry is stamped at call time, and a renamed file, a second hard link
or a loosened mode is not written through. The first two fail on the
previous writer.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
RequestTrace.fetch copied every upstream request body into a Buffer to
count its bytes, then discarded the copy when the trace recorded
nothing. append now takes the body as sent and counts a string with
Buffer.byteLength; the body is encoded only when the capture records
it.

The new test sends a multibyte body with capture off, checks the
manifest's byte count, and fails if the body was passed to Buffer.from.
It fails on the previous code.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
normalizeResponsesToolSchema rebuilt every tool schema on every
Responses request with Object.fromEntries and flatMap, though most
schemas need no change. It now returns a schema that needs no change as
the same object and copies only the path to an omitted pattern. The
input is never changed.

New tests: a schema with only supported patterns comes back as the same
object, an omitted pattern copies only its path, and a __proto__
property name stays data in the copy. The first two fail on the
previous code.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first count_tokens request for an encoding loaded the gpt-tokenizer
module and built its encoder while Claude Code waited. After preflight,
start now loads o200k_base, the fallback, and each supported tokenizer
the configured models report, then logs the loaded names before the
server listens. A preload failure is logged and leaves loading to the
first count, as before.

The new test checks the loaded encoders after a preload and fails when
the preload loads none.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Internals now describes upstream keep-alive and the undici floor,
tokenizer loading at startup, copy-on-write tool schemas, and the
queued log writer with its handle reuse checks. Architecture lists
preloadTokenizers in the startup order. The active log file is resolved
for each entry when it is logged; CLAUDE.md, Architecture and Logging
and troubleshooting now say so, and the troubleshooting page says a
moved or deleted active file is replaced at the dated path. English and
中文 pages change together.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
D0n9X1n and others added 8 commits October 2, 2026 19:47
The tokenizer preload test calls preloadTokenizers directly, so nothing
checked that start runs it before its server listens. The packed smoke
now logs at info, and its network guard writes SMOKE_LISTENING to stdout
when the server listens. The smoke fails if "Tokenizers loaded" is
missing or comes after that marker. A build that preloads after
startServer fails it.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
flushLogs waited until the shared write chain stopped changing. Two
overlapping calls each saw the close the other had just queued, queued
another, and neither resolved; with no file I/O left the loop starved
the event loop. Each call now waits for the close it queued and closes
again only while entries are still queued.

The new test runs two concurrent flushLogs calls in a child process
with a timeout. It hangs on the previous code and passes now.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The reused log handle was checked only by lstat of the file path, which
follows links in the parent path. A logs directory replaced by a link
to the folder the open file was moved to led back to the same file, so
entries kept going there; main refused that directory on every write.

The writer now records the app and logs directories when it opens the
file and reuses the handle only while both are still those directories:
real directories, not links, and on POSIX mode 0700. Otherwise it
reopens with the full checks, which refuse a linked directory and
tighten a loosened mode.

New tests: an entry is not appended through a logs folder replaced by
a link (fails before), and on POSIX a loosened logs folder mode is
restored before the next append. Internals describes the directory
check and that each flushLogs call waits for its own close.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Windows cannot rename or move a folder while a file in it is open, so
the kept-open handle locked the logs folder for as long as the relay
ran; main held the file open only during an append. The writer now
closes the file once no entry has been written for a second, through
the write chain so the close never runs during a drain. The next entry
reopens the file with the full checks. The timer is unref'd and
flushLogs clears it.

The new test logs one entry and checks that the handle is closed 1.5 s
later; it fails before. Internals and Logging and troubleshooting (EN
and 中文) describe the close and the Windows folder lock.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both reviews asked for tests that fail when the reuse check is wrong in
either direction. New tests: batches written within a second share one
open of the unchanged file, which fails when the reuse check never
passes (3 opens, expected 1); and an entry logged after the active file
is replaced by a new private file goes to the new file, which fails
without the file identity check. The stamp test now starts its clock at
23:59:30 local time, so the minute that passes before the write crosses
midnight. The tokenizer preload test checks that the expected encodings
are loaded and the unknown name is not, instead of the exact cache
contents and order.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The copy-on-write array copy replaced main's value.map, which keeps
holes and the array length. After the first changed element the copy
pushed every later index, so a hole became an own undefined element.
Schemas parsed from a JSON request body have no holes, so requests were
not affected; a sparse array passed in directly now gets the same copy
main made. The copy skips holes, writes each changed element at its
index, and keeps the input length.

The new test copies an anyOf array with holes, one of them trailing; it
fails before. Removing the hole check or the length assignment also
fails it.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Opus review asked Internals to state the trade-off of the 50 s idle
keep-alive. Internals (EN and 中文) now says that undici processes a FIN
or RST already received on an idle connection before writing to it, and
what happens to a request written to a connection a NAT or proxy
dropped silently: a reset or an operating system timeout is retried
once, and the upstreamTimeoutSeconds deadline fails with a 504 that is
not retried. With the 4 s default only a drop under 4 s could cause
this; now a drop within 50 s can.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The idle close from 1645930 used the global setTimeout.
auth-recovery.test.ts replaces the global to capture the token refresh
timer and expects exactly one. A log drain that finished during its
setup added the log writer's 1000 ms timer first, so the full unit run
failed with 2 !== 1, locally and on all six CI legs; five more local
runs of that file captured the same two timers.
lifecycle-recovery.test.ts also replaces the global, to run poll timers
at once.

The idle close now takes setTimeout and clearTimeout from node:timers,
which those replacements leave alone. The new test records the global
setTimeout across a logged entry and a flush; it recorded [1000] before.
auth-recovery.test.ts passed in three full runs after the change.
Internals (EN and 中文) says where the timer comes from.

Part of #141

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@D0n9X1n
D0n9X1n merged commit 5e16372 into main Oct 3, 2026
6 checks passed
@D0n9X1n
D0n9X1n deleted the perf/hot-path branch October 3, 2026 04:53
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]: Cut relay hot-path overhead: log writes, idle-socket timer, request copies, schema rebuilds, upstream keep-alive

1 participant