Skip to content

fix: hold idle HTTP connections open past the OS proxy's reuse window; 33.0.8:1 → 33.0.8:2 - #125

Merged
MattDHill merged 1 commit into
masterfrom
fix/proxy-keepalive-flap
Aug 17, 2026
Merged

fix: hold idle HTTP connections open past the OS proxy's reuse window; 33.0.8:1 → 33.0.8:2#125
MattDHill merged 1 commit into
masterfrom
fix/proxy-keepalive-flap

Conversation

@stupleb

@stupleb stupleb commented Aug 17, 2026

Copy link
Copy Markdown
Member

Fixes the periodic Connected → "Network error" → reconnect cycle reported with Nextcloud Desktop (Windows and Linux), which correlates with paired 10.0.3.1 - - "-" 408 0 "-" "-" entries in the service log. Sync was never at risk — the flap is cosmetic — but it fires on essentially every idle cycle of the desktop client's 30 s connection validator.

Why it happens

The StartOS reverse proxy pins each client connection to a single backend connection (dialed at TLS ClientHello, never re-dialed) and holds idle client connections for up to 60 s, while stock Debian Apache closes its side after 5 s of keep-alive idle and 408s request-less connections after ~20 s (mod_reqtimeout). So the backend leg dies first, the proxy keeps the client leg looking healthy, and the client's next poll aborts mid-request — surfacing as QNetworkReply::RemoteHostClosedError. The 408 pairs are the desktop client's speculative spare sockets being reaped by mod_reqtimeout through the proxy's eager dial. Full analysis with code pointers and repros: Start9Labs/start-technologies#3731.

The proper fix is in the OS proxy (per-request upstream dialing, tracked in that issue). This PR is the package-side hardening that removes the user-visible symptom now, and it independently also fixes the .onion leg, which bypasses the proxy (tor dials the container directly) and hits the same 5 s close raw.

The change

A conf snippet baked into the image (startos-proxy-keepalive.conf):

KeepAliveTimeout 75
RequestReadTimeout header=90
  • 75 s keep-alive > the proxy's 60 s client-idle window and > the sync client's 30 s poll — so idle connections are always closed from the client side inward, never mid-pair by Apache. Polls reuse live connections instead of racing dead ones.
  • 90 s header timeout > the proxy's 60 s — so a request-less connection is torn down by the proxy (client side first) before Apache ever 408s it. The body-read timeout (upload slowloris protection) is untouched.
  • Cost: an idle connection pins an mpm_prefork child for up to 75 s instead of 5 s. At household scale (a handful of clients × ~2 connections, MaxRequestWorkers 150) this is negligible.

Also bumps 33.0.8:1 → 33.0.8:2 with release notes (all five locales) and documents the tuning and its diagnosis in the README's network section.

Verification

Empirical, against nextcloud:33.0.8-apache containers (stock vs. this snippet, via the exact printf/a2enconf the Dockerfile runs):

Probe Stock With snippet
Keep-Alive advertised timeout=5, max=100 timeout=75, max=100
Second request after 12 s idle on one connection connection already closed succeeds
Stalled request (GET then silence) closed at 20.0 s, logged "-" 408 0 "-" "-" still open past 65 s

npm run check (tsc) passes. Not verified: an install on a live StartOS box (none available from this session) — the meaningful acceptance check after merge/sideload is: desktop client stays Connected across idle periods, and "-" 408 entries stop appearing in the service log during normal client use.

Note for the merger: pushing this to master triggers tagAndRelease → v33.0.8_2 publishes to the registry. Merging is releasing.

🤖 Generated with Claude Code

…; 33.0.8:1 -> 33.0.8:2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@MattDHill MattDHill left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the diagnosis independently rather than taking the issue's word for it, and every load-bearing claim holds.

Proxy side (start-technologies @ 8a916a0da): run_http1_proxy handshakes once on the single upstream stream and shares it behind Arc<Mutex> (net/http.rs:330) — nothing re-dials; header_read_timeout is a hardcoded Duration::from_secs(60) (net/http.rs:337), so the 60 s figure is stable; try_join(from.with_upgrades(), to.with_upgrades()) (net/http.rs:412) means the upstream future resolving Ok on Apache's FIN leaves the client leg running; the failed send_request returns Err out of the service_fn (net/http.rs:368) so hyper aborts with no response written — precisely RemoteHostClosedError. Eager dial confirmed at net/vhost.rs:1096 (inside preprocess, the ClientHello hook), and http_aware selection at net/vhost.rs:1206/1226. The http preset sets addXForwardedHeaders: true and pins ALPN http/1.1 (Host.ts:23-29), and interfaces.ts binds protocol: 'http', so this package is always on the affected h1 path. HOST_IP = [10,0,3,1] (lib.rs:8) matches the log signature.

Apache side, measured in the image (nextcloud:32.0.6-apache — same Debian/php base): apache2.conf:111 sets KeepAliveTimeout 5 and IncludeOptional conf-enabled/*.conf is at apache2.conf:222, so the snippet parses last and wins; mods-enabled/*.conf is included at line 147, so header=90 overrides header=20-40,minrate=500 while body=10,minrate=500 survives untouched. Running the exact printf + a2enconf: syntax OK, reuse after 12 s idle goes FAILED → OK, and a stalled request goes closed-at-20.0 s → still open at 45 s. The PR's table reproduces. Only /var/www/html and the postgres dir are volume-mounted, so nothing shadows the baked config.

Ordering is right — 75 > 60 > 30 — and raising is the only direction that works; KeepAlive Off would be strictly worse, since the single pinned upstream would die after one request.

One thing worth recording in #3731: MaxKeepAliveRequests 100 is untouched and this change is what makes it reachable, since connections now live minutes instead of 5 s. It does not reopen the flap — Apache emits Connection: close on the 100th response, hyper's client preserves the header (h1/role.rs:1109-1130) and hyper's server honors it on encode (role.rs:821-830), so both legs close in-band.

Approving. The unverified part is exactly the part the description flags as unverified.

@MattDHill
MattDHill merged commit b08a4f4 into master Aug 17, 2026
1 of 3 checks passed
@MattDHill
MattDHill deleted the fix/proxy-keepalive-flap branch August 17, 2026 16:24
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.

2 participants