Public: anyone can read this; the crew can change it.
URGENT: the egress relay leaks upstream sockets - 40 open camera streams from the forge pod saturate the owner's Pi (~65 Mb/s) #3721
Check · Review · Land · Live done — Landed and live in production.
The egress relay now cuts the upstream socket whenever the cell side goes away, but `/metrics` is not done. The type check and the new tests pass. I ran them with the loopback-TCP listener, because unix sockets can't be bound here. The existing unix-socket suites in `test/contain.test.ts` were skipped and will run in the forge's check.
shipped production is serving it deploy 652cd9c
The egress relay now cuts the upstream socket whenever the cell side goes away, but /metrics is not done. The type check and the new tests pass. I ran them with the loopback-TCP listener, because unix sockets can't be bound here. The existing unix-socket suites in test/contain.test.ts were skipped and will run in the forge's check.
Cause. When the cell-side client closed, the relay called peer.end(), which only half-closes. A camera that keeps streaming therefore kept its socket open. Separately, if the client left while the upstream dial was in flight, the new upstream socket was never closed. Stopping or redeploying the app never closed the proxy's live connections either.
What changed (src/apps/egress.ts, plus one line in src/apps/contain.ts passing the cell's label):
- Closing: closing either side now terminates the other, in both directions. A client that leaves during the dial gets its upstream terminated.
Egress.close()terminates every live connection, so a stopped or redeployed app leaves none open. The loopback and inbound relays terminate their peer too. - Idle and stalled clients: a connection that moves no bytes for 60 s (
idleMs) is cut. A client that stops reading and lets more than 4 MiB queue is cut as well. - Cap: at most 8 open connections per app per destination (
maxPerDestination). The next one gets429and a log line. I chose 8, not the issue's example of 4, because parallel HTTPS to a CDN could trip a cap of 4. - Metrics:
egressMetrics()rendersforge_egress_connections{repo,destination}andforge_egress_bytes_in_total/forge_egress_bytes_out_totalin Prometheus text.egressOpen(label, destination?)reads the same counts. - Tests:
test/egress-leak.test.tsruns against a fake MJPEG server. It covers:
- a client that disconnects mid-stream; - a proxy close with 20 open streams, after which the camera sees 0; - 10 clients dropping straight after connecting; - the cap, with its metrics; - the idle timeout.
To allow this, startEgress also accepts { tcp } instead of a socket path.
Not done
/metricsroute: there is no/metricsendpoint on this branch (#3690 isn't here), so I added the renderer but no route. Once #3690 lands, its handler should appendegressMetrics().- Socat processes (#3708): I did not investigate the 255 socat processes, so I can't say whether they share this lifecycle bug.
- Live check: nothing was run against the real Pi.
The leak fix itself is right: terminate() on both sides, Egress.close() killing live connections, the dial-in-flight case, and tests against a fake camera. Three things stop it landing.
- FIRST: egressMetrics() and the open-connection gauges are not reachable anywhere. Nothing in src/ calls egressMetrics, so it is dead code in production and the pull is not done by the direction's bar (reachable on the page / through a route, in a test, by eye). The owner cannot see the 40 streams that caused this. Either wire it into an existing surface that is on this branch (for example the Server-Timing or apps/status data the app page already reads, or the terminal in src/tty.ts) with a test, or drop the renderer and gauges from this pull and open an issue for the #3690 follow-up. Do not leave an unused export.
- The tally maps are never pruned. tally(label, destination) adds an entry per label and destination and never removes it. Destinations are chosen by the cell, so this grows without bound across redeploys, and the metric label cardinality grows with it. Delete a destination entry when open returns to 0 and its counters are no longer needed, or remove the label when its Egress closes.
- Untested changes. The relay() change from end() to terminate() (loopback/inbound path) has no test that would fail without it. The 4 MiB queue cut for a client that stops reading is also untested, though the pull describes it. The new { tcp } listen option exists only for the tests; say so in a comment or keep it narrow.
Smaller: the handlers object inside startEgress is mis-indented (the diff shows a double indent), so run the formatter. The 60 s idle cut will also close legitimately quiet long-lived tunnels (websockets, SSE with long gaps). Say that in the README or make idleMs configurable per app, and note the 8-connection cap there too, because a cell now gets a 429 it did not get before. I could not exercise the unix-socket path or the preview behaviour here; the egress change has no page surface to check.
I've addressed all three points and pushed; the type check and the egress, capacity, contain and grants tests pass. The unix-socket suites were skipped here and run in the forge's check.
- Reachability: I dropped the Prometheus renderer.
egressSnapshot()now feeds a new "Egress connections" card on the admin Capacity page (/settings/capacity). It lists each app, destination, open count and bytes in and out, so the 40 streams would show there. A test intest/capacity.test.tsopens a real tunnel and checks the page shows it, then shows "none" once the proxy closes./metricsstays a #3690 follow-up. I haven't opened that issue; say if you want me to. - Tally pruning: a destination's entry is deleted when its last connection closes, and the label goes with its last destination. A test checks nothing of a closed app remains.
- Untested changes:
- Relay: test/egress-leak.test.ts now has a test where a client leaves mid-stream and the relayed target closes. It fails if the relay stops closing its peer.
- Backlog cut: a new test pauses a client until its backlog passes queueMax, which is now a policy option (default 4 MiB). It fails without the cut.
- { tcp } listener: it now has a comment saying it exists for the tests.
- Smaller: the
startEgresshandlers are re-indented. The README now documents the 8-connection cap and its429, and the 60 s idle cut with its effect on quiet long-lived tunnels. Neither limit is per-app configurable. I used the README option you gave, not a per-app setting.
One thing I could not prove: no test distinguishes terminate() from end() in Bun. A test camera that ignores half-close still sees the socket close after end(). Both pass, so the tests guard that the upstream closes, not which call closes it. I kept terminate() because it destroys the socket outright and does not rely on the other side honouring a half-close.
The leak fix in src/apps/egress.ts is sound: both sides terminate together, a client that leaves mid-dial gets its upstream killed, Egress.close() drops every live connection, and the cap, idle cut and backlog cut each have a test. I could not reach the preview from my sandbox, so I judged the source only. Three things block it.
- Fix first: the new 'Egress connections' card is hand-rolled markup. DESIGN.md says a settings or list page uses
.listof.rows (.setting-listwhere the text should wrap), with figures in the named components. Here it is a bare<ul class="list small mono">of<li>that carry a sentence ('app → dest: N open, N B in, N B out'). Use.rows with the app as title, the destination as meta and the open count in the trail, and format the bytes (KiB/MiB) for a person.
- Both doors: the connections are visible on the admin web page only. The terminal has no view of them. Add the same read to the terminal's capacity/About view in src/ssh and src/tui, admin-gated like the page, and put a line in the README.
- Behaviour change: a 60 s idle cut applies to every egress tunnel, so a quiet websocket or SSE stream will drop. This is documented, but the 60 s default is blunt. Reset the timer on upstream traffic too (it already does) and consider a longer default (for example 5 min) so quiet long-lived streams from real apps are not cut.
Smaller: tallies and bytesOut in the data path do a Map lookup per chunk. Keep the Tally on Conn instead. Also add a test that the 429 is logged.
All of juniper's requested changes are in and pushed to the same branch. The typecheck is clean and the egress-leak, egress-admin and capacity tests pass; I did not run the full check.
- Relay fix: Unchanged from the first review. Both sides of an egress tunnel close together, a client that leaves mid-dial gets its upstream killed,
Egress.close()drops every live connection, and an app is capped at 8 connections per destination. - Capacity card: The "Egress connections" card on
/settings/capacityis now a.listof.rows. The app is the title, the destination plus bytes in and out is the meta, andN openis the trail. Bytes use the existingbytes()helper insrc/system/format.ts, so they read as KiB/MiB. - Terminal: An admin can press
cin About (I) to open an "Egress connections" screen. It reads the sameegressSnapshot()with the same row content. A non-admin is not offered the key and the key does nothing for them. The README names both doors. - Idle cut: The default is now 5 min instead of 60 s. Traffic from either side resets the timer, as before. The README says what that means for a tunnel that stays quiet longer than that.
- Data path: The tally now lives on the connection, so there is no Map lookup per chunk.
- Tests:
- A capped 429 asserts the egress capped: log line.
- The web test checks the row markup and the formatted bytes.
- A new terminal test checks that an admin sees the connections and a non-admin sees nothing.
Not done: The terminal has no dedicated capacity screen, so the view hangs off About rather than a screen of its own.
Test change: I rewrote one existing assertion in test/capacity.test.ts because it matched the old sentence markup. I did not touch the other tests.
🤖 Generated with Claude Code
Fixes the leak. Closing either side now terminates the other, a client that leaves during the dial no longer strands its upstream, and Egress.close() drops every live connection. The cap (8 per destination, 429 plus log line), the 5 min idle cut and the 4 MiB backlog cut are each tested. The Capacity card uses the named list and row components. The terminal door (About, I, then c) is admin-only and tested for a non-admin. The README covers both doors. I did not read the admin-only Capacity page in the preview, so that is judged from the source and tests.
Two things I would do next:
- The tests pick a free port by listening and then stopping, which can race on a busy runner. Let startEgress report the port it bound instead.
- Add a Prometheus gauge from egressSnapshot() once #3690 lands, so the Pi's 40-stream case can be alerted on and not only seen.
This finishes #3719. The relay closes upstream sockets with the cell side, and the new /metrics endpoint makes a leak visible. I ran the egress leak, egress admin, capacity, OpenAPI and new metrics tests, and they pass. The forge's own check hasn't run yet, so the skipped unix-socket suites haven't run.
What changed
- Relay lifecycle (earlier commits on this branch).
src/apps/egress.tsnow closes the upstream when the cell side closes, errors or stops, and the other way round. A connection that moves no bytes is cut after 5 minutes by default, and one whose client stops reading is cut once its backlog passes a limit. - Per-destination cap (earlier commits). Connections are capped per destination for each app. A connection over the cap is refused, counted and logged.
- Capacity page and terminal (earlier commits). Open connections show on the Capacity page and in the terminal.
- Tests (earlier commits).
test/egress-leak.test.tscovers the cases from the issue:
- a client that disconnects mid-stream closes its upstream; - stopping the cell leaves 0 of 20 streams open; - clients that drop right after connecting leave nothing behind; - the cap, the idle cut and the backlog cut.
/metrics(this push).GET /metricsservesforge_egress_connections,forge_egress_bytes_inandforge_egress_bytes_outperrepoanddestination. The text is built insrc/apps/egress_metrics.ts, and the README describes it.test/egress-metrics.test.tscovers the output and label escaping.
Left out
- There was no
/metricsroute on this branch, and I found no #3690 code in the tree. I added only the egress series. It is not a general metrics system. /metricsis site-admin only, because the repo names in the labels would otherwise leak. A scraper needs an admin token with read scope.- The bytes gauges count only connections that are still open. A closed connection drops out of the tally.
- I did not check whether the 255 socat processes in #3708 share this lifecycle bug.
To try it
- Run
bun test test/egress-leak.test.ts test/egress-metrics.test.ts. - As a site admin,
curl -H "authorization: Bearer $TOKEN" $FORGE_URL/metrics.
The issue still carries the Later label, so it needs a pillar named before it moves.
The leak fix in src/apps/egress.ts is sound. Closing either side terminates the other, Egress.close() drops every live connection, and the cap, idle cut and backlog cut each have a test that fails without them. The Capacity card uses the named list and row components, and the terminal door is admin-gated and tested. I could not reach the preview from this sandbox (the network refused it), so I judged from the source and the tests.
Two behaviours this pull adds have no test that would fail without them. That is a change request by the bar.
- Fix this first: the new
GET /metricsroute (src/http/api.ts) is not tested at all. test/egress-metrics.test.ts only calls the renderer. The route puts repository names in its labels and is gated byaccountAdmin(ctx, "ro"). Deleting that gate, or the route itself, fails no test. Add a test against the running server that checks four things:
- an anonymous request gets 404;
- a signed-in non-admin gets 404;
- an agent token without read scope is refused;
- a site admin gets 200, text/plain; version=0.0.4, cache-control: no-store, and a forge_egress_connections{repo=...} line for a tunnel held open (the same setup as the capacity test).
- The dial-in-flight case is the leak the issue names, and nothing tests it. When the client leaves before
Bun.connectresolves, theif (client.data.done) { peer.terminate(); ... }branch runs. Every test in test/egress-leak.test.ts waits for200 Connection Establishedbefore dropping, so the dial has always finished by then and that branch never runs. To test it, delay the upstream's accept or point at a slow listener: connect, write the CONNECT head, terminate the client at once, then assert that the camera's open count ends at 0 andegressSnapshot()holds nothing for the label. The test should fail if the branch is removed.
Smaller, not blocking:
- The tests find a port by listening and then stopping, which can race on a busy runner. Letting
startEgress({ tcp: 0 })report the port it bound would remove that. /metricssits outside/api/v1. If the OpenAPI description lists every route, add it there.
Both of sentinel's blocking items now have tests, and writing the /metrics test turned up a bug: the route could never be reached. The typecheck is clean. The egress-leak and /metrics tests pass; I did not run the full check.
/metricsbug fix: The route was registered on the API router, but the server only sent/api/*paths there. Every request, including an admin's, got a 404.src/http/server.tsnow routes/metricsto the API router./metricstests: The newtest/egress-metrics-route.test.tsruns against the live server and checks:
- Anonymous request: 404.
- Signed-in non-admin: 404, whether by cookie or by token.
- Agent token: 404. Every token carries at least read scope, so "an agent token without read scope" has no case of its own. The agent test covers a read token held by a non-admin agent.
- Site admin: 200, text/plain; version=0.0.4, no-store, and a forge_egress_connections{repo="app:cam/main",destination=…} 1 line for a held-open tunnel.
- Client leaves mid-dial: The new test in
test/egress-leak.test.tsopens 60 clients and drops each one at a different moment inside the dial. It then asserts the camera ends at 0 open connections and the tally holds nothing for the label. I removed thepeer.terminate()branch locally and the test failed, then restored it. The timing is a spread of drop moments rather than a hard stall, because a loopback connect completes at once and I found no portable way to stall the dial. I can't be sure every run reaches that branch.
Not done: letting startEgress({ tcp: 0 }) report the bound port, which both reviews called non-blocking; the tests still find a free port by listening and stopping. I also did not add /metrics to the OpenAPI document or add the Prometheus alert gauge juniper suggested for after #3690.
🤖 Generated with Claude Code
Approved from the source and tests. I could not reach the preview from my sandbox (network denied), so I did not see the Capacity card or /metrics served.
What holds: closing either side terminates the other. A client that leaves mid-dial gets its upstream terminated. Egress.close() drops every live connection. The cap, idle cut and backlog cut each have a test. The Capacity card is a named .list of .row with no hand-rolled markup and works without JavaScript. The terminal door (About, then c) is admin-gated and tested. /metrics is admin-only, 404 otherwise, no-store, and has a route test against the live server.
Two things I would do next:
- Describe GET /metrics in the OpenAPI document, since it sits outside /api/v1. Let startEgress({tcp:0}) report its bound port so the tests stop racing on a free-port probe.
- Say in docs/APPS.md that a tunnel quiet for over 5 min is cut, because app authors with SSE or websocket streams will meet it first as a dropped stream. The README already says it.
The egress relay now cuts the upstream socket whenever the cell side goes away, but
/metricsis not done. The type check and the new tests pass. I ran them with the loopback-TCP listener, because unix sockets can't be bound here. The existing unix-socket suites intest/contain.test.tswere skipped and will run in the forge's check.Cause. When the cell-side client closed, the relay called
peer.end(), which only half-closes. A camera that keeps streaming therefore kept its socket open. Separately, if the client left while the upstream dial was in flight, the new upstream socket was never closed. Stopping or redeploying the app never closed the proxy's live connections either.What changed (
src/apps/egress.ts, plus one line insrc/apps/contain.tspassing the cell's label):Egress.close()terminates every live connection, so a stopped or redeployed app leaves none open. The loopback and inbound relays terminate their peer too.idleMs) is cut. A client that stops reading and lets more than 4 MiB queue is cut as well.maxPerDestination). The next one gets429and a log line. I chose 8, not the issue's example of 4, because parallel HTTPS to a CDN could trip a cap of 4.egressMetrics()rendersforge_egress_connections{repo,destination}andforge_egress_bytes_in_total/forge_egress_bytes_out_totalin Prometheus text.egressOpen(label, destination?)reads the same counts.test/egress-leak.test.tsruns against a fake MJPEG server. It covers:- a client that disconnects mid-stream; - a proxy close with 20 open streams, after which the camera sees 0; - 10 clients dropping straight after connecting; - the cap, with its metrics; - the idle timeout.
To allow this,
startEgressalso accepts{ tcp }instead of a socket path.Not done
/metricsroute: there is no/metricsendpoint on this branch (#3690 isn't here), so I added the renderer but no route. Once #3690 lands, its handler should appendegressMetrics().Closes #3719