Repository navigation
Run the test files in one process, and test the dedup ring and the changelog script in process - #18
Merged
Conversation
added 4 commits
October 28, 2026 10:18
…rived Three integration tests delivered a webhook event the server must not pass on, slept 50 ms and then asserted the host had received nothing. On a busy machine 50 ms can pass before anything would have arrived, so the checks could pass without proving anything. Each now asserts the delivery was answered 2xx, so a refused delivery can't pass for an absence. The harness's deliver already waits for the listener to finish handling the event, and the tests then make one host request and await its answer: the server writes to the host in order on one stream, so anything it sent while handling the event has arrived by then. The three checks guard the server's egress check as a whole, not each path inside it.
The test for the webhook listener's dedup ring posted 2004 signed deliveries over loopback, one after another, taking about 5 s of a suite run on the VPS. - webhook: DeliveryRing is exported with its capacity, and the listener has a read-only dedupCapacity beside rememberedDeliveries. - A unit test of the ring at DELIVERY_DEDUP_RING checks 2000 ids remembered, a replay at the boundary still remembered, and two more ids forgetting both gate-0 and gate-1: the ring keeps the last 2000 ids by first arrival, and a replay doesn't move an id. - The HTTP test of a replayed delivery also asserts the listener remembers one id and holds a ring of 2000. With it, the listener's replay branch, its new-id branch and its ring size stay tested through HTTP; eviction is tested on the ring alone. - The test file's header now leaves the ring's case out of "every case goes through a real node:http server". The old test's comment said re-seeing an id moves it to the tail. It doesn't.
Each of the 24 changelog tests ran the script as a child Node process, about 5 s of a suite run on the VPS. - scripts/release-changelog.mjs exports release(dir), which returns the line the CLI prints and throws ReleaseError for a refusal, and ReleaseError. The CLI runs it on the current directory only when the script is the program Node was started with, comparing real paths, since argv[1] keeps a symlinked path that import.meta.url resolves. An absent argv[1], or one that names no file, means it isn't. - scripts/release-changelog.d.mts declares the two exports for the typecheck, which covers the tests. - All 24 existing cases call release() in process with the same assertions. Three new tests spawn Node: the CLI releasing through a symlink to the script; the CLI refusing a duplicate version with a fragment pending, which exits 1 with its reason on stderr and touches nothing; and an import of the script with argv[1] set to a glob pattern, which runs nothing and touches nothing.
node --test starts a process per test file by default, and each one loads tsx and transforms src/ again: about 0.25 s of CPU per file before any test runs. With --experimental-test-isolation=none the files run one after another in one process. With this branch's test files, the flag alone took the suite's CPU from 9.7-9.8 s to 3.6-3.7 s on a shared 12-core host, about 63% less, in two alternating pairs of runs. The verdicts were the same on Node 24.22.1 and 22.12.0, and in three imposed file orders. Node 22.7 refuses the flag and 22.8 takes it, so npm test needs Node 22.8 or later. What one process shares: the only test that changes process-wide state, githubAdapter.test.ts's process.stderr.write swap, restores it in a finally. No test writes process.env, globalThis, process listeners, process.exitCode, the cwd or the umask; src/'s module-level sets are constants, and the poller's timer is unref'd. A handle a test leaves open keeps the run from ending under either mode, so CI's job gets timeout-minutes: 10 instead of GitHub's 360-minute default, and a leak shows as a failed job.
nissa-seru
pushed a commit
to nissa-seru/github-mcpl
that referenced
this pull request
Oct 28, 2026
main now carries the faster test suite (anima-research#18), which edits code lines beside four comments this branch rewrites or removes. Each conflict keeps this branch's comment text with main's code: ReleaseError and DeliveryRing are exported, collectFragments takes the directory, and the listener's dedupCapacity getter stays without the section banner this branch removes.
nissa-seru
pushed a commit
to nissa-seru/github-mcpl
that referenced
this pull request
Oct 28, 2026
main now carries the faster test suite (anima-research#18), which gives CI's job a 10-minute timeout where this branch adds the Node 22 and 24 matrix. The job keeps both.
This was referenced Oct 28, 2026
nissa-seru
pushed a commit
to anima-research/slack-mcpl
that referenced
this pull request
Oct 28, 2026
npm test spent most of its time waiting: about 25 s of every run waited
out the server's own clock (the call budget, the rate-limit and
registration waits) and the fake Slack API's delays, and the child
processes of cli, serverVersion and release-changelog took 11-18 s
more. Each test file also started a process of its own. With the files
in one process the CPU fell by about 70%, but the waits then added up
one after another, so they go too.
- src/clock.ts: clock.now() and clock.sleep(ms, { unref }), Date.now()
and setTimeout by default. slack-adapter.ts' start rule, its check
that a wait fits and its rate-limit sleep, and server.ts' arrival
stamp and registration wait read it. Nothing else changes for the
server. test/clock.test.ts pins the default: a sleep waits out its
time, and holds the process unless it is unref.
- test/virtualClock.ts: virtualClock(t) runs clock.now() and
clock.sleep() on a time fixed when the test calls it, which only its
waits move, and records each wait, so a test asserts its waits went through
the clock. The fake Slack API's elapseMs moves the same time;
silentSlackApi().end() ends the requests it holds.
- The tests that waited run on it: six in slackClient, two in
uploadSteps, one in registrationRecovery, whose superseded
registration now holds its wait open until the test has revoked.
slackClient's and attachmentDownload's queue tests end the held post
with end() rather than a 1 s client timeout. New slackClient tests pin
that a wait counts the time an earlier request's wait used, and that
a call's time starts when it reaches the server.
- One wait stays real on purpose: the first case of "a rate limit the
call can afford" waits Slack's 1 s on the real clock and asserts that
the fake Slack got the request again at least 990 ms after its
Retry-After. A virtual sleep resolves at once, so it is the one check
that sees a retry sent without waiting for the sleep.
- slackClient's acknowledgement test stops its adapter, so its armed
removal can't fire during a later file now that files share a
process.
- slackClient's client-timeout test counts the connections the silent
API took, once it has taken one and the event loop has polled again,
rather than the requests it had read when the call failed. By probe, a stall of the process just
after the client arms its 300 ms timeout lets the timeout end the
request before the same process's server reads it, which failed the
test once on main on a loaded host. A request sent again would need
a new connection.
- test/cli.test.ts: one start of the CLI end to end; the switch cases,
the other refusals and the settings of a complete environment
in-process against startupSettings.
- test/serverVersion.test.ts: two copies of src/ in a fixture with its
own version, at src/ and at the built layout tsconfig's outDir and
rootDir give, imported in-process, from the repository's cwd. It no
longer runs tsc, so it no longer checks that tsc places server.js at
dist/src/; by reading, tsc rewrites neither path string server.ts
reads, and the layout is tsconfig's.
- scripts/release-changelog.mjs, scripts/release-changelog.d.mts and
test/release-changelog.test.ts: github-mcpl's, byte for byte, as
merged in anima-research/github-mcpl#18 (7eaea03), which tests
release(dir) in-process and the CLI three times.
- package.json: npm test runs the files in one process
(--experimental-test-isolation=none, on Node 22.12.0 and 24).
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.
npm testnow runs every test file in one Node process, and the dedup ring's test and the changelog script's 24 tests now run in process, instead of through 2004 HTTP posts and a child Node process per test. Three tests that slept 50 ms before asserting that nothing from an event reached the host now wait for the host to answer a request first. In our runs, every test this change and3cfdfe3share gave the same result at both. In three rounds of runs alternating between the two commits, on a Linux machine with 12 CPUs at a one-minute load average of 97 to 129, the command thatnpm testruns used 68 to 76% less CPU time than at3cfdfe3, and finished 19 to 57% sooner. Of the Node 22 releasesenginesallows,npm testnow needs 22.8.0 or later, and CI's job has a 10-minute timeout.Problem
At
3cfdfe3, themaincommit this change starts from,npm testisnode --import tsx --test test/*.test.ts(package.json:13), so Node runs each of the 18 test files in a process of its own. In our runs of that command, with--test-reporter=tapadded:--experimental-test-isolation=noneadded, the same files used 6.1 to 6.5 s;test/webhook.test.ts:217to:227);test/release-changelog.test.ts's 24 tests took 5.8 to 8.5 s together. Each callsrun(), which runs the script withexecFileSync(process.execPath, [SCRIPT], …)(:61,:63).Three tests in
test/integration.test.tsdeliver a webhook event the server must not pass on, wait 50 ms (:254,:319,:580), and then assert that the host received nothing from it.Changes
Line numbers are at
738b552.14b5668, the three absence checks. Each now asserts that the delivery was answered with a 2xx status (accepted,test/integration.test.ts:131), then makes one host request and awaits its answer (hostCaughtUp,:127), at:263to:264,:328to:329and:589to:590. Each test's assertions that nothing arrived from the event follow (:265to:266,:330,:591to:592). The comment abovehostCaughtUpsays "The server writes to the host in order on one stream, so once the answer to a later request is back, everything it sent before has arrived."a64cc43, the dedup ring.src/webhook.tsexportsDeliveryRingwith itscapacityreadable (:81,:85), and the listener has a read-onlydedupCapacity(:176). The test of 2004 posts is now "the dedup ring remembers 2000 ids and then forgets the oldest" (test/webhook.test.ts:215), which adds ids to aDeliveryRingof 2000 directly. It checks that 2000 ids are remembered, that a replay ofgate-0at that boundary is still remembered, and that two more ids then forgetgate-0andgate-1. The HTTP test of a replayed delivery also asserts that the listener remembers one id and holds a ring of 2000 (:211,:212). The old test's comment said "the oldest aftergate-0was re-seen at the tail isgate-1" (:223to:224at3cfdfe3). The ring's code is the same at both commits apart from itsexportand itscapacityno longer beingprivate, and the new test, which asserts that two more ids forget a replayedgate-0as well asgate-1, passes with it. So the comment described something the ring doesn't do.59c7e3a, the changelog script.scripts/release-changelog.mjsexportsrelease(dir)andReleaseError(:145,:37), andscripts/release-changelog.d.mtsdeclares the two. The script runsrelease(process.cwd())only whenisMain()is true (:205to:214), which compares the real path ofprocess.argv[1]with the script's own (:208). The 24 tests callrelease()in process throughrun()(test/release-changelog.test.ts:59,:61), with their assertions unchanged. Three new tests start Node (:211,:222,:233): the script releasing when run through a symlink, the script refusing with exit status 1 and its reason on stderr, and an import of the script withprocess.argv[1]set totest/*.test.ts, which writes nothing to stdout and changes neither the changelog nor the fragments.738b552, one process and a bound on CI.package.json'stestadds--experimental-test-isolation=none(:13), and CI's job hastimeout-minutes: 10(.github/workflows/ci.yml:16).test/,package.jsonand CI, the change editssrc/webhook.ts(the export, thecapacityno longerprivate, and the getter) and the changelog script (release(dir), the exports,isMain()and the.d.mtsfile).Other open pull requests, by
git merge-treeagainst each one's head at 14:40 UTC on October 28:3cfdfe3.3cfdfe3, and conflict with this change in the same files.3cfdfe3, and conflicts with this change in four hunks: two inscripts/release-changelog.mjsand two insrc/webhook.ts. In each, Comments: keep only those one rule allows, outside the files #11 changes #15 rewrites or removes a comment beside a line this change edits or adds.3cfdfe3, and conflicts with this change in one hunk of.github/workflows/ci.yml, where Use @animalabs/mcpl-core 0.4.0, which needs Node 22.12.0 or later #16 adds a Node 22 and 24 matrix and this change addstimeout-minutes: 10.Whichever of #15 and this change merges second has to resolve those hunks, and the same goes for #16.
What a contributor would notice:
enginesallows (>=22.0.0,package.json:28),npm testneeds 22.8.0 or later. Each release from 22.0.0 to 22.7.0 refuses--experimental-test-isolation=nonewith "bad option", and 22.8.0 takes it. Use @animalabs/mcpl-core 0.4.0, which needs Node 22.12.0 or later #16 changesenginesto>=22.12.0.test/for assignments toprocess.env,globalThis,process.exitCodeandprocess.stderr.writeorprocess.stdout.write, and for calls toprocess.chdir,process.umaskandprocess.on, finds onlytest/githubAdapter.test.ts:1815's replacement ofprocess.stderr.write, which:1822puts back in afinally.3cfdfe3's files, the flag alone used less CPU time in each of our rounds, but finished later in two of the three (table below).What this leaves
test/githubAdapter.test.tskeeps thenode:httpserver on 127.0.0.1 that it points the adapter at. Its header says why: "the paths, query strings, bodies and headers asserted below are the ones that would go to github.com."Tests
On Linux 6.12.113 (x86_64, 12 CPUs) with Node 24.22.1 and npm 11.19.1, in clean checkouts of
738b552and3cfdfe3, each installed withnpm ci(@animalabs/mcpl-core0.3.0, octokit 4.1.4, tsx 4.23.15, TypeScript 5.9.3):Typecheck and build:
npm run typecheckandnpm run buildsucceed at738b552.The suite: at
738b552, 617 tests run: 597 pass, 2 fail and 18 are skipped. At3cfdfe3, 614 run: 594 pass, 2 fail and 18 are skipped. The same two fail at both commits on this machine, both intest/wp8-docs.test.ts: "README: the credential split creates both files at mode 600" stops onjq: not found, and "README: an empty host reaches the registration script by following step 1" onfatal: repository '…' does not existfor the directory it ran in.The same results: the test names differ by exactly five: "the dedup gate remembers 2000 ids…" at
3cfdfe3, against the ring test and the three new script tests at738b552. Every name the two commits share has the same result. That held in all twelve runs below, and in three runs of738b552's files in one process in orders we imposed: reversed, and two seeded shuffles. We imposed each order by giving Node symlinks with numbered names.Time, in three rounds, each running
3cfdfe3and738b552with a process per file and in one process, one after another. CPU time is user plus system for the whole run, as bash'stimereports it:3cfdfe3, a process per file738b552, one process3cfdfe3, one process738b552, a process per fileThe first two columns are the command that
npm testruns at each commit, run withnodedirectly and with--test-reporter=tapadded. The one-minute load average was 97 to 129 over the twelve runs. Against3cfdfe3's command,738b552's used 68 to 76% less CPU time and finished 19 to 57% sooner. The flag alone used 47 to 56% less CPU time on3cfdfe3's files and 57 to 66% less on738b552's.One process: two test files that each print
process.pidprinted two different pids with Node's default isolation, and the same pid with--experimental-test-isolation=none.Node 22, through
npx node@<version>:--test --experimental-test-isolation=noneon a one-test file exits 9 with "bad option" on each of the ten Node releases from 22.0.0 to 22.7.0, and passes on 22.8.0 and 22.12.0. On Node 22.12.0, the command thatnpm testruns at each commit, with--test-reporter=tapadded, gives the counts above.A leaked handle: a test file whose one test leaves an HTTP server listening was still running after 30 s with
--experimental-test-isolation=noneand with--experimental-test-isolation=process. With--test-force-exitadded, it ended in 0.3 s.Mutants, each run on its test file alone at
738b552:eventEgressAllowedreturning true (src/server.ts:1423), "a host that grants nothing…" and "a disabled github.events…", two of the three absence checks, fail, with four other tests intest/integration.test.ts. With:1291's!this.mcplEnabledcheck removed too, the third, "a plain-MCP host sees tools only…", fails as well.test/webhook.test.ts, a ring that holds one id fewer than its capacity, or one that moves a replayed id to the tail, fails the ring test. A listener whose ring holds 1999 fails the replayed-delivery test.test/release-changelog.test.ts,isMain()comparingprocess.argv[1]withoutrealpathSyncfails the symlink test.isMain()without itstryfails the import test. A refusal that exits 0 fails the refusal test. A script that, run as a program, deletes the fragments as soon as it has read them fails the symlink and refusal tests. A release that keeps the fragments fails "folds fragments into a versioned section…" and the symlink test.Not verified
3cfdfe3, GitHub's runner ran the Test step in about 4 s by its step times (CI run 40593411689). We didn't measure this change there.npm version. No run used it. Its hook runsnode scripts/release-changelog.mjs(package.json:16), and two of the new tests start the script withnodein a release directory (test/release-changelog.test.ts:211,:222).Ogden-2688 made the change, and Esther-2584 read it and ran it before its last commit's message was reworded. Agnes-984 made the house's calls on it. Daniel-2612 wrote this description.