Skip to content

Investigate intermittent TCP stalls with 5,000 active loopback connections on macOS #61

Open

Description

@nick2781

Status

Deferred at maintainer request on 2026-11-10; unresolved. Keep this issue open. No transport repair or production-capacity acceptance has been established, and further TCP load/capture/system-setting experiments are deferred while framework work continues.

See the progress and cross-platform comparison summary. The sections below retain historical experiments and their original limits; they are not an instruction to resume the investigation. Windows/WSL2 passes do not establish bare-metal Linux, cross-host or release acceptance.

Problem

Some connections intermittently stop completing requests in a 5,000-active-connection macOS loopback workload. The native Asio control also reproduces the symptom. This does not establish a cause in the workload, Asio, arknet or the OS TCP stack.

Reproduction context

  • macOS 27.0.1 (26A434), arm64, Darwin 27.0.0; AppleClang 21, C++20 Release.
  • Boost 1.90.0 / Boost.Asio 1.38.0; TLS off.
  • Tool: benchmarks/asio_connections.cpp, source 66289bbfb34cc1c68ea438fd572fbe3cb675acac (diagnostic change in fix(benchmarks)!: preserve TCP kernel sample onset #60).
  • One IO context / one IO worker per process; 5,000 loopback TCP connections.
  • 1,024-byte application payload, four outstanding requests per connection; 4,096-byte initial/max receive buffer.
  • Connection rate 1,000/s, at most 32 concurrent connects; traffic hold duration 3 s; request/connect timeout 5 s, workload deadline 40 s, cleanup allowance 5 s.
  • Limits: 20,000 in-flight requests, 64 MiB in-flight bytes, 512 MiB buffer budget. Operation trace, socket-operation trace and TCP kernel samples were all enabled.
  • Process descriptor soft limit was 1,048,575. All 5,000 connections were established/accepted in each run; no system-limit tuning was performed.

Completed observations

A fixed three-run campaign retained all outcomes: 5 unfinished connections, all connections completed, then 3 unfinished connections. These observations are not a failure-rate or capacity estimate.

Eight failing peer pairs matched by identity and reversed socket endpoints. Schema 2 retained 21 server queries before the corresponding last client query, addressing the earlier loss of pre-cancellation server history.

  • Retained client queries: established connection, loss-recovery flag, congestion window 0, reported retransmission timeout 0, 4,364 bytes in the TCP send buffer, positive peer receive window.
  • Corresponding server queries before cancellation: established connection, empty TCP send buffer, positive congestion window.
  • Completed client write bytes minus completed server read bytes: 4,364 bytes, equal to four 1,091-byte framed requests.
  • Both processes completed cleanup. These counters do not establish where bytes were lost/stalled or why progress stopped.

Earlier schema 1 tail-only evidence and the new schema 2 first-four/latest-four evidence remain distinct. Raw JSON, packet captures and logs remain local and are not release assets or public downloads.

Current investigation

Resumed by maintainer request on 2026-11-05. Root cause remains unresolved; no transport repair or long-run/capacity acceptance is claimed.

  • Diagnostic source: fe83d005b5c318da34938e06a1384f9d4f84a3f9 in test(benchmarks): observe stalled sockets outside the IO worker #74, based on accepted d8ff34162cd1b490683f579ef23431068d8b7d8b. Existing local Boost 1.92.0 / Asio 1.38.2 differs from the historical dependency versions; no controlled version comparison was performed.
  • Independent, non-consuming socket observations retained empty readable queues and matched both peers before cancellation. A separate bounded three-trial capture established all 5,000 connections in each trial; two trials completed and one retained a single unfinished pair. All workload/capture processes exited without an outer kill.
  • For that pair, all four outstanding 1,091-byte requests were positively observed on lo0 before the cancellation marker. Receiver SACK records acknowledged later segments while cumulative acknowledgement remained before the first segment; repeated transmissions of that first segment were also observed. This narrows the next investigation to TCP delivery/recovery and does not locate a loss point or establish an OS defect.
  • Capture reports zero kernel drops, but received and saved record counts differ. The conservative completeness gate therefore fails: absent packets or absent ACKs cannot be used as causal evidence. A later complete SACK block is not certified as pre-cancellation. Raw captures, identities, logs and analysis remain local.

MSS diagnostic control

A separate local-only connected-socket experiment requested TCP_MAXSEG 1460 after connection establishment. All 5,000 sockets on both peers read back 1460 before application traffic. Two trials completed; a later trial established all 5,000 but left seven peer pairs unfinished, including five client-side stalled senders whose retained samples still reported MSS 1460, an empty readable queue and congestion window zero. Setting MSS 1460 is therefore not an established remedy, and a fall to 1188 is not required for that observed stalled state. No library default was changed.

The batch is not a balanced statistical comparison: two other planned trials failed before reaching 5,000 connections with a local-address allocation error and are invalid for the established-connection-stall comparison. An earlier fixed-port batch stopped on address-in-use before its MSS arm started; its initial failed workload and setup failure are both retained locally. These setup outcomes must not be counted as successful traffic tests, merged with the original stall or silently replaced.

The experimental option and all raw results remain local; #74 contains only the independently tested observer/clock diagnostics. Root cause and a transport repair remain pending.

Recovery timer and post-failure observation

Two independent local diagnostics used Boost 1.92.0 / Asio 1.38.2 with the original five-second request failure criterion. Neither is a transport repair.

  • The public macOS libproc socket query exposes retransmission, persist, keepalive and TIME_WAIT timers. A controlled zero-window connection verified that a reported retransmission timeout of zero can coexist with an active persist timer. The four fields do not expose every internal recovery timer, and their raw values cannot be interpreted as remaining milliseconds without the unavailable timer base.
  • A bounded native-Asio campaign stopped at its first traffic failure: the first trial completed, the second established all 5,000 connections and left one pair unfinished. Eight pre-cancellation samples on its client sender retained 4,364 send-buffer bytes, congestion window zero, a positive peer receive window and a nonzero persist timer. This rules out the claim that all recovery timers were absent in that observed state; it does not establish why recovery did not progress.
  • A separate first-failure trial established all 5,000 connections and retained 15 unfinished peer pairs. After the original request timeout was latched, an additional ten-second observation admitted no new requests and kept existing reads active. All 60 outstanding requests remained outstanding throughout 11 live sample rounds: admitted/sent/received counts stayed 550,861 / 550,861 / 550,801. Both processes cleaned up, and the client still exited with failure. This shows no later application progress within that bounded interval, not an indefinitely stuck connection or a broken timer callback.
  • Local diagnostic source is f732e59414b6afcbae47cb9cdea745db7b2006a1, without the earlier TCP_MAXSEG experiment. Its binary SHA256 is 54f1b613c6f1ae87581edd7aef883ea3b58941717ec228152a3305c999cd4425. Raw observations remain local. No matching public source for the running XNU 13432 kernel was found; older Apple source is explanatory context, not proof of this kernel's implementation.

The owner-approved one-shot SACK control stopped at its negotiation preflight: the helper wrote the host setting from 1 to 0 and verified 0, but a newly created loopback client still reported the public SACK-enabled option (TCP_CONNECTION_INFO returned all 112 bytes, established state, options 7). The guard therefore aborted before launching any 5,000-connection workload; the server option query was not reached after the client mismatch. The helper restored 1, the independent watchdog verified restoration, and a separate read confirmed 1. This is an inconclusive control, not a SACK-disabled workload result or transport fix. No additional setting attempts or retries were made. Root cause, targeted repair and repair validation remain pending.

All-options-disabled traffic isolation

A public per-socket TCP_NOOPT export control, with host SACK restored to1, returned established options0 on both peers. Its Boolean readback was raw8, not normalized1; the initial assertion error stopped before connect and was retained separately. This control removes timestamps and window scaling as well as SACK, so it is not a pure SACK experiment.

Exactly one native-Asio traffic trial then used the same5,000-connection,1,024-byte/window4, one-context/worker and original5s request criterion. Both peers passed5,000 complete established-options0 checks, with zero option failures. Local diagnostic source was bd95573e5c715cff31a8c9b051813d3d201fe980; binary SHA256 d98cf56bcad2ea49cbd5bf6668d89a12b7661966e19044208db36f0dea58fdff. No host setting was changed.

  • One request exceeded5s. Its response completed after5.468465s on the client's own clock,430.798ms after failure was latched. During the bounded observation, admission stayed441,491, responses advanced441,490 to441,491 and outstanding requests1 to0.
  • The affected server sender retained positive congestion window7,168 bytes, an active retransmission timer, persist0 and MSS512; send-buffer bytes progressed780 to268. This differs from the original zero-congestion-window / persist / no-ten-second-progress state.
  • Both processes retained exit1 and cleaned up without an outer kill. Final4,999 finished reflects frozen post-failure admission, not proof of a permanently missing response. A late response does not convert the original timeout into success.

This trial reproduces a five-second timeout without TCP options, but does not reproduce or exclude the original persistent stall. No targeted transport repair or acceptance is established; no retries followed.

A separate offline comparison of the retained original first-hole repetitions found increasing TSval values above previously observed peer echoes. It does not support stale/retrograde timestamps on those observed repetitions, does not establish receiver ts_recent, and does not exclude PAWS. The incomplete-capture limitation remains unchanged.

Remaining work

  • Review retained evidence and select independent socket/sequence observations as a bounded discriminator.
  • Match both peers and bracket socket observations with clock/cancellation markers; retain the incomplete-capture limitation.
  • Establish the failed sequence range's delivery/recovery cause and validate a targeted repair or justified intervention; do not infer this from aggregate TCP counters.
  • Preserve fixed run counts, source/binary identities and failures; do not retry to green or increase timeouts to hide the problem.
  • Obtain cross-host/Linux acceptance when an appropriate test environment is available.

Related diagnostic changes: #58, #59, #60. No fix or 5,000-active-connection production-capacity claim is made.

Activity

  1. nick2781 commented on Nov 9, 2026

    @nick2781
    MemberAuthor

    Same-host controls, 2026-11-09

    Machine: macOS 27.0.1 (26A434), Darwin 27.0.0, xnu-13432.1.9~1/RELEASE_ARM64_T6041, arm64 (Mac16,8). Apple clang 17.0.0, C++20 Release, Boost 1.92.0 (Boost.Asio from the same headers), TLS off. Base 96973b27e2aec938393b913a979f9e5fde592828; every control below except the first native-Asio batch ran uncommitted local diagnostic changes, identified per batch by source and binary SHA-256 in the local records. Raw JSON and logs remain local.

    Workload unless stated: loopback IPv4, one thread per process, 5,000 connections, 1,024-byte payload, four outstanding requests per connection, connect rate 1,000/s with 32 parallel, three-second hold, five-second request timeout, 40-second deadline. Each batch used a fixed trial count with configurations interleaved; every outcome is retained and nothing was retried. TIME_WAIT was below 100 sockets before each trial.

    Controls

    A new diagnostic peer, arknet_poll_connection_control (POSIX, no Asio or strands), speaks the native control's wire protocol, so either role can be paired with arknet_asio_connection_control. It drives sockets with poll(2) or, with --events kqueue, a loop that registers events the way the Boost.Asio kqueue reactor does (EV_ADD | EV_CLEAR, speculative operation first, write filter removed when idle). Further switches reproduce the native control's serialized writes (--write-pacing serial), one-recv-per-step, one-frame-per-completion reads (--read-pacing serial) and socket preparation (--socket-setup asio: SO_NOSIGPIPE, ioctl(FIONBIO)). The native control gained --session-strands on|off, --write-buffers split|joined and --io-histogram on.

    Outcomes at 5,000 connections (failed / trials)

    Pairing Failed
    native Asio ↔ native Asio, default switches (some with --io-histogram on) 12 / 18
    native Asio ↔ native Asio, --session-strands off 1 / 3
    native Asio ↔ native Asio, --write-buffers joined 2 / 3
    native Asio server ↔ poll client; poll server ↔ native Asio client 0 / 6
    poll or kqueue peers, all pacing and setup variants 1 / 36

    The single non-Asio failure used kqueue events with serial reads, serial writes and Asio socket setup. Twelve consecutive connections (adjacent local ports) stopped together after eight of twelve responses; the client had no unacknowledged send-queue bytes and no readable bytes, the server had handed every response to send, and no progress occurred during a ten-second observation. The same configuration completed in the other two trials of that batch and in all six later trials. Socket preparation is not established as a factor: FIONBIO and O_NONBLOCK set the same flag, and the trial count is small.

    Per-call histograms showed the serial modes reproduce the native control's recv/send size distribution (for example 73 % versus 71 % of server recv calls returning one frame) without reproducing its failure rate.

    Load gradient and loopback packet accounting

    lo0 input/output packet deltas were recorded around each trial; a 15-second idle baseline per round showed no difference. "Missing" is output minus input packets on lo0. It is a lower bound for loss on the loopback path and does not observe drops after interface input.

    Connections poll, serial modes: missing / failed native Asio: missing / failed
    1,000 0, 0, 0 / 0 of 3 0, 0, 0 / 0 of 3
    2,500 14, 11, 15 / 0 of 3 0, 0, 8 / 0 of 3
    5,000 184, 199, 184 / 0 of 3 179, 172, 176 / 2 of 3

    The interface reported zero input errors and drops throughout. TCP statistics were unavailable (net.inet.tcp.disable_access_to_stats=1).

    Send-queue observation

    With --send-queue-watch on, the poll peers query every socket's kernel send queue every 250 ms and record Apple TCP_CONNECTION_INFO for queues unchanged for one second. In 5,000-connection trials that all succeeded, 230–300 connections per side per trial held exactly 4,364 unacknowledged bytes (one window of four 1,091-byte frames) for 1.25–3.5 s before progressing, with large retransmitted-byte counts and receiver out-of-order bytes. These episodes occurred in both directions. At 1,000 and 2,500 connections there were none in four of six trials; the other two had 12 and 64 client-side episodes of about 1.26 s, one of them with no missing lo0 packets.

    Interpretation and limits

    • The stall is load-dependent on this host and is not specific to Asio, strands, kqueue registration, write buffering, I/O cadence or socket preparation.
    • It coincides with lost loopback packets and retransmission recovery. Recovery with exponential RTO backoff can plausibly exceed the five-second request timeout; this remains an inference, not a demonstrated mechanism for each failed connection.
    • Not established: where packets are dropped, why some episodes have no missing lo0 packets, and why native Asio pairs exceed five seconds more often than the poll peers at similar loss.
    • These are single-host loopback observations with small fixed trial counts; they make no capacity or production claim. Cross-host and Linux acceptance remain pending.

    Remaining work (proposed additions)

    • Locate the drop point (for example with an owner-approved, restored queue-limit control or with TCP statistics enabled).
    • Explain the longer recovery tail of native Asio pairs at equal loss.
    • Publish the diagnostic controls through a reviewed PR before citing them as repository tools.
  2. nick2781 commented on Nov 10, 2026

    @nick2781
    MemberAuthor

    Progress summary and deferral — 2026-11-10

    The maintainer has deferred this investigation so framework work can continue. Keep this issue open: the root cause remains unresolved and no transport repair is established. No further capacity, packet-capture or system-setting experiments are planned in the current work.

    Matched macOS failure with kevent observation

    A bounded campaign on 2026-11-09 used macOS 27.0.1 / Darwin 27.0.0, Mac16,11 / M4 Pro, AppleClang 21, Boost 1.92 / Asio 1.38.2. The independent fixed-frame driver used one context/IO execution thread, no strand, 5,000 connections, 1,091-byte frames, window 4 and 4,096-byte reads. Request admission remained three seconds, failure at five seconds and observation for ten additional seconds.

    • Asio/Asio completed 635,551 round trips. A subsequent poll-server/Asio-client trial retained 12 unfinished connections / 48 responses.
    • Before either endpoint closed, reversed four-tuples matched exactly those 12 server sockets to cwnd=0, 4,364 send-buffer bytes each, a positive peer window and established state. Their total 52,368 bytes equalled the 48 missing frames. The other 4,988 server sockets had positive congestion windows and empty send buffers.
    • Both endpoints had completed their application writes. All affected clients retained a pending read, FIONREAD=0, and unchanged completion/sequence counts across 11 snapshots over ten seconds. Sender buffer bytes include in-flight data; they are not all necessarily unsent.
    • The failed Asio client intercepted 80,940 kevent calls with zero syscall failures and zero nonzero EV_ERROR events. Mapping/association/error-record-loss counters were zero. The unchecked registration call discussed in upstream Asio PR 1769 ran successfully 5,000 times during connection establishment. This failure therefore did not require an ignored kevent error; it does not prove readiness/completion delivery was correct.
    • Real-kernel positive probes verified syscall errors, nonzero EV_ERROR and successful data-zero receipts. No library implementation or TCP setting was changed. Local diagnostic revision: 714f7a9fda8a87cf1253842022a5c9dff19de2c0 (unpublished).

    Bounded Windows and Linux comparisons

    The same portable fixed-frame source and Boost headers were run on Windows 11 and an existing Ubuntu 24.04 WSL2 guest (Linux 6.6.87.2). Host hardware: Core Ultra 5 125H, 18 logical processors, approximately 32 GB RAM. Source SHA256: 14fb93fb04e09c615b34484e023eb6fb1373c8e03753adb6702462240861eb3b; local revision 6fb98201e0572da3e0678bc3e8566106021075e0 (unpublished).

    Platform Asio/Asio Native poll/Asio, first Native poll/Asio, second
    Windows 11 (native mode is WSAPoll) 59,863 completed 51,559 completed 51,599 completed
    Ubuntu WSL2 377,854 completed 404,854 completed 439,795 completed

    Every trial established and closed all 5,000 connections, completed all admitted requests, read back TCP_NODELAY on every socket and ended with both processes exiting 0, zero pending operations and complete cleanup. Trials were sequential with at least 31 seconds between large-load runs; the campaign stopped after these six trials.

    The first Windows 32-connection preflight completed its data but reported a server reset during peer teardown. That failure is retained. The portable diagnostic then uniformly used shutdown before close on both transports/platforms; no network error was ignored. The corrected preflight and all six large-load cases used that same version. This is a diagnostic-driver adjustment, not an arknet repair.

    These are independent-driver comparisons, not 5K validation of PR #76's head or its full native-control handshake. WSL2 is not bare-metal/cross-host Linux acceptance. macOS's large-run witness used the earlier driver variant, and achieved traffic rates differed substantially, especially on Windows. Results strengthen the macOS-specific investigation direction but do not isolate a kernel defect or establish production capacity.

    PR #76 review and follow-up boundary

    At reviewed head 01cbc412592e123e8550915b94fef9aea9818a08, the pure non-Asio positive case reported in the preceding comment is valuable, but it still uses kqueue. It does not exclude kqueue as a factor.

    The send-queue watcher also needs two interpretation corrections before its candidate events are used as recovery evidence:

    1. Constant queue occupancy alone is not stalled delivery: a rolling four-request window can remain at 4,364 bytes while sequence/byte counters advance.
    2. A queue-query failure returns -1 and can currently be emitted as progressed. Unavailable measurement cannot establish recovery.

    IO-size histograms describe completion-size distributions, not equivalent operation/ACK timing. Loopback interface-counter differences and retransmissions are clues; the packet-drop point and the transition into a zero congestion window remain unproven. RTO backoff is a hypothesis, not an explanation established for the ten-second no-progress cases.

    When this issue is resumed, first validate retained per-connection progression and distinguish recovery from sustained silence, then select one bounded discriminator for the ACK/SACK/retransmission/window transition. Do not restart a broad matrix or infer a fix from a passing run.

    All owned workload/observer processes have exited. Raw JSON, captures, logs and binaries remain private/local; no raw evidence is attached or published. Framework progress, long-running/large-scale acceptance and release criteria remain separate decisions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions