Skip to content

fix(server): start background tasks only after listen succeeds; health and static-file fixes - #3

Merged
luwill merged 1 commit into
luwill:mainfrom
amenjason936:fix/server-start-after-listen
Oct 12, 2026
Merged

luwill merged 1 commit into
luwill:mainfrom
amenjason936:fix/server-start-after-listen

Conversation

@amenjason936

Copy link
Copy Markdown
Contributor

Problem

token-watcher serve without --port retries the next port on EADDRINUSE/EACCES (bin/tokenwatcher.js:282-300). startServer started all of its side effects before listen resolved:

  • scanner update listeners
  • the Claude/Cursor/ZCode/balance pollers
  • backup timers
  • the hourly leaderboard upload
  • the 24 h price refresh

Every failed attempt therefore left a full copy running. One busy port means:

  • every official-API poller runs twice
  • backups run twice
  • two leaderboard uploads per hour, and the Worker answers the second with 429. That 429 is stored as last_error, so the panel shows "上次上报失败" even though the first upload succeeded.

This was listed as a P2 in docs/RELEASE-READINESS-2026-09-24.md and is still present on main.

Change

  • startServer: side effects move into startLifecycle(), which runs inside the listen callback. The signature and callers are unchanged.
  • computeHealth: a parse error now outranks "empty". A source whose first scan fails is exactly the upstream-format-change case CONTRIBUTING.md warns about. It used to show as empty, and doctor passed it. Also a 2026-09-24 P2.
  • serveFile: a read-stream error (file replaced between stat and open, e.g. during npm update -g) now destroys the response. Before, pipe didn't forward the error, so the request hung, and the stream emitted an unhandled error event.

Tests

test/server-lifecycle.mjs adds 16 checks, run in a child process with a temp HOME and TOKENMETER_OFFLINE=1, so they never touch a real ~/.tokenmeter.

  • Busy port with injected pollers: after the failed attempt there are 0 poller starts, 0 listeners and 0 timers. After success, each starts exactly once.
  • Production shape: bin passes only balancePoller, and the Claude/Cursor/ZCode pollers are built inside startServer. The test checks this case and counts one-off timers too: backup at 30 s, first upload at 90 s.
  • Health ordering.
  • serveFile hang.

12 of these checks fail against the current src/server.js. Full npm test passes on Node 22.22.

The four PRs in this set are independent. Each adds one registration line after stall-guard.mjs in test/run.mjs, so the second one merged will need a one-line rebase.

🤖 Generated with Claude Code

serve retries the next port on EADDRINUSE/EACCES, but startServer started
its side effects before listen resolved: scanner update listeners, the
Claude/Cursor/ZCode/balance pollers, the backup timers, the leaderboard
upload and the price refresh. Each failed attempt left a full copy
running: duplicate official-API polling, duplicate backups, and a second
leaderboard upload that the Worker throttles with 429, which then shows
up as 上次上报失败 in the panel. They now start once, in the listen
callback.

Also:
- computeHealth: a parse error outranks 'empty', so a source whose first
  scan fails (the upstream-format-change case) reports error and doctor
  stops passing it.
- serveFile: a read-stream error (file replaced between stat and open,
  e.g. during npm update -g) destroys the response instead of leaving the
  request hanging.

Tests: test/server-lifecycle.mjs (16 checks, run in a temp-HOME offline
child). 12 fail against the current server.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@luwill
luwill merged commit 3eaff3b into luwill:main Oct 12, 2026
10 checks passed
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.

2 participants