#563

feat(proxy): add opt-in --idle-exit to stop the proxy after its last app exits

OpenKnat-Devwants to merge intofrom

Discussed in #562.

Summary

The proxy that portless run auto-starts keeps running after every app has exited, and keeps holding its port until portless proxy stop or a reboot. This adds an opt-in idle exit: the proxy stops a few seconds after its last app exits, and the next portless run starts it again. With the option off, behavior is unchanged.

  • --idle-exit on proxy start, or PORTLESS_IDLE_EXIT=1 for auto-start. Stored in a proxy.idle-exit state file like the TLS, LAN and unprivileged markers, so every auto-started proxy keeps it. PORTLESS_IDLE_EXIT=0 turns a remembered setting off.
  • The proxy stops 5 seconds after the last app exits, long enough that restarting a dev server in place reuses the running proxy. --idle-exit-after <seconds> / PORTLESS_IDLE_EXIT_AFTER changes the wait and is rejected without idle exit.
  • Until the first app connects, the proxy stays up for at least 30 seconds, so an auto-start has time to register the app it was started for.
  • Static aliases, including compose up -d routes, keep the proxy up.
  • service install refuses the option, since launchd or systemd would restart the proxy as soon as it stopped. The service environment sets PORTLESS_IDLE_EXIT=0.
  • MCP proxy_start takes idleExit and idleExitAfter.

How it works

Each portless run holds a lease: one open connection to the proxy for as long as the run lives. The operating system closes it however the run ends (Ctrl+C, SIGTERM, a closed terminal, SIGKILL, or a crash), so the proxy learns the app is gone from the socket's close event. When the last lease closes, a single timer starts. A new lease cancels it. Nothing polls.

  • idle-exit.ts: the tracker. It holds leases, arms one setTimeout when the last one closes, fires at most once, refuses leases after firing and ends held leases on shutdown. It takes a structural connection type and injected timers, so its tests run without sockets.
  • proxy.ts: POST /.portless/lease answers 200 with one leased\n line and never ends the response. The open response is the lease.
  • cli-utils.ts: holdProxyLease. A lease counts once the client reads the ack line; then the client clears its timeouts and unrefs the socket, so a lease never keeps portless run alive. Against an HTTPS proxy it uses TLS with rejectUnauthorized: false, like hosts sync. An older proxy answers 404 and the run continues without a lease.
  • cli.ts: every path that registers routes takes a lease afterwards: runApp, multi-app spawn, turbo and both compose up paths. The daemon wires the tracker into startProxyServer and stops through its existing shutdown path. Unlike proxy stop, an idle exit keeps the remembered port, TLS, custom cert, TLD and LAN settings, so the next auto-start comes back on the same URLs.

routes.json stays the record of routes and is unchanged. When a countdown ends, the proxy reads it once. If an alias or a route with a live owner is still there, it restarts the countdown instead of stopping. That covers a server that daemonized itself (#444), whose launcher has exited and released its lease, and a run whose route is registered but whose lease hasn't landed yet. If a run reaches a proxy that already stopped, its lease attempt fails and it starts a fresh proxy.

A running proxy started with a different idle-exit setting is not treated as a config mismatch, because the setting doesn't affect routing. proxy start --idle-exit against such a proxy prints a note to restart it.

Security considerations

The lease endpoint is new surface on a shared local daemon, so it is guarded like the hosts-sync endpoint:

  • Loopback peers and loopback Host values only. Any other Host is routed as a normal request.
  • It requires exactly one x-portless-lease: 1 header, which forces a CORS preflight from a browser that the proxy never answers, and it refuses requests carrying Origin or Sec-Fetch-Site. A page open in a browser can't hold the proxy open.
  • At most 32 leases (429 beyond that), so the daemon doesn't accumulate open sockets. After the idle exit fires, new leases get 503.

Leases are taken whether or not idle exit is on, which keeps one code path in run. A lease costs one idle loopback socket per running app. If the proxy restarts while apps are running, their old leases are gone, and the countdown-end check of routes.json keeps the new proxy up for as long as those apps live.

Test plan

  • idle-exit.test.ts (14 tests, fake timers): boot grace, countdown after the last lease, a new lease cancels it, several leases, fire once, refuse after firing, closeAll, the cap, aliases pin it, a live lease-less route restarts the countdown.
  • proxy.test.ts "lease route" (11): the 403 / 404 / 429 / 503 cases, the ack line, holding and closing.
  • cli-utils.test.ts, service.test.ts, mcp.test.ts: parsing, precedence and errors, the state file, generated start arguments, client results, service install refusals, MCP arguments.
  • cli.test.ts (26, real daemons and apps): the proxy stops after the last run ends by SIGINT, SIGTERM, SIGHUP or SIGKILL and after an app crash, each checking the process is gone, the port is free and the pid file is removed. Also: stays up while another app lives, an alias keeps it up, --unprivileged --wildcard --tld test -p together, an HTTPS proxy, the setting survives a stop and an auto-start, a proxy started from flags only comes back on the same port, TLS and TLD after an idle exit, a run that lands after an idle exit starts a new proxy and is served, flag and env precedence, validation errors and the service install refusals.
  • pnpm format:check, lint, type-check and build pass. pnpm test: 1304 passed. Two failures also fail on main on the same machine: certs.test.ts (#429, OpenSSL) and the mcp.test.ts "rejects every flag" test, which hits its 5 s timeout under full-suite load on both and passes alone. The docs route test passes (89/89) with refreshed hashes for /commands and /configuration.

End to end with real apps, an isolated state directory and no TTY, using the default 5 second wait:

ScenarioResult
Last run ends by SIGINT, SIGTERM, SIGHUP or SIGKILL, or the app crashesProxy still up during the 5 s wait, then gone: port free, process gone, pid file removed
--unprivileged on port 80, no sudo, no TTY, one round per way of stoppingPort 80 freed after each round, and the next run starts a new proxy that takes port 80 again
Two apps, closed one after the otherProxy stays up after the first, stops after the second
Nx app with Storybook beside it (--name storybook.web)Closing Storybook keeps the proxy up; closing the app stops it
pnpm workspace and Turborepo multi-app runsStop 5 s after the last app
Restart a dev server within the waitSame proxy process keeps serving
portless alias with no appsProxy stays up; stops after alias --remove
Command that backgrounds its server and exits (#444)Proxy stays up while the server lives, stops after it exits
--idle-exit-after 2Stops about 2 s after the last app
--lanStops after the last app
Lease held for 6 minutesProxy up and serving the whole time
Lease endpoint without the header, with Origin, with Sec-Fetch-Site, or with a non-loopback HostRefused; a valid request gets leased and stays open
proxy start --unprivileged --no-tls --idle-exit, nothing exported; app closes, then a bare portless runThe new proxy comes back on port 80, plain HTTP and unprivileged, with no sudo
Idle exit offProxy keeps running after the last app, as on main

Activity

11
vercel[bot]7d ago

@Knat-Dev is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

vercel[bot]reviewed7d ago
Knat-Devreviewed7d ago
Knat-Devreviewed7d ago
vercel[bot]reviewed7d ago
vercel[bot]reviewed7d ago
Knat-Dev7d ago

Related: #566 fixes #564 for an explicit proxy stop or kill (unprivileged mode came back without its HTTPS). This PR already keeps every remembered setting across an idle exit. Both touch the same cleanup block in startProxyServer, so whichever lands second needs a small rebase, which I'll do.