Skip to content

feat(webui): fall back to the next free port when the default port is taken - #16

Merged
weekbin merged 1 commit into
mainfrom
feat/webui-port-fallback
Sep 22, 2026
Merged

weekbin merged 1 commit into
mainfrom
feat/webui-port-fallback

Conversation

@weekbin

@weekbin weekbin commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Change

pnpm mcode-web (and mcode webui / mcode web) no longer dies when the default port is already taken, and the default port itself moves off the busiest number.

The default is now 18090, and it is a preference rather than a promise — when it is taken the server walks forward to the next free port and logs the port it actually bound, which is the URL the launcher opens:

[webui] port 18090 is already in use — trying 18091
[webui] listening on http://127.0.0.1:18091

8080 was chosen in v0.5.bx-38 for its "web alt" reading, but it collides with desktop clients and other dev servers often enough that a fallback would fire regularly — it was already occupied by an unrelated process on the machine this was tested on.

Two supporting fixes came out of the same path:

  • A failed listen used to be swallowed. installGlobalErrorHandlers only logs uncaughtException, so a taken port left a process that was alive but never listening; the launcher then waited for a listening line that could never arrive. Listen failures now report and exit 1 — including the synchronous ERR_SOCKET_BAD_PORT throw that an out-of-range or non-integer port produces.
  • The port consumers read is now the port actually serving. The CORS origin trust set, /api/health, the state payload, the LAN share URL and the LAN 403 page are built per request from getServingPort(). A snapshot of the configured value would have kept trusting http://127.0.0.1:18090 while the browser talked to 18091, and would have named the wrong port in share URLs.

An explicitly configured port stays exact

PORT set to a positive value, or --port passed, pins the port: it never moves and a taken port exits 1 with guidance.

$ pnpm mcode-web --port 18090
[webui] cannot listen on 127.0.0.1:18090 — EADDRINUSE
[webui] port 18090 is taken and was pinned (PORT / --port), so no fallback was attempted. Free it, or pass another value.

This is deliberate. Docker publishes and healthchecks ${WEBUI_PORT}, and the webui integration tests dial the port they chose; neither can discover a fallback, so silently moving would break them. docker-compose.yml and the Dockerfile healthcheck pin 18080 explicitly and are unaffected. Unset, empty, 0 and non-numeric PORT values are treated as "not configured" — the same set that Number(process.env.PORT) || 18090 maps onto the default.

Validation

  • pnpm verify (full profile, Node 24.19.0, Linux, clean tree at the PR head fa29968): check:source, check:tsconfig, export source preview, test:release-tools, typecheck, build, check:standalone, test:artifact, test:capabilities, test:status-contract, test:smoke and test:byok all pass. test:webui reports 1303 pass / 1 fail — the single failure is the pre-existing upload-limits flake documented below, not one of the new tests. test:policy is not reached when a gate fails; run on its own it passes (142 tests). The same test:webui gate passed fully (1303 pass / 0 fail) on this branch's previous revision, and upload-limits.test.js passes 7/7 when run alone (three consecutive runs) — which is why the flake is context-sensitive rather than a regression here.
  • New tests, both inside test:webui: packages/webui/test/port.test.js (11 cases) and packages/webui/test/integration/port-fallback.test.js (1 case) — all passing in every run.
    • packages/webui/test/port.test.js — the pin rule (positive pins; unset/empty/0/non-numeric do not), the attempt budget, and the retry mechanics against real sockets: a free port is used as-is, a taken port walks forward and serves, a pinned port reports EADDRINUSE without moving, an exhausted budget reports EADDRINUSE instead of spinning, an out-of-range port reports ERR_SOCKET_BAD_PORT instead of throwing out of the helper.
    • packages/webui/test/integration/port-fallback.test.js — a live server.js boot with PORT unset and the default port held: reports the next free port, /api/health agrees with it, that port's own browser Origin is reflected, and the configured-but-unused port's Origin is not.
  • Manual end-to-end, since the launcher is the path the change is about: with 18090 held by a blocker process, node dist/mcode-web.js --no-open logged trying 18091 and listening on http://127.0.0.1:18091; with 18090 free it logged listening on http://127.0.0.1:18090; with --port 18090 held it exited 1 with the pinned-port message above.
  • Mutation check: reverting buildTrustedOrigins({ port: ... }) in router.js to the configured port makes the integration test fail, so it pins the behaviour it claims to.
  • Documentation updated in both languages for the new default: README.md / README_ZH.md, docs/webui.md / docs/webui.zh-CN.md, packages/webui/README.md / README.zh-CN.md, and the webui's DEVELOPMENT, TROUBLESHOOTING, API and HTTPS-REVERSE-PROXY docs with their .zh-CN counterparts, plus an Unreleased entry in packages/webui/docs/CHANGELOG.md. packages/webui/scripts/check-docs-alignment.mjs passes.
  • packages/webui/test/lib-settings.test.js previously pinned the 403 page to the literal 127.0.0.1:8080; it now asserts against getServingPort() so the next default change cannot leave it stale.
  • NOT RUN: macOS and Windows platform runs, and live-service acceptance. Offline gates do not establish live-service or cross-platform acceptance.

Pre-existing upload-limits flake (not introduced here)

