You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The orchestrator's HTTP server burns exactly one full CPU core, continuously and indefinitely, once any upstream proxy half-closes a keep-alive connection. The process never recovers on its own.
Observed on chrisleekr/github-app:1.15.0-orchestrator (Bun 1.3.14) running behind ingress-nginx. The pod had burned 13.93 days of CPU over 13.95 days of wall-clock (99.88%) before it was restarted.
This is not a timer/poller problem, and not a proxy misconfiguration. It is a socket-lifecycle defect in Bun's node:http layer that this app is exposed to because src/app.ts never sets a server timeout.
Impact
~1 CPU core wasted per orchestrator pod, permanently, from ~20 minutes after startup.
The spin is binary, not cumulative: a single stuck socket pegs the event-loop thread at 100%. Seven stuck sockets cost exactly the same as one. This is why CPU is flat rather than ramping, and why it is easy to mistake for "normal load".
Restarting the pod is not a workaround. The pod was idle for only ~22 minutes of its 13.95-day life, i.e. the spin re-established itself within ~20 minutes of boot.
epoll_pwait2 is called ~32,000 times per second with zero errors. clock_gettime runs at exactly 2.0002x that rate (the event loop timing each iteration).
The raw calls show exactly what it is spinning on:
Every call returns the same 7 fds, all EPOLLRDHUP, with identical data pointers. Bun asks to wait ~78ms (tv_nsec visibly counts down toward its next timer deadline across successive calls) but epoll returns instantly, because those 7 fds are permanently ready.
Those 7 fds correspond one-for-one with 7 sockets stuck in CLOSE_WAIT:
State Recv-Q Local Address:Port Peer Address:Port
CLOSE-WAIT 1 [::ffff:10.0.0.206]:3000 [::ffff:10.0.0.67]:48232
CLOSE-WAIT 1 [::ffff:10.0.0.206]:3000 [::ffff:10.0.0.67]:42786
... (7 total)
10.0.0.67 is the ingress-nginx controller. Recv-Q=1 is the undrained FIN.
Supporting measurements:
Exactly 1.000 cores, flat for 24h (Prometheus rate(container_cpu_usage_seconds_total[5m])).
nr_throttled 0, throttled_usec 0 against cpu.max = 200000 100000 (a 2-core limit) — the process could use 2 cores and is not being capped. The spin is genuine.
user_usec 56.0% / system_usec 44.0% — the kernel half is the epoll_pwait2 storm.
Only 2.9 voluntary context switches/sec while burning a full core: the thread essentially never sleeps.
Per-thread accounting: thread 1 holds 120,353,370 of 120,387,653 ticks (99.97%). Every other thread is idle.
Timers are excluded:timerfd_settime was called 2 times in 8 seconds. proposal-poller runs at intervalMs=90000 and scheduler at intervalMs=300000. Neither can produce a 32kHz loop.
Mechanism
ingress-nginx is configured with upstream-keepalive-timeout: 5 — it closes idle upstream connections after 5s. This is normal, correct proxy behaviour.
The peer's FIN arrives. Our socket enters CLOSE_WAIT. Bun registered EPOLLRDHUP interest on it.
Bun never handles the EPOLLRDHUP: it does not close() the fd, nor epoll_ctl(EPOLL_CTL_DEL) it. (close fired 2x and epoll_ctl 6x across 8 seconds — nowhere near the 7 needed.)
epoll is level-triggered, so the fd stays permanently ready. epoll_pwait2 returns immediately, forever. One core, gone.
It is a race, not a rule — nginx closed thousands of connections over 14 days and only 7 stuck. So timeout tuning changes the odds, not the mechanism.
Suggested fix
src/app.ts:326 never sets a server timeout:
constserver=http.createServer((req,res)=>{ ... });// line 244server.listen(config.port,()=>{ ... });// line 326
Per a Bun maintainer in oven-sh/bun#12446, the supported knob for node:http servers in Bun is server.setTimeout() (since v1.1.27) — notserver.keepAliveTimeout, which Bun does not honour the same way as Node.
Options, roughly in order of preference:
Set server.setTimeout() below the proxy's idle timeout so we close first and never enter CLOSE_WAIT. Note this inverts the usual advice (for AWS ALB you want the server timeout longer than the LB's, to avoid a 502 race). nginx's proxy_next_upstream error retries connection-level failures by default, which makes the trade more acceptable here than it would be behind an ALB — but it is a real trade and should be verified under load.
Move the server to Bun.serve(), which supports idleTimeout natively and avoids the node:http shim entirely. Larger change; the app is already on Hono, which has a native Bun adapter.
Report upstream to Bun. I could not find an existing issue for EPOLLRDHUP/CLOSE_WAIT spin in node:http. The closest is oven-sh/bun#33699 (node:http leaking on client disconnect, confirmed on 1.3.14, closed 2026-07-07) — same subsystem, different symptom. Bun 1.3.14 is the latest release (2026-05-13), so there is no version to upgrade to.
Whatever the app does, the underlying Bun defect should be fixed upstream: a half-closed peer connection must not be able to peg an event loop forever.
Environment
Image
chrisleekr/github-app:1.15.0-orchestrator
Bun
1.3.14 (latest release, 2026-05-13)
Server
node:http via http.createServer (src/app.ts:244), Hono 4.12.25
Platform
Kubernetes 1.33.4, Linux x64, Debian 13 (trixie) base
requests: cpu=500m/512Mi, limits: cpu=2/2Gi, Burstable, caps dropped ALL, uid 1000
Reproduction sketch
Not yet reduced to a standalone case. Expected shape:
http.createServer under Bun 1.3.14, listening, no setTimeout set.
A client that opens keep-alive connections and half-closes them after a shorter idle period than Bun's 10s default (mimicking upstream-keepalive-timeout: 5).
Drive traffic until a connection lands in CLOSE_WAIT (it is a race; may take a while).
Observe: ss -tan state close-wait non-empty, and CPU pinned at exactly 1.00 core with strace showing epoll_pwait2 returning EPOLLRDHUP in a tight loop.
A minimal repro is the main thing needed before filing upstream with Bun.
Summary
The orchestrator's HTTP server burns exactly one full CPU core, continuously and indefinitely, once any upstream proxy half-closes a keep-alive connection. The process never recovers on its own.
Observed on
chrisleekr/github-app:1.15.0-orchestrator(Bun 1.3.14) running behind ingress-nginx. The pod had burned 13.93 days of CPU over 13.95 days of wall-clock (99.88%) before it was restarted.This is not a timer/poller problem, and not a proxy misconfiguration. It is a socket-lifecycle defect in Bun's
node:httplayer that this app is exposed to becausesrc/app.tsnever sets a server timeout.Impact
Evidence
straceon the main thread (PID 1), ~8 seconds:epoll_pwait2is called ~32,000 times per second with zero errors.clock_gettimeruns at exactly 2.0002x that rate (the event loop timing each iteration).The raw calls show exactly what it is spinning on:
Every call returns the same 7 fds, all
EPOLLRDHUP, with identicaldatapointers. Bun asks to wait ~78ms (tv_nsecvisibly counts down toward its next timer deadline across successive calls) but epoll returns instantly, because those 7 fds are permanently ready.Those 7 fds correspond one-for-one with 7 sockets stuck in
CLOSE_WAIT:10.0.0.67is the ingress-nginx controller.Recv-Q=1is the undrained FIN.Supporting measurements:
rate(container_cpu_usage_seconds_total[5m])).nr_throttled 0,throttled_usec 0againstcpu.max = 200000 100000(a 2-core limit) — the process could use 2 cores and is not being capped. The spin is genuine.user_usec56.0% /system_usec44.0% — the kernel half is theepoll_pwait2storm.Timers are excluded:
timerfd_settimewas called 2 times in 8 seconds.proposal-pollerruns atintervalMs=90000andscheduleratintervalMs=300000. Neither can produce a 32kHz loop.Mechanism
upstream-keepalive-timeout: 5— it closes idle upstream connections after 5s. This is normal, correct proxy behaviour.node:httpserver uses a default idle timeout of 10s (uWSHTTP_IDLE_TIMEOUT_S, see Ability to set keepAliveTimeout in server created by "node:http" like in Nodejs. oven-sh/bun#12446). Because 10s > 5s, nginx always closes first.CLOSE_WAIT. Bun registeredEPOLLRDHUPinterest on it.EPOLLRDHUP: it does notclose()the fd, norepoll_ctl(EPOLL_CTL_DEL)it. (closefired 2x andepoll_ctl6x across 8 seconds — nowhere near the 7 needed.)epoll_pwait2returns immediately, forever. One core, gone.It is a race, not a rule — nginx closed thousands of connections over 14 days and only 7 stuck. So timeout tuning changes the odds, not the mechanism.
Suggested fix
src/app.ts:326never sets a server timeout:Per a Bun maintainer in oven-sh/bun#12446, the supported knob for
node:httpservers in Bun isserver.setTimeout()(since v1.1.27) — notserver.keepAliveTimeout, which Bun does not honour the same way as Node.Options, roughly in order of preference:
server.setTimeout()below the proxy's idle timeout so we close first and never enterCLOSE_WAIT. Note this inverts the usual advice (for AWS ALB you want the server timeout longer than the LB's, to avoid a 502 race). nginx'sproxy_next_upstream errorretries connection-level failures by default, which makes the trade more acceptable here than it would be behind an ALB — but it is a real trade and should be verified under load.Bun.serve(), which supportsidleTimeoutnatively and avoids thenode:httpshim entirely. Larger change; the app is already on Hono, which has a native Bun adapter.EPOLLRDHUP/CLOSE_WAITspin innode:http. The closest is oven-sh/bun#33699 (node:httpleaking on client disconnect, confirmed on 1.3.14, closed 2026-07-07) — same subsystem, different symptom. Bun 1.3.14 is the latest release (2026-05-13), so there is no version to upgrade to.Whatever the app does, the underlying Bun defect should be fixed upstream: a half-closed peer connection must not be able to peg an event loop forever.
Environment
chrisleekr/github-app:1.15.0-orchestratornode:httpviahttp.createServer(src/app.ts:244), Hono 4.12.25upstream-keepalive-timeout: 5,upstream-keepalive-time: 30srequests: cpu=500m/512Mi,limits: cpu=2/2Gi, Burstable, caps dropped ALL, uid 1000Reproduction sketch
Not yet reduced to a standalone case. Expected shape:
http.createServerunder Bun 1.3.14, listening, nosetTimeoutset.upstream-keepalive-timeout: 5).CLOSE_WAIT(it is a race; may take a while).ss -tan state close-waitnon-empty, and CPU pinned at exactly 1.00 core withstraceshowingepoll_pwait2returningEPOLLRDHUPin a tight loop.A minimal repro is the main thing needed before filing upstream with Bun.