Repository navigation
Cut relay hot-path overhead - #142
Merged
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cuts the per-request overhead found in the #141 performance review. Each fix is its own commit with a regression test.
package.json,package-lock.json). undici 7.28 checked an idle keep-alive socket with an unref'dsetTimeout(0)before reusing it; on Windows that timer can wait for the next system timer tick. 7.29.1 and later use a ref'dsetImmediate. Test: a second request over the same idle connection schedules no zero-delay timer (fails on 7.28.0).src/copilot/client.ts). Copilot sends noKeep-Alivehint, 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 theupstreamTimeoutSecondsdeadline.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; concurrentflushLogscalls resolve; the idle timer does not use the globalsetTimeout.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 withBuffer.byteLengthand encoded only when recorded. Test fails before.src/copilot/tool-schema.ts).normalizeResponsesToolSchemarebuilt 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.src/start.ts,src/lib/tokenizer.ts). The firstcount_tokensrequest loaded the gpt-tokenizer module and built the encoder while Claude Code waited. After preflight, start loadso200k_baseand each supported tokenizer the configured models report, logsTokenizers 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 logsTokenizers loadedbefore its server listens; a build that preloads afterstartServerfails 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 concurrentflushLogscalls 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 ownundefinedelements. 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 from1645930used the globalsetTimeout, whichauth-recovery.test.tsreplaces 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 fromnode:timers. Test: the globalsetTimeoutrecords 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 inf53b1c1..25d3ccf, and recommended merging. Opus found its four findings and the schema holes resolved and no new defects inefdd1e9..25d3ccf, and gave a merge verdict with three optional nits: Logging and troubleshooting names only the logs folder, though renaming~/.copilot-relayalso 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; andnode:testMockTimers enabled forsetTimeoutalso replacesnode: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 typechecknpm run test:unit(872 pass, 0 fail, 30 skipped)npm run test:integration(389 pass, 0 fail)npm run buildThe gate ran on
8cb8a91, which has the same tree as25d3ccf; 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
78eec3dwere also checked by breakingsrc/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 frommapArrayfails the array test.Measurements
main(78eb4f2, undici 7.28.0) against this branch at25d3ccf, 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:
setTimeout(0)from undici's idle-socket check, per requestKeep-AlivehintKeep-Alive: timeout=5, after 3.5 s / 6 s idleIn process, per call, at request sizes of 204800 / 1048576 / 4194304 bytes:
Instrumented relay, 50 requests with 204800-byte bodies at log level info. The instrumentation wraps
JSON,Buffer.fromand threefsmethods to count calls, so its CPU figures include that wrapping. Each mode ran twice; the call and byte counts are from the first run:mkdir/lstat/opencalls, non-streamingmkdir/lstat/opencalls, streamingBuffer.from, non-streaming / streamingToken counting in a fresh process, three runs each: on
mainthe 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
Keep-Alive: timeout=Nhint still gets undici's hint-based timeout.resolvedURL for undici was set back to registry.npmjs.org with the same integrity hash.npm cipassed on every CI leg against that hash.a0ffef2, had attributed the request to both reviews instead of Opus, and the timer fix, now25d3ccf, named the docs commit's previous hash. The trees are unchanged.tests/unit/auth-recovery.test.ts(2 !== 1);25d3ccffixes the timer collision behind it, and run 37095824238 on25d3ccfpassed on all six legs. On an earlier push, Windows Node 26 failed once intests/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.Checklist
config.default.yaml, README, andwiki/(EN and ZH). No config changes.After merge
perf/hot-pathwas deleted on origin and locally, and stale refs were pruned. The only worktree is the main checkout; nothing was preserved. The wiki publish run on5e16372succeeded,scripts/publish-wiki.py verifypassed 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