feat(frontend): bound stdio inbound reads and report every drop path - #322
Open
jaronoff97 wants to merge 1 commit into
Open
jaronoff97 wants to merge 1 commit into
jaronoff97 wants to merge 1 commit into
Conversation
An ECS customer saw tero-edge marked UNHEALTHY for ~90 s at a time, with flat CPU, flat memory, and no error in the logs. Two defects explain it, and neither was visible from outside the process. Dispatch, measured on a 16-core host against a 3 s upstream: httpz batches up to 16 ready requests and hands the whole batch to one pool thread, whose queue no other thread drains. A health probe in that batch waited 3.0 s to 15.6 s with only 15 requests in flight and 128 idle handler threads. The thread count is not the lever: 16 threads and 128 threads behave the same. bench/health_hol_repro.py reproduces it. Inbound reads, stdio: std.Io.net.Stream.Reader never times out, so 300 idle sockets took every connection slot and /_health never answered again, while httpz reclaimed all 256 slots in 15 s. bench/idle_conn_check.py reproduces it. This commit fixes the second defect and makes the first one diagnosable. frontend/stdio/deadline_reader.zig reads through the net_receive operation under Io.operateTimeout, which on POSIX is a non-blocking recvmsg plus poll with the deadline: no extra task, no unit of concurrency, and no std.posix outside core/io_select.zig. It carries two deadlines. The idle one caps a read while no request is in flight; the request one caps a whole request from its first byte, which httpz cannot do because SO_RCVTIMEO restarts on every read. A stalled sender now gets 408 instead of silence, and a stalled body gets 408 instead of a 502 that blamed the upstream. Every remaining drop path now logs and answers: load shed (503), a head that fails to parse (400), an inbound deadline (408), a response that fails after its head is on the wire (truncated, with a warn), and a fixed error response we could not deliver. New series, all frontend-neutral where a frontend can feed them: edge_requests_in_flight, edge_upstream_timeouts_total, edge_connections_max, edge_connections_total, edge_connections_active, edge_connections_shed_total by reason, edge_inbound_timeouts_total by phase, edge_requests_invalid_total. The connection series are gated to stdio, since httpz gives the handler no accept hook and a flat zero would read as "no connections" rather than "not measured". Request duration buckets now reach 60 s; the old ceiling of 5 s could not show a 30 s stall. Throughput after the change: small bodies cost 1.8% (90.2k to 88.6k rps), 1 MB bodies gain 10% (3885 to 4287 rps) with p99.9 down from 35.9 ms to 21.0 ms. Known gaps, unchanged by this commit: the upstream dial has no deadline (std.http.Client takes none), and the write side has no deadline (std.Io 0.16 has no net_send operation), so a client that stops reading still holds its slot.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — The new stdio deadline reader changes default handling of existing connections and stalled requests, including connection reclamation and 408 responses. The accompanying telemetry spans both frontends and adds multiple new production metrics, making this broader than a small, isolated fix. Not approved because:
Review your spending limits in Billing settings. You can add or adjust custom eligibility rules. Learn more. |
jaronoff97
added this pull request to stack #324
September 17, 2026 01:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack
Merge bottom to top.
Why
An ECS customer sees
tero-edgemarked UNHEALTHY for ~90 s at a time: flat CPU, flat memory, logs still flowing, and the task recovers on its own. Two defects explain the class, and neither was visible from outside the process.Dispatch (httpz). httpz batches up to 16 ready requests and hands the whole batch to one pool thread. No other thread drains that queue, and stealing is one hop, so half the queues have no stealer at all. Measured on a 16-core host against a 3 s upstream: a
/_healthprobe in that batch waited 3.0 s to 15.6 s with 15 requests in flight and 128 idle handler threads. The thread count is not the lever — 16 threads and 128 threads give the same distribution.bench/health_hol_repro.pyreproduces it.Inbound reads (stdio).
std.Io.net.Stream.Readernever times out. 300 idle sockets took every connection slot and/_healthnever answered again, while httpz reclaimed all 256 slots within 15 s.bench/idle_conn_check.pyreproduces it.This PR fixes the second defect and makes the first one diagnosable. The dispatch fix needs a patch to httpz itself, which is a separate change.
What
src/frontend/stdio/deadline_reader.zigreads through thenet_receiveoperation underIo.operateTimeout. On POSIX that is a non-blockingrecvmsgpluspollwith the deadline: no extra task, no unit of concurrency, and nostd.posixoutsidecore/io_select.zig. Two deadlines:timeout.keepaliveSO_RCVTIMEOrestarts per readEvery drop path now logs and answers. Load shed 503, unparseable head 400, inbound deadline 408, a response that fails after its head is on the wire truncates with a warn, and a fixed error response we could not deliver is logged instead of ignored.
New series.
edge_requests_in_flight,edge_upstream_timeouts_total,edge_connections_maxon both frontends;edge_connections_total,edge_connections_active,edge_connections_shed_total{reason},edge_inbound_timeouts_total{phase},edge_requests_invalid_totalon stdio. The connection series are gated to stdio because httpz gives the handler no accept hook, and a flat zero would read as "no connections" rather than "not measured". Duration buckets now reach 60 s; the old 5 s ceiling could not show a 30 s stall.Verification
Idle socket wedge, 300 idle sockets against
max_connections256:Stalled partial request:
Throughput: small bodies cost 1.8% (90.2k → 88.6k rps); 1 MB bodies gain 10% (3885 → 4287 rps) with p99.9 down from 35.9 ms to 21.0 ms.
zig build testpasses on both frontends (527/528, 1 skipped), plusziglintandzig fmt --check. Three new tests cover the reader over real loopback TCP.Known gaps, unchanged here
std.http.Clienttakes none.std.Io0.16 has nonet_sendoperation. A client that stops reading still holds its slot. httpz has the same gap.🤖 Generated with Claude Code