Skip to content

Pin which server-global caller shapes observe the UDS-mirror upgrade dispatch (v5.1.26) and TLS listener properties (v5.2.0) - #3169

Draft
kriszyp wants to merge 4 commits into
mainfrom
test/server-global-uds-mirror-caller-impact
Draft

kriszyp wants to merge 4 commits into
mainfrom
test/server-global-uds-mirror-caller-impact

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

Harper's upgrade-impact pipeline flags apps as "potentially affected" by a release step through a static pattern match on the API they use. Over a thousand apps reference the injected server global, nearly all through server.http / server.ws. Two release steps changed the listener objects behind that global: v5.1.25 → v5.1.26 (the per-worker UDS mirror now dispatches WebSocket upgrades, and the port's server object carries a udsMirror reference) and v5.1.26 → v5.2.0 (TLS listeners resolve ciphers per usage type and carry appliedCiphers, verifiesClientCerts, mtlsRequired, udsMirrorUwsConfig). Without a precise rule, every one of those apps gets flagged. This PR pins each caller shape with a test on main and states the rule below. It changes no runtime behavior.

❓ Your call: is a research PR that only adds tests the right vehicle? As specified by the task, yes: the tests make the rule executable, so a later change to these listeners fails a named shape instead of silently invalidating the rule. The alternative is a doc-only write-up, which goes stale without anyone noticing. Reversal costs one file deletion.

Caller impact rule

(a) Trigger conditions

Both changes act on the internal Node listener objects (http.Server, tls.Server) stored in SERVERS. Neither adds, removes or changes a key of the server global itself. Application code observes them through exactly two routes:

  1. Mirror upgrade dispatch (v5.1.26). A server.ws(...) registration (and any server.upgrade(...) middleware on the same port) observes a difference only when all of these hold:

    • the registration resolves to a secure port: an explicit securePort, or no port options while http.securePort is configured (the default for server.ws(listener) with no options);
    • tls.unixDomainSockets is enabled, so the per-worker UDS mirror exists (getHTTPServer);
    • clients reach the worker through that mirror (a fronting TLS proxy that forwards to the per-worker sockets), not the TLS port directly.

    Before v5.1.26 such handshakes got a zero-byte close and the listener never ran. Now they reach the listener, and any server.upgrade middleware on the port runs first (onWebSocket dispatch). v5.1.x has no uWS backend. v5.2.0 first ships the opt-in uWS-served mirror (HARPER_UWS_UDS). On that mirror server.ws listeners run but upgrade middleware never does; Harper only logs a warning.

  2. New properties on returned listener objects (v5.1.26 udsMirror; v5.2.0 the rest). Code observes them only if it keeps the return value of server.http(...), server.ws(...) (both documented as HttpServer[]) or server.socket(...) (documented as SocketServer) and then reads one of the new property names, enumerates the object (Object.keys, for..in, spread, Object.assign), or serializes it (JSON.stringify, util.inspect). The properties are set only on secure listeners (server/http.ts:929-931, threadServer.js:776-778); udsMirror and udsMirrorUwsConfig additionally need tls.unixDomainSockets (and HARPER_UWS_UDS for the latter).

The cipher resolution itself (which suite string a TLS listener is built with) depends on TLS configuration and certificate records, not on application code. server.socket(listener, { securePort, usageType }) is the one code-visible input: before v5.2.0 the usageType option was ignored on raw-socket listeners; since #2003 it selects certificates and ciphers. Existing tests pin that: unitTests/security/keys.test.js "applies the operationsApi.tls layer only when usageType is 'operations-api'" and the two tests after it.

(b) Caller shapes

Every row names the test in serverGlobalCallerImpact.test.js that pins it.

