Skip to content

Test harness: integration tier, refactor-protection unit tests, and CI - #599

Merged
jcheng5 merged 7 commits into
masterfrom
jcheng5/test-harness
Aug 29, 2026
Merged

jcheng5 merged 7 commits into
masterfrom
jcheng5/test-harness

Conversation

@jcheng5

@jcheng5 jcheng5 commented Aug 29, 2026

Copy link
Copy Markdown
Member

Phases 2–5 of the test-harness plan. Stacked on #598 — review that first;
this PR's base will retarget to master once it lands.

Goal isn't "test Express 5". It's to build the characterization net that makes
three pending changes safe — the Express 4→5 upgrade (#596), the Q removal, and
the TypeScript port — and to use Express 4→5 as its first customer.

The tiers

mocha isn't recursive, so every directory is named explicitly (in package.json
and Jenkinsfile — a new directory added in only one silently doesn't run).

Command What Needs
npm test unit + fast integration nothing
npm run test:r real R processes R + shiny

test/integration/ boots a real, complete server on an ephemeral port for
each test — ~15ms — and makes real HTTP requests. Only su and R are stubbed;
the real TcpTransport, endpoint, shared secret, connectEndpoint_p handshake
and proxy all stay live, which is the part an Express upgrade actually touches.
Covers __assets__ rewriting (including the ?foo case the explicit /?
rewrite exists for, and traversal attempts), send's directory handling and the
text/R MIME mutation, req._parsedUrl survival to the proxy, worker refcount
cleanup with compression on and off, X-Powered-By, and the access log's
reliance on originalUrl.

test/integration-r/ launches actual R through the real AppWorker. Runs as
the current user, since AppWorker only shells out to su when runAs differs
from the process user.

Unit tests for what the Q and TypeScript ports will touch, all previously at
zero coverage: qutil (forEachPromise_p is the router chain's control flow;
map_p's sequencing is load-bearing), ShinyProxy.httpListener's dispatch
contract, Scheduler.shutdown()/dump() (the only synchronous promise
inspection in the codebase — no native equivalent, so it needs a design change
during the Q removal), connectEndpoint_p, and the hand-written config
language, which the memory bank rates as the highest-value gap.

Findings, characterized rather than fixed

  • qutil.serialized hands a queued caller the previous invocation's
    outcome.
    Q's .fin() settles with the original promise's value even when
    its callback returns a promise. Benign today only because the sole production
    user is loadConfig_p, whose queued caller is the SIGHUP handler, which
    .eat()s the result — but a live trap for the Q removal.
  • NODE_ENV is never set, so Express runs in development mode and an
    unhandled throw returns a stack trace, with no 4-arg error handler to stop it.
    Asserted, so that adding one becomes a deliberate change. Possibly worth
    treating as security-adjacent independently of this work.
  • connectEndpoint_p's timeoutId is assigned null and never set, so the
    clearTimeout on the success path is a no-op; the !isPending() guard is
    what actually stops the retry loop.
  • manual.test/test-config-config.js fails on a stale expectation, not a
    product bug
    — run_as; with no users is legal, because the schema declares
    param String users....

Two flakiness traps worth reading

Both had symptoms pointing nowhere near the cause; together they took the suite
from ~12% flaky to 0 failures in 25 consecutive full runs.

  • Ephemeral-port shadowing. TcpTransport allocates a worker port by
    binding 127.0.0.1:0, reading it, and closing again. A server listening on
    the wildcard :: can be handed that same port; the worker then binds
    127.0.0.1 on it, which succeeds — a specific-address bind is allowed
    alongside a wildcard one — and shadows the server for loopback traffic.
    Requests silently reach the worker instead of Shiny Server, surfacing as
    inexplicable 404s and Parse Error: Expected HTTP/. The harness pins its
    listener to 127.0.0.1.
  • Socket pooling across recycled ports. fetch() pools keep-alive sockets
    per origin; test servers restart milliseconds apart and ports get recycled, so
    a socket belonging to a dead server gets handed to the next test — which then
    talks to the previous test's config. Connection: close is not a fix: fetch
    treats it as a forbidden header and drops it silently. Requests go through
    http.request with agent: false.

CI

.github/workflows/ci.yml — npm ci, tests and license check on Linux and
macOS, plus a build-freshness check (tsc then git diff --exit-code lib/).
Generated .js is committed next to the .ts and tests run against the
generated files, so editing a .ts without npm run build passes CI today
while changing nothing at runtime.

Node is pinned from .nvmrc: master's nan (^2.18.0) does not compile against
Node 24, so anything else fails in node-gyp rather than anywhere informative.
That's also a prerequisite for #596's Node bump. The license check runs
check-licenses.js and check-upstream.sh directly rather than
tools/preflight.sh, which invokes ./bin/node — a runtime that only exists
after the full CMake build.

docker/jenkins/Dockerfile.ubuntu-20.04 gains the shiny package; it had
r-base only, so the real-R tier would have skipped itself there. CI treats a
skip as a provisioning failure so it can't pass vacuously.

Verification

  • npm test: 291 passing, 0 failing, 4.3s (macOS, Node 20.17.0).
  • npm run test:r: 8 passing.
  • 0 failures across 15 consecutive full runs; 25 across the integration tier.
  • tsc produces no diff.
  • The real-R assertions were negative-checked: deliberately breaking the config
    so R can't launch fails 6 of 8, and the two that still pass are the ones
    correctly independent of R.

Suggested next step

With this in place, #596 can be split into separately-attributable steps —
Node, then http-proxy-3, then express+send together, then TypeScript — running
this suite after each, so a status change at the express+send step is a genuine
breaking change rather than something to bisect by hand.

🤖 Generated with Claude Code

jcheng5 and others added 6 commits August 29, 2026 13:33
test/integration/ boots a real, complete server on an ephemeral port for each
test, in about 15ms, and makes real HTTP requests against it. Built on
createServer_p from the previous commits.

test/support/ holds three pieces:

- server.js -- start_p(configText, options) resolving to a handle with .port,
  .baseUrl, .get_p(), .workerEntries() and .stop_p().
- config.js -- writes a throwaway config into a temp dir, substituting $USER,
  $ROOT and $DIR.
- fake-worker.js -- replaces lib/worker/app-worker's launchWorker_p with one
  that binds a real http.Server on the endpoint's port.

Only `su` and R are stubbed. The real TcpTransport, endpoint, shared secret,
connectEndpoint_p handshake and proxy all stay live -- which is the part an
Express upgrade actually touches.

The seam is a module-export assignment rather than rewire, because rewire loads
a second copy of the module and the server built by server-init.js would still
use the first. It works because scheduler.js resolves app_worker.launchWorker_p
as a property at call time. Note that setTransport() alone is not a sufficient
seam: spawnWorker calls posix.getpwnam(appSpec.runAs) and launchWorker_p
regardless of transport, which is why test configs must run_as the current user.

Two traps are worth recording, because in both cases the symptom points nowhere
near the cause:

- The harness pins its listener to 127.0.0.1 rather than the wildcard. With
  `listen 0` on `::`, the kernel can hand the server a port TcpTransport just
  probed-and-released for a worker; the worker then binds 127.0.0.1 on that same
  port, which *succeeds* -- a specific-address bind is allowed alongside a
  wildcard one -- and shadows the server for all loopback traffic. Requests
  silently reach the worker instead of Shiny Server.
- Requests go through http.request with `agent: false`, not fetch(). fetch
  pools keep-alive sockets per origin; test servers restart milliseconds apart
  and ephemeral ports get recycled, so a pooled socket belonging to a dead
  server gets handed to the next test. `Connection: close` is not a fix --
  fetch treats it as a forbidden header and drops it silently.

Together these took the suite from ~12% flaky to 0 failures in 25 consecutive
full runs.

mocha is not recursive, so package.json's "test" script and Jenkinsfile both
name the directories explicitly; .mocharc.json gains a timeout, since the 2s
default cannot survive a server boot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Independent of Express; this is the part meant to make those two refactors
safe. All of it had zero coverage.

- test/qutil.js -- forEachPromise_p (the router chain's control flow), map_p's
  sequencing (a naive Promise.all port would change I/O ordering), serialized,
  wrap, .eat().
- test/proxy-http.js -- ShinyProxy.httpListener's dispatch contract: the strict
  `appSpec === true` check, the 404/500/503 paths, and the acquire/release
  accounting including the _.once cleanup on both 'finish' and 'close'.
- test/scheduler-introspection.js -- shutdown() and dump(), the only places
  that inspect a promise synchronously (Q's isFulfilled/inspect().value, which
  has no native equivalent and so needs a design change during the Q removal).
  Pinned as observable outcomes rather than as the Q idiom.
- test/connect-endpoint.js -- the retry ladder and both abort paths, whose
  messages end up on the 500 page.
- test/config-{lexer,parser,schema}.js -- the hand-written config language,
  which the memory bank rates as the highest-value gap. Ported and expanded
  from the manual.test/ scripts nothing ever ran.

Two findings, characterized rather than fixed:

- qutil.serialized hands a *queued* caller the previous invocation's outcome.
  Q's .fin() settles with the original promise's value even when its callback
  returns a promise, so a queued caller gets whatever ran ahead of it. Benign
  today only because the sole production user is loadConfig_p, whose queued
  caller is the SIGHUP handler, which .eat()s the result -- but a live trap for
  the Q removal.
- connectEndpoint_p's `timeoutId` is assigned null and never set, so the
  clearTimeout on the success path is a no-op. What actually stops the retry
  loop is the !isPending() guard. Anyone tidying up the unused variable should
  know which one is load-bearing.

Also confirms that manual.test/test-config-config.js fails because of a stale
expectation, not a product bug: `run_as;` with no users is legal, because the
schema declares `param String users...`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test/integration-r/ launches actual R processes through the real AppWorker --
the stdin handshake, the port handshake, per-worker logging, teardown. It is
not part of `npm test`; run it with `npm run test:r`. It skips itself loudly
when R or the shiny package is missing, so a developer without R still gets a
useful signal from the rest of the suite; CI treats a skip as a provisioning
failure so it can't pass vacuously.

These run as the current user: AppWorker only shells out to `su` when
appSpec.runAs differs from the process user, so a `run_as $USER` config
exercises the real launcher without needing root. They also need an explicit
bookmark_state_dir -- otherwise the worker tries to mkdir
/var/lib/shiny-server/bookmarks and fails before R is ever started.

.github/workflows/ci.yml runs npm ci, the tests and the license check on Linux
and macOS, plus a **build-freshness check**: `tsc` followed by
`git diff --exit-code lib/`. Generated .js is committed next to the .ts and the
tests run against the generated files, so editing a .ts without running
`npm run build` currently passes CI while changing nothing at runtime.

Node is pinned from .nvmrc: master's nan (^2.18.0) does not compile against
Node 24, so anything else fails in node-gyp rather than anywhere informative.

The license check runs check-licenses.js and check-upstream.sh directly rather
than tools/preflight.sh, which invokes ./bin/node -- a runtime that only exists
after the full CMake build.

docker/jenkins/Dockerfile.ubuntu-20.04 installs the shiny package; it had
r-base only, so the real-R tier would have skipped itself there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its runner-setup and coverage sections went stale the moment test/integration/
existed. Records the tier layout and the fact that mocha is not recursive (so
package.json and Jenkinsfile must list directories in step), how the harness
works and why its seam is a module-export swap rather than rewire, the two
flakiness traps, and an honest recount of the coverage map -- including that
the SockJS and WebSocket paths are now the highest-value remaining gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…honest

The real-R job failed on CI while passing locally. app-worker.ts launches R
with a deliberately scrubbed environment -- HOME, LANG and PATH only -- so the
worker never sees R_LIBS_USER, which is where r-lib/actions installs by
default. `Rscript` in the job could load shiny; the worker could not, and
crash-looped one process per request (there is no spawn backoff), leaving five
worker logs behind.

Install into the site library instead, which R finds unconditionally, and
verify it afterwards using `env -i` with the same three variables a worker
gets. The Jenkins image already gets this right by accident: its Docker build
runs as root, where .libPaths()[1] is the site library.

Two changes so this cannot recur silently:

- hasShiny() now probes with the worker's environment rather than an inherited
  one. Before, the skip guard could pass while every test failed with a 500 --
  the worst of both worlds, since it neither ran nor announced itself.
- The app-page assertion prints the whole response body on failure. It is a
  rendered 500 carrying the tail of the worker's console log, i.e. R's actual
  complaint; truncating it made this failure pure archaeology.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.Library.site is NA on setup-r's R -- it ships without a site library -- so the
previous attempt resolved to the read-only base library and the install failed
outright. R searches R_HOME/site-library unconditionally once it exists, and
needs no environment variable to do so, which is what a worker's scrubbed
environment requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jcheng5
jcheng5 changed the base branch from jcheng5/server-init-extraction to master August 29, 2026 20:53
Brings in #600 so CI validates the harness against the native-addon change
rather than the base it was opened on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jcheng5
jcheng5 merged commit 855045f into master Aug 29, 2026
4 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.

1 participant