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-exitonproxy start, orPORTLESS_IDLE_EXIT=1for auto-start. Stored in aproxy.idle-exitstate file like the TLS, LAN and unprivileged markers, so every auto-started proxy keeps it.PORTLESS_IDLE_EXIT=0turns 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_AFTERchanges 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 -droutes, keep the proxy up. service installrefuses the option, since launchd or systemd would restart the proxy as soon as it stopped. The service environment setsPORTLESS_IDLE_EXIT=0.- MCP
proxy_starttakesidleExitandidleExitAfter.
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 onesetTimeoutwhen 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/leaseanswers 200 with oneleased\nline 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 keepsportless runalive. Against an HTTPS proxy it uses TLS withrejectUnauthorized: 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 bothcompose uppaths. The daemon wires the tracker intostartProxyServerand stops through its existing shutdown path. Unlikeproxy 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
Hostvalues only. Any otherHostis routed as a normal request. - It requires exactly one
x-portless-lease: 1header, which forces a CORS preflight from a browser that the proxy never answers, and it refuses requests carryingOriginorSec-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 installrefusals, 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 -ptogether, 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 theservice installrefusals.pnpm format:check,lint,type-checkandbuildpass.pnpm test: 1304 passed. Two failures also fail onmainon the same machine:certs.test.ts(#429, OpenSSL) and themcp.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/commandsand/configuration.
End to end with real apps, an isolated state directory and no TTY, using the default 5 second wait:
| Scenario | Result |
|---|---|
| Last run ends by SIGINT, SIGTERM, SIGHUP or SIGKILL, or the app crashes | Proxy 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 stopping | Port 80 freed after each round, and the next run starts a new proxy that takes port 80 again |
| Two apps, closed one after the other | Proxy 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 runs | Stop 5 s after the last app |
| Restart a dev server within the wait | Same proxy process keeps serving |
portless alias with no apps | Proxy 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 2 | Stops about 2 s after the last app |
--lan | Stops after the last app |
| Lease held for 6 minutes | Proxy up and serving the whole time |
Lease endpoint without the header, with Origin, with Sec-Fetch-Site, or with a non-loopback Host | Refused; a valid request gets leased and stays open |
proxy start --unprivileged --no-tls --idle-exit, nothing exported; app closes, then a bare portless run | The new proxy comes back on port 80, plain HTTP and unprivileged, with no sudo |
| Idle exit off | Proxy keeps running after the last app, as on main |
Activity
114f5f757Related: #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.
@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.