ReviewOS

stacks/rpx

rpx CLI hangs at startup on Linux: `rpx --help` prints nothing and never exits

#2267
Closed chris opened this 11 days ago ·
11 days ago

What happens

On Linux, running the CLI produces no output at all and the process stays alive indefinitely. rpx --help never prints its help and has to be killed. On macOS the same command works normally.

Reproduced on both GitHub runners in CI, ubuntu-latest (x64) and a linux/arm64 container, at 45 second timeouts.

Evidence

A test that spawns the CLI and waits for output fails with:

rpx --help produced nothing (still running after 45s)

The process is confirmed alive, not crashed: no error event from the spawn, and no exit event. Its stdout and stderr are both empty.

startGateway logs its route count and listening port unconditionally as its first action. That line appears in CI from the in-process tests and never from a spawned child, so the child is not reaching its command at all. The failure is in the entry point, before dispatch.

What is not affected

The gateway itself is fine on Linux. The in-process tests covering fragment merging, Host-header routing, HTTP-only serving and the 404 for an unknown host all pass on x64 and arm64.

Deployments are also unaffected: ts-cloud provisions the gateway as a compiled binary built from a launcher that imports startGateway directly, so it does not go through bin/cli.ts or the CLI framework.

Where to look

Nothing in the import graph does obvious blocking work at module scope, so the likely candidates are a side effect during module initialisation or the argument parser itself. Two module-scope constructions worth checking first are in packages/rpx/src/start.ts:

  • new ProcessManager()
  • new DefaultPortManager('0.0.0.0')

Also worth checking is the process.chdir at the top of packages/rpx/bin/cli.ts. It is written above the imports but, because ES module imports are hoisted, it actually runs after the whole graph has been evaluated, which is not what its comment describes.

Coverage

packages/rpx/test/gateway.test.ts has a case named "starts and prints its help" that reproduces this directly. It and the end-to-end gateway case are currently skipped on Linux so the rest of CI stays meaningful; both should be re-enabled with this fix.

11 days ago

Both cases are now skipped on Linux in packages/rpx/test/gateway.test.ts so the rest of CI can fail for real reasons. Remove the describe.skipIf when this is fixed.

11 days ago

Found it, and it's neither Linux nor the CLI: a leaked spawn spy from another test file.

process-manager.test.ts installs

spyOn(childProcess, 'spawn').mockImplementation(() => ({
  on: mock(() => {}), once: mock(() => {}),
  stdout: { on: mock(() => {}) }, stderr: { on: mock(() => {}) },
  kill: mock(() => true),
  pid: 12345,
}))

and its afterEach says // No cleanup needed - Bun's spyOn handles cleanup automatically. It doesn't. The spy sits on the shared node:child_process module object, so it stays installed for every file that runs after it in the same bun test process. Every later spawn gets that stub — no child starts, so nothing is ever written to stdout or stderr and no exit event ever fires.

That is precisely the evidence in the report: a live child, no spawn error, zero bytes. The pid CI reported for the stuck child was literally this file's 12345.

Why it looked Linux-only. bun test walks files in a deterministic but platform-dependent order. On the runners process-manager.test.ts landed before gateway.test.ts with no mock.restore() between them; on macOS a file that does call mock.restore() happened to fall in between. Forcing the order reproduces it anywhere, including macOS:

bun test packages/rpx/test/process-manager.test.ts packages/rpx/test/gateway.test.ts

45s timeout on the gateway CLI case before the fix; passes after.

How it was pinned. A bun:test preload keyed on Bun.main spawned a canary through the broken path before each file's first test. The transition was exact and identical on both runners — last OK process-manager.test.ts, first DUD the file immediately after. A variant sweep at the same moment showed Bun.spawn and spawnSync working while every node:child_process.spawn variant returned the stub, which is also why cli.test.ts passed in the same run that recorded these two failures: it uses spawnSync, which was never mocked.

Corrections to the report, for the record:

  • The CLI does not hang at startup on Linux. In the same run that recorded both 45s timeouts, cli.test.ts spawned the identical CLI and passed in 112ms.
  • new ProcessManager() and new DefaultPortManager('0.0.0.0') are pure field assignment, zero I/O — not implicated. There is no top-level await anywhere in the graph and no sync I/O at module scope.
  • The process.chdir in bin/cli.ts is a real defect — ESM hoisting means it runs after the whole graph evaluates, so it can't do what its comment claims — but it is unrelated to this hang.

Fix in #2268: mock.restore() in that afterEach, and the describe.skipIf removed so both gateway CLI cases run on every platform again. Green on both runners — the two cases that burned 45s each now take 109ms and 212ms.

One adjacent thing worth a separate issue: importing src/start.ts installs process-wide uncaughtException / unhandledRejection handlers that log and deliberately keep running. Right for a long-lived proxy, wrong for rpx --help, which inherits them via the CLI's import graph. It wasn't the cause here (those write to stderr, and stderr was empty), but it means any future startup failure in a short-lived command will present as this same silent hang rather than a non-zero exit.

Sign in to comment on this issue.