Skip to content

Keep paths alive on a timer of their own - #76

Merged
masa-koz merged 1 commit into
seera-mainfrom
masa-koz/path-keep-alive-timer
Aug 4, 2026
Merged

Keep paths alive on a timer of their own#76
masa-koz merged 1 commit into
seera-mainfrom
masa-koz/path-keep-alive-timer

Conversation

@masa-koz

@masa-koz masa-koz commented Aug 4, 2026

Copy link
Copy Markdown

Description

#74 made the keep alive PING go out on every path, but hung that off QUIC_CONN_TIMER_KEEP_ALIVE — the wrong timer. QuicConnResetIdleTimeout re-arms it on every ack-eliciting packet received and on the first packet put in flight:

    if (Connection->Settings.KeepAliveIntervalMs != 0) {
        QuicConnTimerSet(
            Connection,
            QUIC_CONN_TIMER_KEEP_ALIVE,
            MS_TO_US(Connection->Settings.KeepAliveIntervalMs));
    }

So it fires only once the whole connection has gone quiet. That is the right rule for what it is — a timer that stops the connection going idle — and the wrong rule for keeping a path alive. A connection busy on one path pushes the timer out indefinitely while another path sits untouched, loses its NAT mapping, and is eventually abandoned by the peer.

Changes

A separate timer with its own setting, which nothing resets.

PathKeepAliveIntervalMs (default 0, disabled) is how long a path may go without sending before it gets a PING. It is counted per path, from what that path carried:

  • QUIC_PATH gains LastSendTimeUs, stamped in QuicPacketBuilderSendBatch.
  • QuicConnPathKeepAliveTimerUpdate sets SendKeepAlive on every path idle for the full interval, then arms QUIC_CONN_TIMER_PATH_KEEP_ALIVE for whichever path comes due next. A path that is sending on its own never gets a redundant PING, and the timer never wakes earlier than it has to.
  • Armed when the handshake completes — the earliest point 1-RTT keys exist, which QuicSendPathKeepAlives needs — and on SetParam. Cancelled when the interval is 0 or the connection closes.

QuicConnProcessKeepAliveOperation goes back to a plain QUIC_CONN_SEND_FLAG_PING. KeepAliveIntervalMs means "keep the connection alive" again; keeping the paths alive is now the other setting's job. QuicSendPathKeepAlives from #74 is unchanged and serves the new timer.

QUIC_CONN_TIMER_PATH_KEEP_ALIVE is appended at the end of QUIC_CONN_TIMER_TYPE so the existing timer codes keep their values for anything decoding traces.

Testing

New Basic/WithFamilyArgs.PathKeepAlive in PathTest.cpp, registered in MsQuicTests.h, quic_gtest.cpp and winkernel/control.cpp. It brings up a two-path connection with no connection keep alive on either side and the MTU pinned so discovery cannot contribute traffic, then asserts both paths carry client→server datagrams over a 600ms window. Every datagram the server sees in that window is a keep alive. Removing the arming call flips it to failure:

FirstPathCounter.Count >= Expected not true
[  FAILED  ] Basic/WithFamilyArgs.PathKeepAlive/0

The "not reset by connection activity" half is not asserted, only measured. With a server keep alive of 20ms holding the connection busy, the path keep alive still fired 4 times in the window; emulating the old behaviour by re-arming the new timer from QuicConnResetIdleTimeout dropped that to 0 — the bug this change is about. It cannot become an assertion because QuicConnChoosePath picks a random active path per send, so under load neither path stays quiet enough for a datapath hook to tell keep alives from ordinary traffic.

Sweep Result
*Multipath*:*Path*:*Migration*:*UnconnectedSocket*:*KeepAlive* 109 tests pass
*Basic*:*Datagram*:*Receive*:*Recv* 624 tests pass
PathKeepAlive × 6 repeats pass
msquiccoretest --gtest_filter=*Settings* 14 pass, 3 skipped (pre-existing)
cargo test --features preview-api 13 pass

No compiler warnings.

Documentation

docs/Settings.md gains PathKeepAliveIntervalMs, and the KeepAliveIntervalMs row now says it is reset by connection activity — the distinction between the two.

The Rust bindings are updated: src/rs/settings.rs gains the setter and linux_bindings.rs was regenerated with cargo build --features overwrite. win_bindings.rs cannot be regenerated on Linux, so the identical hunks were applied by hand; the two diffs match line for line. The C# bindings were not updated — they need their generator re-run, and are already missing several fork settings.

#74 made the keep alive PING go out on every path, but hung that off
QUIC_CONN_TIMER_KEEP_ALIVE. QuicConnResetIdleTimeout re-arms that timer
on every ack-eliciting packet received and on the first packet put in
flight, so it fires only once the whole connection has gone quiet. That
is the right rule for a timer whose job is to stop the connection going
idle, and the wrong rule for keeping a path alive: a connection busy on
one path pushes the timer out indefinitely while another path sits
untouched, loses its NAT mapping, and is eventually abandoned by the
peer.

Give the per-path job its own timer and setting, which nothing resets.