packages/webui/test/integration/upload-limits.test.js is timing-sensitive and failed intermittently while this work was in progress on upload-limits: oversized request → 413 mid-stream (write EPIPE before the response event, and once a bytesWritten budget overrun) — the same failure observed while syncing upstream main in #15, and present on main today. The file and server.js's upload path are not touched by this PR.

Publication and contribution checks

  • Permission to contribute under the existing licenses is unchanged.
  • No credentials, account data, real user content or private review material is included.
  • No source file was added or removed by this change; content-only edits leave release/public-source.json valid (check:source passes).
  • Shared English/Chinese documentation and the webui CHANGELOG are updated.

Maintainer handoff

Publication scope or license changes: none.

Shared-source port: not needed — this is a fork-local Web UI behaviour change.

@fengzhi09

Copy link
Copy Markdown
Collaborator

看了没啥大毛病,但是要不要考虑 默认值不设置为8080, 比如换成8090/18090 冲突的概率小一点

`mcode-web` / `mcode webui` used to die on a taken port — and because the global
uncaughtException handler only logs, the process could also stay alive without
listening at all.

The default port is now 18090, and it is a preference rather than a promise:
when it is taken the server walks forward to the next free port (20 attempts)
and logs the port it bound, which is the URL the launcher opens. 8080 was chosen
in v0.5.bx-38 for its "web alt" reading, but it collides with desktop clients
and other dev servers often enough that the fallback would fire regularly.

An explicitly configured port stays exact — `PORT` set, or `--port` passed.
That is deliberate: docker port publishing, the container healthcheck and the
webui integration tests all address the configured value and cannot discover a
fallback. A taken pinned port now exits 1 with an actionable message instead of
a stack trace plus a live-but-not-listening process. An out-of-range or
non-integer port throws synchronously out of `server.listen`, so that path now
reaches the same handler instead of escaping to the global logger.

The serving port is exposed through getServingPort() because it is read at
request time, not at import time. Otherwise the CORS origin trust set would keep
trusting the configured port while the browser talks to the fallback one, and
/api/health, the state payload, the LAN share URL and the LAN 403 page would
name the wrong port. isPortPinned is the single implementation of the pin rule.

Tests:
- test/port.test.js: the pin rule, the attempt budget, and the retry mechanics
  (a free port is used as-is; a taken one walks forward; a pinned one reports
  EADDRINUSE; an exhausted budget reports EADDRINUSE; an out-of-range port
  reports ERR_SOCKET_BAD_PORT instead of throwing out of the helper).
- test/integration/port-fallback.test.js: a real server.js boot on a taken 18090
  reports the next free port, /api/health agrees, and that port's browser Origin
  is trusted while the configured-but-unused port's is not.
@weekbin
weekbin force-pushed the feat/webui-port-fallback branch from cc0a7bb to fa29968 Compare September 22, 2026 05:45
@weekbin

weekbin commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

采纳了 —— 默认端口从 8080 改成 18090(packages/webui/server/lib/config.js),并且和端口回退机制配合使用,两者是互补的:

  • 默认端口只是"偏好":被占用时往后找下一个空闲端口(最多尝试 20 个),并打印实际绑定的端口 —— 启动器(mcode-web / mcode webui)打开的就是这个地址。所以就算 18090 也被占,也不会挂。
  • 显式指定的端口仍然"钉住"(PORT,或 mcode-web --port),不会自动后移。这是有意的:docker-compose.yml 发布并健康检查的是 ${WEBUI_PORT},webui 集成测试也按自选端口拨号,它们都发现不了回退,静默换端口会让容器/测试失联。被占用的钉住端口现在给出可操作的提示并以 1 退出(之前是抛栈 + 因为全局 uncaughtException 只记录不退出,进程会停在"活着但没在监听"的状态)。
  • 顺带修了一个同源问题:server.listen 对越界/非整数端口是同步抛 ERR_SOCKET_BAD_PORT,之前会绕过监听失败处理逃到全局处理器,现在走同一条终止路径。

另外把"端口"的消费方也收口了:origin 信任集、/api/health、state 快照、LAN 分享 URL 与 LAN 403 页面现在都按实际监听端口(getServingPort())计算。否则回退到 18091 之后,浏览器带来的是 http://127.0.0.1:18091,会被自己的 CSRF 网关拒掉 —— 也就是功能"看着起来、实际不可用"。这一点在集成测试里有断言(顺带做了变异检验:把该处改回配置端口,测试立刻失败)。

实测(本机 18090 由 blocker 占住):

[webui] port 18090 is already in use — trying 18091
[webui] listening on http://127.0.0.1:18091

18090 空闲时直接用 18090;--port 18090 被占时以 1 退出并提示。

文档(EN + zh-CN,含 webui CHANGELOG)已按新默认值同步;packages/webui/test/lib-settings.test.js 里那处写死 127.0.0.1:8080 的断言也改成按 getServingPort() 断言,避免下次改默认值又留一处失效的字面量。

分支上只有一个提交,我把评审后的改动直接并进去了(force-push,没有叠加 delta commit)—— 如果你更希望单独看一版 delta,我可以拆成两个提交。

@weekbin
weekbin merged commit 1633eec into main Sep 22, 2026
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