Skip to content

test: spawn the server on a port that is verifiably its own - #486

Merged
MagicalTux merged 2 commits into
masterfrom
test/spawn-server-helper
Sep 29, 2026
Merged

MagicalTux merged 2 commits into
masterfrom
test/spawn-server-helper

Conversation

@MagicalTux

Copy link
Copy Markdown
Member

The fourth failure from the run behind #484 (reload picks up messageThreads → 501 reload not supported) was a race, not load. Thirteen tests spawn the real server: each picked a port with closedPort() (bind 0, read, close), wrote it into a throwaway config and polled /teamclaude/status until something answered. Test files run in parallel, so the port could be taken before the child's listen() — and when what took it was another test's in-process createProxyServer (which has no hooks.reload), its 200 satisfied the poll and the test drove the wrong server.

  • One production line: the status server block carries pid, so a caller can tell which process answered on a port (documented beside server.version in docs/usage.md).
  • test-helpers/spawn-server.js: spawnServer({ config, dir?, env?, args? }) picks a port, writes the config, spawns server --headless, and treats a status reply as readiness only when server.pid === child.pid. A child that exits with already in use is respawned on a fresh port (10 attempts); any other exit throws with its output. The exit promise is attached at spawn (on close, so the output is complete for the retry decision) so stop() cannot hang on a child that died early. Proxy variables are scrubbed from the child's environment per test/README.md. closedPort() is still exported for the dead upstream use, where a port nothing listens on is the point.
  • Lives outside test/ because node's default test glob is **/test/**/*.js — verified empirically that test/helpers/, test/_helpers/, *.helper.js and .mjs are all collected as test files. package.json files is ["src/"], so it is not published. Convention added to test/README.md.
  • The 13 files drop their private harness copies (−581 lines). Only 8 spawn a server; the other 5 used closedPort() for dead ports and now import it. server-persists-account-ids proves "no rewrite" by comparing bytes with the helper's compact write instead of an mtime it no longer controls; status-server-version asserts server.pid.

Verified with two concurrent npm test runs: 2582/2582 in each.

🤖 Generated with Claude Code

MagicalTux and others added 2 commits September 29, 2026 11:57
Thirteen test files ran `src/index.js server --headless` as a child, each
picking a port by binding 0 and releasing it, writing it as proxy.port,
and polling /teamclaude/status until it answered. `node --test` runs files
in parallel, so between the probe's close and the child's listen() another
test — or anything else on the machine — could take the port. Under load
that surfaced two ways: the child exited with "Port N is already in use"
and the test timed out on a confusing error; or, worse, a neighbouring
test's in-process createProxyServer answered on that port, its 200
satisfied the poll, and the test drove the wrong server (it has no
hooks.reload, so POST /teamclaude/reload came back 501 in
reload-account-fields.test.js).

The status block now carries `server.pid`, so a caller can tell which
process answered on a port. One shared harness, test-helpers/spawn-server.js,
treats a status reply as readiness only when that pid is the child's own,
and when the child exits because the port was taken it is respawned on a
fresh one (up to ten tries, then the collected output is the error). Any
other exit throws with the output; there is no wall-clock deadline of the
helper's own beyond the runner's --test-timeout. stop() awaits an exit
promise attached at spawn, so a child that died early cannot hang it.