Caller shape Synthetic example v5.1.25 → v5.1.26 v5.1.26 → v5.2.0 Pinned by
server.ws on a secure port server.ws((ws) => ws.send('hi')) behavior change only, undecidable from code (needs tls.unixDomainSockets and mirror-routed clients): mirror connections now reach the listener unaffected "receives WebSocket connections that arrive on the UDS mirror"
server.upgrade middleware with server.ws on the same secure port server.upgrade((req, sock, head, next) => { authorize(req); return next(req, sock, head); }) behavior change only, undecidable from code: middleware now also runs for mirror handshakes unaffected "runs for UDS-mirror handshakes when server.ws is registered on the same port"
server.ws + server.upgrade on a uWS-served mirror (HARPER_UWS_UDS) same as above, with HARPER_UWS_UDS=1 n/a (no uWS backend on v5.1.x) new opt-in backend: server.ws listeners run on that mirror; upgrade middleware does not (uWS accepts the handshake natively; only a warning is logged) "under HARPER_UWS_UDS: the uWS mirror config gets the WebSocket handler", "… runs server.ws listeners but not upgrade middleware"
server.upgrade alone server.upgrade(mw, { securePort: 9443 }) unaffected (creates no listener at all) unaffected "server.upgrade(middleware) alone creates no listener at all"
server.http + server.upgrade, no server.ws on that port server.http(h, { securePort }); server.upgrade(mw, { securePort }) unaffected (neither the port nor its mirror gets an 'upgrade' listener; handshakes still get a zero-byte close) unaffected "neither the port nor its mirror dispatches upgrades"
server.ws on a plain port only server.ws(l, { port: 9926 }) unaffected (mirrors exist only for secure ports) unaffected "on a plain port: no mirror exists"
server.http(listener) request handling server.http((req, next) => next(req)) unaffected unaffected "serves plain requests on the UDS mirror, as before both releases"
Reading a new property off a returned listener const [s] = server.http(h, { securePort }); if (s.udsMirror) … behavior change only: udsMirror was undefined behavior change only: appliedCiphers, verifiesClientCerts, mtlsRequired were undefined "s.udsMirror is the registered mirror http.Server", "reading s.appliedCiphers / …"
Enumerating or serializing a returned listener Object.keys(server.http(h, opts)[0]), JSON.stringify(s) behavior change only: one more key; serialization now nests the mirror server behavior change only: three more keys (udsMirrorUwsConfig instead of udsMirror under the new HARPER_UWS_UDS backend) "Object.keys / for..in / JSON.stringify …"
server.ws / server.http return identity server.ws(l, o)[0] === server.http(h, o)[0] unaffected unaffected "return the same server object for the same port"
server.socket return, securePort only const s = server.socket(l, { securePort: 8883 }) unaffected behavior change only: stamps present "s carries the v5.2.0 stamps"
server.socket return, port + securePort server.socket(l, { port: 1883, securePort: 8883 }) unaffected unaffected (returns the plain TCP server, which carries no stamps) "s is the plain TCP server, with no stamps"
server.socket with usageType server.socket(l, { securePort, usageType: 'mqtt' }) unaffected behavior change only, depends on certificate/cipher config existing keys.test.js usageType tests
Reading members of the global server.resources, server.contentTypes, server.http unaffected unaffected "registering listeners adds none of the stamps to the global"
Enumerating the global Object.keys(server) unaffected unaffected by these changes (v5.2.0 does add setMcpQuotaHandler from an unrelated MCP feature) same test
fastifyRoutes app (fastify plugins) routes/*.js exporting async (fastify, { hdbCore }) => … unaffected unaffected: hand-over timing is identical "server.http receives the built fastify http.Server once, never a pending build"
Reaching a listener through a request socket request._nodeRequest.socket.server.mtlsRequired — undecidable from code, behavior change only: mirror-accepted sockets' server now carries verifiesClientCerts / mtlsRequired not pinned (undocumented internal path)

❓ Your call: two rows pin shapes that look incidental rather than designed: server.socket({ port, securePort }) returning only the plain server, and the stamps being own-enumerable and JSON-serializable. Pinning them means a later change (return both servers, make the stamps non-enumerable) fails a named test and must update this rule on purpose. The alternative is pinning only documented members, which leaves the enumeration rows of the rule unverified. Reversal is deleting the asserts.

❓ Your call: the "neither the port nor its mirror dispatches upgrades" row pins today's zero-byte close as unchanged behavior. server/DESIGN.md describes that silent close as a failure mode seen in production, so a future fix (a 426 response or a log line) will fail this test. That failure is the point: the fix changes this caller shape, and the rule has to say so. The DESIGN.md note likewise documents that the override reaches servers an application creates. That records an existing side effect; it does not endorse it. Reversal is deleting two asserts and the note's last clause.

No row breaks. Every difference is additive: a property that used to be undefined, or a connection that used to be dropped and is now served.

The fastify hand-over did not change. v5.1.26 had server.http((await fastifyServer).server); v5.2.0 and main have const built = await fastifyServer; server.http(built.server) (fastifyRoutes.ts:62-64). Both await the build before the hand-over, and both hand over before fastify's ready(). What v5.2.0 did change for fastifyRoutes apps is outside the server global: a component mounted on a host now refuses to load, the route prefix is computed from the external base path, and the instance is registered per port for the Bun/uWS fallback.

(c) Intended contract change or accidental regression

Intended fixes; the property exposure is an incidental side effect of returning the internal objects.

Nothing here is an accidental break that Harper should fix, so the task's "stop and ask before a fix" condition did not arise.

(d) Static-detection recipe

Neither change can be decided from source for the registration shapes, so a scanner has two jobs.

  1. Flag as behavior-change-only, deployment-dependent: \bserver\.(ws|upgrade)\s*\(. Then downgrade to unaffected when the call's options literal names port: and not securePort: (single-line heuristic: \bserver\.ws\s*\([^)]*\{[^}]*\bport\s*:(?![^}]*securePort)).
    • False positives: every app whose deployment does not enable tls.unixDomainSockets, or whose clients connect to the TLS port directly. That is most of them, and the code cannot tell.
    • False negatives: none for the global. A destructured or aliased call (const { ws } = server; ws(l)) needs \{[^}]*\bws\b[^}]*\}\s*=\s*server\b as well.
  2. Flag as behavior-change-only, decidable: a direct property read, \.(udsMirror|udsMirrorUwsConfig|appliedCiphers|verifiesClientCerts|mtlsRequired)\b, or a captured return value that is enumerated or serialized: =\s*server\.(http|ws|socket)\s*\( in the same file as Object\.(keys|entries|values|assign)\(|JSON\.stringify\(|for\s*\(\s*(const|let|var)\s+\w+\s+in\b|\.\.\.\w.
    • False positives: mtlsRequired collides with config-shaped objects ({ mtlsRequired: true }); the enumeration co-occurrence is file-level, not data-flow.
    • False negatives: the return value passed to a helper in another file before being enumerated; listeners reached through request._nodeRequest.socket.server or ws._socket.server.
  3. Never flag for these two changes: server.http(listener) handlers, server.resources, server.contentTypes, server.operation, other reads of the global, and fastifyRoutes route files.

(e) Main versus the target releases

Main behaves as v5.2.0 for every shape above. The mirror dispatch (server/http.ts:1903) is present on v5.1.26 through v5.1.28, v5.2.0 through v5.2.15, v5.3.2 and main. The stamps are present on v5.2.0 through v5.2.15, v5.3.2 and main, and on no v5.1.x tag. Unrelated to these two changes, main (since v5.3.0, #3035) lets server.http / server.ws / server.socket throw when a port is owned by a dedicated worker pool (threadType). This rule does not cover that.

💡 Solution

One new unit file, unitTests/server/serverGlobalCallerImpact.test.js, drives the real server.http / server.ws / server.upgrade / server.socket with tls.unixDomainSockets toggled. It listens the resulting mirror objects on loopback TCP, so it runs on every CI leg including Windows, and exercises real WebSocket and HTTP traffic. Each it is named for one caller shape in the table, and old behavior is stated in a comment naming the release and PR that changed it. Teardown restores SERVERS, uwsServeConfigs and portServer, so a later listenOnPorts() test does not bind these listeners. The uWS-mirror cases call the installed wsHandler directly; they do not exercise uWS's native upgrade callback.

⚠️ Look hardest: the zero-byte-close row. Plain Node (checked on v20.18, v22.13, v22.23, v24.7, v24.10, v24.21, v26.2) handles an upgrade request with no 'upgrade' listener as an ordinary request. Harper's IncomingMessage.prototype.upgrade override is what turns that into a dropped socket. The test pins the dropped socket.

server/DESIGN.md said a Node HTTP server with no 'upgrade' listener destroys upgrade sockets. That is only true inside Harper: the note now names the override as the cause and says it applies to every http.Server on a thread that loads server/http.ts, including ones an application creates (checked with a standalone server plus the override on Node v22, v24, v26: 0 bytes, closed).

❓ Your call: the note restates the mechanism in prose and names the pinning test, rather than shrinking to a one-line pointer at the test as the root DESIGN.md placement rule prefers. The prose is kept because the old wording blamed Node, and a pointer alone would not undo that. Easy to shrink later.

unitTests/server/httpChainPortAll.test.js registered an http-shaped (request, next) handler as server.upgrade middleware on the 'all' pseudo-port. That throws next is not a function on any real upgrade a later file dispatches in the same mocha process, and it broke the new end-to-end WebSocket tests in a full-directory run. It now registers an upgrade-shaped pass-through; what that file asserts is unchanged.

⚖️ Alternatives

  • Extend the mirror block in unitTests/apiTests/mqtt-test.mjs instead of a new file. That block already pins the Node and uWS mirror WebSocket paths over a real socket path with a PROXY v1 header. It runs in test:unit:apitests and is organized around MQTT, not caller shapes. A new file in the default unit suite, with one it per rule row, keeps the rule and its tests side by side, at the cost of a less faithful transport (loopback TCP, no PROXY header) and some overlap.

    ❓ Your call: keep the overlap, or fold the shape tests into mqtt-test.mjs? Kept separate here; moving them is mechanical.

✅ Verification

End-to-end route: real WebSocket and HTTP traffic over the mirror listener inside the unit test, no integration test needed.

  • npx mocha unitTests/server/serverGlobalCallerImpact.test.js → 17 passing; with threadServerListenOnPorts.test.js after it, 25 passing (the registries this file touches are restored before that file's listenOnPorts()).
  • Mutation checks against dist/: removing the mirror 'upgrade' dispatch and the udsMirror assignment (the v5.1.25 state) fails 4 of 17. Removing the three v5.2.0 stamp assignments in getHTTPServer fails 2 of 17. Restored, all passing.
  • npx mocha "unitTests/server/**/*.js" → before the httpChainPortAll fix, 3 failures in the new file from the 'all' upgrade handler; at the head, 1331 passing, 0 failing.
  • npm run test:unit:main on the first commit → 6906 passing, 0 failing (the head's later changes are confined to the new file and re-verified by the unitTests/server run above).
  • prettier --check and oxlint --deny-warnings clean on both files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CjJe3sGr1tdJmvjvVZx77N

— Claude Opus 5.5

Release target: main — tests and a design-note correction only; no runtime change to backport
Related PRs: none found (partial scan: 1 PRs with more than 100 files or 10 linked issues could not be fully matched)
Complexity: medium

Dispatch: task harper-upgrade-rule-global-server-5126-520 · queued by unknown · ran by claude/opus/high · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse; rounds=4; full=1 @ 0b95566

Review-Attention: read ~3m (decisions: pin-silent-upgrade-close, design-note-vs-named-test, pin-incidental-shapes, new-unit-file-vs-extend-mqtt-test) @ 0b95566

kriszyp and others added 4 commits October 12, 2026 06:14
…ispatch and TLS listener stamps

Pins, per caller code shape, what application code using the `server`
global observes from two listener changes: v5.1.26's upgrade dispatch on
the per-worker UDS mirror (and the `udsMirror` reference on the port's
server object), and v5.2.0's `appliedCiphers` / `verifiesClientCerts` /
`mtlsRequired` / `udsMirrorUwsConfig` stamps on TLS listeners. Only
server.ws / server.upgrade registrations (deployment-dependent) and code
that reads or enumerates the objects server.http / server.ws /
server.socket return can observe a difference; the global itself and the
fastifyRoutes hand-over are unchanged.

httpChainPortAll.test.js registered an http-shaped (request, next)
handler as server.upgrade middleware on the 'all' pseudo-port, which
throws on any real upgrade a later file dispatches in the same mocha
process; it now registers an upgrade-shaped pass-through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjJe3sGr1tdJmvjvVZx77N
Dispatch-Task: harper-upgrade-rule-global-server-5126-520
…caller-impact tests

Addresses pre-push review round 1: snapshot and restore SERVERS,
uwsServeConfigs and portServer so a later listenOnPorts() test does not
bind this file's listeners; give the WebSocket, listener and fetch
waits deadlines; drop sinon for counters and a restored receiver; pin
that upgrade middleware never runs on the uWS-served mirror; attribute
the uWS mirror cases to v5.2.0 (v5.1.x has no uWS backend); assert the
TLS port's own 'upgrade' listener alongside the mirror's; trim comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjJe3sGr1tdJmvjvVZx77N
Dispatch-Task: harper-upgrade-rule-global-server-5126-520
…gMessage override, not Node

server/DESIGN.md attributed the silent destroy of an un-listened WebSocket
upgrade to a Node default. Plain Node (v20-v26) hands such a request to
the 'request' listener; Harper's process-wide IncomingMessage.prototype
.upgrade override with a no-op setter defeats that fallback, which also
makes it apply to application-created http.Servers. Also detaches
listenOnLoopback's error listener once listen succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjJe3sGr1tdJmvjvVZx77N
Dispatch-Task: harper-upgrade-rule-global-server-5126-520
…rver/http.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjJe3sGr1tdJmvjvVZx77N
Dispatch-Task: harper-upgrade-rule-global-server-5126-520
@kriszyp kriszyp added this to the v5.4 milestone Oct 12, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the design documentation regarding WebSocket upgrade fallback behavior and introduces a new test suite in unitTests/server/serverGlobalCallerImpact.test.js to verify UDS mirror upgrade dispatches and TLS listener stamps. It also refactors existing upgrade chain tests to use a dedicated pass-through helper. The review feedback suggests awaiting the asynchronous server closures in the test teardown hook to prevent Mocha from hanging, and explicitly requesting connection closure in the test fetch request to avoid keeping sockets alive.

Comment thread unitTests/server/serverGlobalCallerImpact.test.js
Comment thread unitTests/server/serverGlobalCallerImpact.test.js

This branch has not been deployed

No deployments
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.

Per-certificate TLS ciphers (including client-CA SECLEVEL overrides) silently ignored — only tls.ciphers/tls[0].ciphers takes effect

1 participant