PathKeepAliveIntervalMs (default 0, disabled) is how long a path may go
without sending before it gets a PING, counted from what that path
carried:

- QUIC_PATH gains LastSendTimeUs, stamped in QuicPacketBuilderSendBatch.
- QuicConnPathKeepAliveTimerUpdate sets SendKeepAlive on every path idle
  for the full interval, then arms QUIC_CONN_TIMER_PATH_KEEP_ALIVE for
  whichever path comes due next. A path that is sending on its own never
  gets a redundant PING.
- Armed when the handshake completes -- the earliest point 1-RTT keys
  exist, which QuicSendPathKeepAlives needs -- and on SetParam. Cancelled
  when the interval is 0 or the connection closes.

QuicConnProcessKeepAliveOperation goes back to a plain
QUIC_CONN_SEND_FLAG_PING: KeepAliveIntervalMs means "keep the connection
alive" again. QuicSendPathKeepAlives from #74 is unchanged and serves the
new timer. The new timer type is appended at the end of
QUIC_CONN_TIMER_TYPE so the existing codes keep their values for anything
decoding traces.

Basic/WithFamilyArgs.PathKeepAlive brings up a two-path connection with
no connection keep alive on either side and the MTU pinned so discovery
cannot contribute traffic, then asserts both paths carry client->server
datagrams over a 600ms window. Removing the arming call flips it to
failure. The "not reset by connection activity" half is measured rather
than asserted -- with a server keep alive of 20ms holding the connection
busy the path keep alive still fired 4 times, and emulating the old
behaviour dropped that to 0 -- because QuicConnChoosePath picks a random
active path per send, so under load neither path stays quiet enough for a
datapath hook to tell keep alives from ordinary traffic.

Plumbed through the usual places: msquic.h/.hpp, settings.h/.c,
quicdef.h, docs/Settings.md, SettingsTest, the Linux clog artifacts and
sidecar, and the Rust bindings. The C# bindings were not updated; they
need their generator re-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/core/settings.c

if (!Settings->IsSet.PathKeepAliveIntervalMs) {
ValueLen = sizeof(Settings->PathKeepAliveIntervalMs);
CxPlatStorageReadValue(
@masa-koz
masa-koz merged commit 5b21c4f into seera-main Aug 4, 2026
404 of 413 checks passed
@masa-koz
masa-koz deleted the masa-koz/path-keep-alive-timer branch August 4, 2026 13:54
masa-koz added a commit that referenced this pull request Aug 4, 2026
Conflicts:

  src/core/connection.c
    QuicConnProcessKeepAliveOperation. qmux-01 branches on QuicConnIsQMux
    to send a QX PING; seera-main dropped the multipath fan-out that #74
    had put on this timer, now that #76 gives the paths one of their own.
    Kept the QMux branch over the reverted body.

    QuicConnPathKeepAliveTimerUpdate also declines to arm for a QMux
    connection: it has one transport-provided path and no 1-RTT keys of
    its own, so QuicSendPathKeepAlives has nothing to build on.

  src/test/bin/quic_gtest.cpp
    Both sides added a TEST_P after Multipath. Kept both, PathKeepAlive
    first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.65217% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/connection.c 96.42% 1 Missing ⚠️
src/core/settings.c 93.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

masa-koz added a commit that referenced this pull request Aug 14, 2026
Brings in #76 (per-path keep alive timer) and #77 (upstream main sync,
25 commits). Conflicts:

  scripts/build.rs
    qmux-01 added iOS/Apple support around the link search path; seera-main
    replaced the single "lib" path with a lib/lib64 probe (upstream microsoft#6168).
    Independent: took the probe, kept the `|| apple_ios` condition and the
    Apple framework branch. `path_extra` is gone with the single path it fed.

  src/core/connection.c
    QuicConnGenerateLocalTransportParameters. qmux-01 wrapped the UDP-only
    transport parameters in !QuicConnIsQMux; seera-main added the MinAckDelay
    clamp (upstream microsoft#6213). Put the clamp inside the non-QMux block, next to
    the MaxAckDelay/MinAckDelay it constrains -- QMux sets neither.

  src/core/send.c
    QuicSendCanSendFlagsNow. qmux-01 wrapped the check in !QuicConnIsQMux;
    seera-main added a clang-tidy ArrayBound suppression above it (upstream
    microsoft#6155). Kept both, with the suppression on the line it was written for.

    That alone was not enough. QuicConnIsQMux is now the first dereference of
    Connection, and being QUIC_INLINE the analyzer reports inside the helper
    in connection.h rather than at the call site, where no NOLINT in send.c
    can reach it. Suppressed inside QuicConnIsQMux instead, matching what
    upstream microsoft#6155 already does for QuicConnIsClosed one function below.

Verified with the CI toolchain rather than the local one, which is too old
to see these diagnostics: a full -CodeCheck build (clang-tidy 21, Debug,
linux, x64, quictls) inside ghcr.io/microsoft/msquic/linux-build-xcomp:
ubuntu-26.04-cross completes clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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