The helper lives outside test/ because node's default glob includes
`**/test/**/*.js`, which would run it as a test file. The thirteen files
now import it; closedPort() stays exported for the dead-upstream use, which
was never the problem. server-persists-account-ids.test.js used an mtime
comparison for "the server did not rewrite the file", which the harness's
own config write would disturb; it now compares the bytes on disk with the
compact form the harness wrote, which the server's indented save cannot
match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@MagicalTux
MagicalTux merged commit ba01b4f into master Sep 29, 2026
5 checks passed
@MagicalTux MagicalTux mentioned this pull request Sep 29, 2026
MagicalTux added a commit that referenced this pull request Sep 29, 2026
Thirty-two commits since 1.1.21. Two change routing on an existing
config without an opt-in (#480, #481); the rest is opt-in, additive, or
display.

Behaviour changes
  #481 an API-key account's 401 is a cooldown, not a permanent `error`:
       1 min, then 5, 15 and 60 for every further rejection with no success
       in between; any 2xx/3xx resets it. The request still fails over and
       the client never sees the 401. OAuth accounts are unchanged
  #480 with session distribution on, requests carrying no session id stay
       within the top priority tier, so a fallback gateway no longer answers
       Claude Code's bootstrap and connector calls
  #470 a 200 whose SSE stream reports a provider failure before any output
       (`server_is_overloaded`, `response.failed`) fails over once, like a
       status-shaped failure would
  #465 a reload removes running accounts whose config entry is gone from
       disk, so `teamclaude remove` from another shell takes effect at once
  #460 `import` refuses an account whose token upstream has definitively
       rejected (401/403), even with `--name`; a 5xx or timeout still imports

Rename
  #483 the project is being renamed to TeamRouter (#72). This release accepts
       the new name everywhere the old one is read and changes nothing an
       install has on disk: `teamrouter` runs the same CLI, every
       `TEAMCLAUDE_*` variable is also read as `TEAMROUTER_*` (which wins when
       both are set), every `/teamclaude/…` control route also answers at
       `/teamrouter/…`, and `~/.config/teamrouter.json` is used when it exists

Features
  #441 per-account egress proxy (`accounts[].routing`: http, socks4/4a,
       socks5/5h) for refresh, probes and requests; `login --routing`,
       `teamclaude routing set/show/clear`, a connection check before it is
       relied on, and a short hold when the proxy is unreachable
  #427 `accounts[].allowExtraUsage: true` lets a paid extra-usage account
       serve once every account is past its threshold, instead of a 429
  #466 `accounts[].maxSpend`, a money cap judged against the month-to-date
       extra-usage spend upstream reports; the TUI shows what an account
       has billed
  #436 `autoRedeemResets` spends a free Codex rate-limit reset credit when
       the Codex pool runs dry (off by default)
  #482 `advisorEligibility: "strict" | "prefer"`; when the advisor model
       narrows selection to a subset of the fleet the log says so, and status
       carries the reading (`advisorNarrowing`)
  #478 `stripOverageHeaders` drops another org's per-organization billing
       headers from responses, for a pool spanning several orgs (#476)
  #471 `quota.unified5hSeenAt` / `unified7dSeenAt` in status: when upstream
       last stated each shared window
  #446 client and dimension usage for the last 5h and 24h in status and the
       dashboard, resumed across restarts
  #458 #459 #461 the dashboard sets the switch threshold, enables/disables and
       reprioritizes an account, and has a light theme remembered per browser
  #464 `l` in the TUI signs an account in `error` in again from the dashboard
  #457 status records which Codex limit meters each model
       (`quota.codexModelLimits`)
  #442 `quotaBarPercent` drops the percentage beside a TUI bar's countdown
  #451 `stripRequestFields` takes `content.<block type>` to drop content
       blocks a strict Anthropic-compatible upstream rejects
  #469 `proxy.mcp` schemas declare their item types, the write audit line
       records what happened, and the write queue has a depth (#447–#450)

Fixes
  #477 a refused WebSocket handshake whose headers all drop is relayed as a
       well-formed head instead of a blank line and body bytes
  #474 two members of one ChatGPT workspace are told apart by user id, so a
       second `login --codex` no longer replaces the first
  #469 a Codex Responses stream with no Content-Type is relayed as a stream
       and booked; thread repair on the global upstream; TUI settings and
       status gaps; a hint when a local login would have served
  #463 a Codex row with no session window draws one wide weekly bar

Tests
  #484 #485 #486 the suite asserts behaviour, not the scheduler: wall-clock
       upper bounds are gone, and subprocess tests spawn the server through
       `test-helpers/spawn-server.js`, which verifies the server it reached by
       `server.pid` (new in status) instead of trusting a port

Tooling
  #452 #453 #454 #455 docker workflow actions bumped
MagicalTux added a commit that referenced this pull request Oct 3, 2026
test/server-warmup-schedule.test.js carried its own bind-a-port-then-spawn
harness, the shape #486 replaced everywhere else: it lost the port race to a
neighbouring test on a loaded runner ("Port 39521 is already in use", node 20
on #499's run). It now uses test-helpers/spawn-server.js, which verifies the
port by the child's pid and respawns on a lost race, and the one-second
AbortSignal on the post-reload quota fetch is gone — the runner's timeout is
the bound for a wedged endpoint.

Co-authored-by: Claude Fable 5.1 <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.

1 participant