Skip to content

CVPN-2346 Implement GSO offload on lightway-server#413

Merged
kp-samuel-tam merged 10 commits into
mainfrom
gso-on-server
Jun 17, 2026
Merged

CVPN-2346 Implement GSO offload on lightway-server#413
kp-samuel-tam merged 10 commits into
mainfrom
gso-on-server

Conversation

@kp-samuel-tam

@kp-samuel-tam kp-samuel-tam commented May 4, 2026

Copy link
Copy Markdown
Member

Description

Implement GSO on server side on DTLS and Expresslane, specifically on bulk server->client traffic. This consistently halves the total syscalls used during bulk transfers, and also improves aggregated server throughput by 2x for multiple clients doing transfers.

When --enable-tun-offload is set, the server reads TSO superpackets from the TUN with IFF_VNET_HDR, segments them in userspace, and emits each superpacket as a single sendmsg(UDP_SEGMENT) instead of N per-segment syscalls. On a single-flow iperf3 reverse test the kernel UDP send path collapses near-completely: udp_sendmsg 0.71% → ~0.05%, sock_alloc_send_pskb 2.61% → ~0.13%, mlx5e_xmit 1.88% → ~0%.

Trade-off: kernel work is replaced with userspace work (per-segment IP/TCP/UDP checksum recomputation, segment assembly). Kernel-side wins are clear and measurable; userspace cost is now the dominant factor.

Pacing: each sendmsg(UDP_SEGMENT) produces a NIC burst of up to N segments. This can exceed receiver socket buffer depth and increase tail drops at peak rates. We will need to revisit better TX pacing under congested links.

Future work will focus on compatibility with TUN backends like io_uring, GRO on the server side, and full GSO/GRO on client side, where single-flow workloads should see the biggest visible speedup not in this PR.

Motivation and Context

See ticket CVPN-2346.

How Has This Been Tested?

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • The correct base branch is being used, if not main

@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown

Code coverage summary for 807f7d1:

Filename                                                          Regions    Missed Regions     Cover   Functions  Missed Functions  Executed       Lines      Missed Lines     Cover    Branches   Missed Branches     Cover
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
lightway-app-utils/src/args/cipher.rs                                   5                 5     0.00%           1                 1     0.00%           5                 5     0.00%           0                 0         -
lightway-app-utils/src/args/connection_type.rs                          8                 8     0.00%           2                 2     0.00%           8                 8     0.00%           0                 0         -
lightway-app-utils/src/args/duration.rs                               121                22    81.82%          11                 5    54.55%          64                15    76.56%           0                 0         -
lightway-app-utils/src/args/ip_map.rs                                  27                27     0.00%           5                 5     0.00%          19                19     0.00%           0                 0         -
lightway-app-utils/src/args/keyshare.rs                                 5                 5     0.00%           1                 1     0.00%           5                 5     0.00%           0                 0         -
lightway-app-utils/src/args/logging.rs                                 37                37     0.00%           3                 3     0.00%          31                31     0.00%           0                 0         -
lightway-app-utils/src/args/nonzero_duration.rs                       128                24    81.25%          10                 4    60.00%          68                16    76.47%           0                 0         -
lightway-app-utils/src/connection_ticker.rs                           229                17    92.58%          28                 4    85.71%         126                15    88.10%           0                 0         -
lightway-app-utils/src/dplpmtud_timer.rs                              209                13    93.78%          22                 4    81.82%         117                11    90.60%           0                 0         -
lightway-app-utils/src/event_stream.rs                                 19                 0   100.00%           3                 0   100.00%          11                 0   100.00%           0                 0         -
lightway-app-utils/src/metrics.rs                                       4                 4     0.00%           2                 2     0.00%           4                 4     0.00%           0                 0         -
lightway-app-utils/src/network_change_monitor.rs                       72                72     0.00%           6                 6     0.00%          49                49     0.00%           0                 0         -
lightway-app-utils/src/packet_codec.rs                                  2                 2     0.00%           1                 1     0.00%           1                 1     0.00%           0                 0         -
lightway-app-utils/src/sockopt/ip_mtu_discover.rs                      81                81     0.00%           6                 6     0.00%          72                72     0.00%           0                 0         -
lightway-app-utils/src/sockopt/ip_pktinfo.rs                           14                 1    92.86%           1                 0   100.00%          16                 1    93.75%           0                 0         -
lightway-app-utils/src/tun.rs                                         366               366     0.00%          36                36     0.00%         231               231     0.00%           0                 0         -
lightway-app-utils/src/utils.rs                                        21                21     0.00%           1                 1     0.00%          11                11     0.00%           0                 0         -
lightway-client/src/config.rs                                         292                96    67.12%          13                 6    53.85%         201                62    69.15%           0                 0         -
lightway-client/src/dns_manager.rs                                     18                18     0.00%           4                 4     0.00%          16                16     0.00%           0                 0         -
lightway-client/src/io/inside/tun.rs                                   82                82     0.00%          12                12     0.00%          60                60     0.00%           0                 0         -
lightway-client/src/io/outside.rs                                      14                14     0.00%           2                 2     0.00%          12                12     0.00%           0                 0         -
lightway-client/src/io/outside/tcp.rs                                  92                92     0.00%          13                13     0.00%          59                59     0.00%           0                 0         -
lightway-client/src/io/outside/udp.rs                                 170               170     0.00%          18                18     0.00%         111               111     0.00%           0                 0         -
lightway-client/src/io/outside/udp/batch_receive.rs                    99                13    86.87%          11                 1    90.91%          84                13    84.52%           0                 0         -
lightway-client/src/keepalive.rs                                      615                44    92.85%          55                 6    89.09%         330                25    92.42%           0                 0         -
lightway-client/src/lib.rs                                           1267               691    45.46%         102                53    48.04%         845               499    40.95%           0                 0         -
lightway-client/src/main.rs                                           351               351     0.00%          20                20     0.00%         233               233     0.00%           0                 0         -
lightway-client/src/platform/linux/dns_manager.rs                     118                91    22.88%          14                 9    35.71%          87                62    28.74%           0                 0         -
lightway-client/src/platform/linux/dns_manager/direct_file.rs         106                42    60.38%          12                 6    50.00%          65                29    55.38%           0                 0         -
lightway-client/src/platform/linux/dns_manager/resolvconf.rs           61                61     0.00%           8                 8     0.00%          52                52     0.00%           0                 0         -
lightway-client/src/platform/linux/dns_manager/resolvectl.rs           41                41     0.00%           5                 5     0.00%          34                34     0.00%           0                 0         -
lightway-client/src/route_manager.rs                                 1148               166    85.54%          75                 6    92.00%         636                84    86.79%           0                 0         -
lightway-core/src/borrowed_bytesmut.rs                                373                 0   100.00%          24                 0   100.00%         185                 0   100.00%           0                 0         -
lightway-core/src/builder_predicates.rs                                24                12    50.00%           4                 2    50.00%          24                12    50.00%           0                 0         -
lightway-core/src/cipher.rs                                            13                 0   100.00%           2                 0   100.00%          10                 0   100.00%           0                 0         -
lightway-core/src/connection.rs                                      1795               958    46.63%          74                29    60.81%        1276               655    48.67%           0                 0         -
lightway-core/src/connection/builders.rs                              252                46    81.75%          22                 8    63.64%         260                53    79.62%           0                 0         -
lightway-core/src/connection/dplpmtud.rs                             1741                81    95.35%          63                 0   100.00%         830                 7    99.16%           0                 0         -
lightway-core/src/connection/expresslane.rs                            27                11    59.26%           4                 1    75.00%          36                 7    80.56%           0                 0         -
lightway-core/src/connection/fragment_map.rs                          366                 6    98.36%          25                 0   100.00%         254                 3    98.82%           0                 0         -
lightway-core/src/connection/io_adapter.rs                            877               149    83.01%          55                 8    85.45%         467                95    79.66%           0                 0         -
lightway-core/src/connection/key_update.rs                             34                13    61.76%           5                 0   100.00%          38                19    50.00%           0                 0         -
lightway-core/src/context.rs                                          214                51    76.17%          28                11    60.71%         235                64    72.77%           0                 0         -
lightway-core/src/context/ip_pool.rs                                    8                 3    62.50%           1                 0   100.00%           5                 0   100.00%           0                 0         -
lightway-core/src/context/server_auth.rs                               37                37     0.00%           4                 4     0.00%          26                26     0.00%           0                 0         -
lightway-core/src/encoding_request_states.rs                            3                 0   100.00%           1                 0   100.00%           3                 0   100.00%           0                 0         -
lightway-core/src/gso.rs                                              900               143    84.11%          29                 6    79.31%         424                92    78.30%           0                 0         -
lightway-core/src/io.rs                                                10                10     0.00%           3                 3     0.00%           9                 9     0.00%           0                 0         -
lightway-core/src/keyshare.rs                                           5                 0   100.00%           1                 0   100.00%           5                 0   100.00%           0                 0         -
lightway-core/src/lib.rs                                                9                 0   100.00%           3                 0   100.00%           9                 0   100.00%           0                 0         -
lightway-core/src/metrics.rs                                           81                71    12.35%          33                29    12.12%          73                63    13.70%           0                 0         -
lightway-core/src/packet.rs                                            38                10    73.68%           4                 1    75.00%          30                 6    80.00%           0                 0         -
lightway-core/src/plugin.rs                                           307                17    94.46%          22                 4    81.82%         148                10    93.24%           0                 0         -
lightway-core/src/tls/mod.rs                                            4                 4     0.00%           1                 1     0.00%           3                 3     0.00%           0                 0         -
lightway-core/src/utils.rs                                            334                26    92.22%          22                 2    90.91%         165                17    89.70%           0                 0         -
lightway-core/src/version.rs                                           93                 0   100.00%          17                 0   100.00%          82                 0   100.00%           0                 0         -
lightway-core/src/wire.rs                                             438                42    90.41%          29                 1    96.55%         235                12    94.89%           0                 0         -
lightway-core/src/wire/auth_failure.rs                                 27                 1    96.30%           3                 0   100.00%          17                 0   100.00%           0                 0         -
lightway-core/src/wire/auth_request.rs                                684                57    91.67%          34                 1    97.06%         351                24    93.16%           0                 0         -
lightway-core/src/wire/auth_success_with_config_ipv4.rs               222                 3    98.65%          11                 0   100.00%         124                 0   100.00%           0                 0         -
lightway-core/src/wire/data.rs                                         51                 1    98.04%           5                 0   100.00%          33                 0   100.00%           0                 0         -
lightway-core/src/wire/data_frag.rs                                   127                 1    99.21%          14                 0   100.00%          80                 0   100.00%           0                 0         -
lightway-core/src/wire/encoding_request.rs                             82                 2    97.56%           6                 0   100.00%          42                 1    97.62%           0                 0         -
lightway-core/src/wire/encoding_response.rs                            82                 2    97.56%           6                 0   100.00%          42                 1    97.62%           0                 0         -
lightway-core/src/wire/expresslane_config.rs                          192                 1    99.48%          10                 0   100.00%         110                 0   100.00%           0                 0         -
lightway-core/src/wire/expresslane_data.rs                           1330                69    94.81%          48                 5    89.58%         679                37    94.55%           0                 0         -
lightway-core/src/wire/ping.rs                                         95                 2    97.89%           7                 0   100.00%          59                 0   100.00%           0                 0         -
lightway-core/src/wire/pong.rs                                        109                 2    98.17%           8                 0   100.00%          72                 0   100.00%           0                 0         -
lightway-core/src/wire/server_config.rs                                69                 2    97.10%           4                 0   100.00%          37                 0   100.00%           0                 0         -
lightway-server/src/auth.rs                                           273                49    82.05%          22                 6    72.73%         171                28    83.63%           0                 0         -
lightway-server/src/config.rs                                          26                26     0.00%           1                 1     0.00%          46                46     0.00%           0                 0         -
lightway-server/src/connection.rs                                     137               137     0.00%          11                11     0.00%         110               110     0.00%           0                 0         -
lightway-server/src/connection_manager.rs                             339               339     0.00%          40                40     0.00%         294               294     0.00%           0                 0         -
lightway-server/src/connection_manager/connection_map.rs              407                20    95.09%          21                 1    95.24%         228                 8    96.49%           0                 0         -
lightway-server/src/io/inside/tun.rs                                   46                46     0.00%          10                10     0.00%          32                32     0.00%           0                 0         -
lightway-server/src/io/outside/tcp.rs                                  93                93     0.00%          10                10     0.00%          68                68     0.00%           0                 0         -
lightway-server/src/io/outside/udp.rs                                 458               458     0.00%          25                25     0.00%         317               317     0.00%           0                 0         -
lightway-server/src/io/outside/udp/batch_receive.rs                   467                19    95.93%          20                 1    95.00%         265                15    94.34%           0                 0         -
lightway-server/src/io/outside/udp/cmsg.rs                            208                48    76.92%          16                 3    81.25%         171                43    74.85%           0                 0         -
lightway-server/src/ip_manager.rs                                     542                50    90.77%          22                 4    81.82%         242                23    90.50%           0                 0         -
lightway-server/src/ip_manager/ip_pool.rs                             545                 0   100.00%          28                 0   100.00%         257                 0   100.00%           0                 0         -
lightway-server/src/lib.rs                                            375               375     0.00%          18                18     0.00%         251               251     0.00%           0                 0         -
lightway-server/src/main.rs                                           248               248     0.00%          10                10     0.00%         131               131     0.00%           0                 0         -
lightway-server/src/metrics.rs                                        254               250     1.57%          85                83     2.35%         215               211     1.86%           0                 0         -
lightway-server/src/statistics.rs                                     132                59    55.30%           8                 4    50.00%          90                39    56.67%           0                 0         -
uniffi-bindgen/src/uniffi-bindgen.rs                                    3                 3     0.00%           1                 1     0.00%           3                 3     0.00%           0                 0         -
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
TOTAL                                                               21428              6730    68.59%        1483               594    59.95%       13032              4680    64.09%           0                 0         -

✅ Region coverage 68% passes
✅ Line coverage 64% passes

@kp-samuel-tam kp-samuel-tam changed the title Implement GSO offload on lightway-server CVPN-2346 Implement GSO offload on lightway-server May 11, 2026
@kp-samuel-tam
kp-samuel-tam force-pushed the gso-on-server branch 7 times, most recently from b5849fe to c62619f Compare May 13, 2026 11:41
@kp-samuel-tam
kp-samuel-tam marked this pull request as ready for review May 13, 2026 12:48
@kp-samuel-tam
kp-samuel-tam requested a review from a team as a code owner May 13, 2026 12:48
Comment thread lightway-core/src/plugin.rs Outdated
}

impl PluginList {
#[cfg(target_os = "linux")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This need not be a linux specific method. Looks generic

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Failed lint because the function is unused on other platforms, needed to add back #[allow(dead_code)].

Comment thread lightway-server/src/lib.rs Outdated
// Expose the full slab to `recv_gso` as `&mut [u8]`.
// SAFETY: every byte of the slab was zero-initialized at
// construction; subsequent iters only ever shrunk `len` or
// overwrote bytes. We never hand out uninitialized memory.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This does not sound safe, as pkt is mutable we could also create new BytesMut and replace it.
So now if you reserve, it might be unintialized.

I think what you want is https://docs.rs/bytes/latest/bytes/struct.BytesMut.html#method.spare_capacity_mut which gives pointer to spare buffer which you can sent to recv_gso

@kp-samuel-tam kp-samuel-tam May 21, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Because spare_capacity_mut(&mut self) -> &mut [MaybeUninit<u8>], I think we ultimately still need a unsafe cast on the &mut [MaybeUninit<u8>] because tun_rs::AsyncDevice::recv() only takes &mut [u8] in the end. I will think about it.

Interestingly there's also std::io::Read::read_buf in the nightly std which takes MaybeUninit<u8> (which tun-rs could use) but it is never stabilized.

Edit: I changed recv signatures to accept MaybeUninit and do one unsafe cast in the end, I am hoping to drop it once tun-rs accepts MaybeUninit. Please take a look at e995ffa 👀

Comment thread lightway-server/src/lib.rs Outdated
@kp-samuel-tam
kp-samuel-tam force-pushed the gso-on-server branch 8 times, most recently from 4642134 to 39cf489 Compare May 28, 2026 07:45

@kp-samuel-tam kp-samuel-tam left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Some updates and comments to help with reviews.

Comment thread lightway-app-utils/src/tun.rs
Comment thread lightway-app-utils/src/tun.rs Outdated
Comment on lines +441 to +451
// SAFETY: `tun_rs::AsyncDevice::recv` takes `&mut [u8]` and forwards
// to `libc::read(2)`. The kernel only writes — it never dereferences
// userspace memory for reading — so handing it our uninitialized slab
// is sound at the syscall boundary. The unsoundness lives in *Rust*:
// constructing a `&mut [u8]` over uninitialized bytes is UB per strict
// aliasing rules, even if no one reads them. This cast is the only
// place we paper over that gap. Delete it (and revert this signature)
// once `tun-rs` exposes a `MaybeUninit`-aware recv.
#[allow(unsafe_code)]
let raw =
unsafe { std::slice::from_raw_parts_mut(buf.as_mut_ptr().cast::<u8>(), buf.len()) };

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is new to cast MaybeUninit away for tun.recv() which doesn't support MaybeUninit yet.

Comment thread lightway-app-utils/src/tun.rs Outdated

/// Raw read from Tun, returning the full virtio frame (header + payload).
#[cfg(target_os = "linux")]
pub async fn recv_gso(&self, buf: &mut [std::mem::MaybeUninit<u8>]) -> IOCallbackResult<usize> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A lot of read/recv signatures are changed from &mut [u8] to &mut [MaybeUninit<u8>].

Comment on lines +468 to +474
// IFF_VNET_HDR requires a zeroed `virtio_net_hdr` prefix
// on every write (NEEDS_CSUM=0, GSO_NONE).
let hdr_len = tun_rs::VIRTIO_NET_HDR_LEN;
let mut prefixed = bytes::BytesMut::zeroed(hdr_len);
prefixed.extend_from_slice(&buf[..]);
tun.try_send(&prefixed[..])
.map(|n| n.saturating_sub(hdr_len))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This can be optimized to be writev but I'd defer it for now.

Comment on lines +103 to +107
/// When `Some`, the wolfssl IO callback `send()` appends raw
/// encrypted segments here instead of sending to the socket.
/// After all segments are collected, `udp_send_gso` wraps each
/// with `wire::Header`, runs plugins, and sends via `send_gso`.
pub(crate) gso_buf: Option<BytesMut>,

@kp-samuel-tam kp-samuel-tam May 28, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is not new but this is a buffer to "intercept" wolfssl doing send immediately after the packet is encrypted. Now when this is Some(_) it will store packets here and return early; for us to do GSO instead of shooting individual packets out right away.

Edit: A future plan is to do streaming/in-place encryption on the original superpacket, so this would not be needed... but deferred

@@ -48,32 +49,45 @@ impl std::fmt::Display for BindMode {

fn send_to_socket(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not new, but did additional refactoring on fn send_to_socket, now .with_control() is unconditional but kernel handles a cmsg_len = 0 cmsg just fine.

send_to_socket(&self.sock, buf, &peer_addr.1, self.reply_pktinfo)
send_to_socket(
&self.sock,
&[IoSlice::new(buf)],

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not new, but this is changed buf -> &[IoSlice::new(buf)] because send_to_socket signature changed for iovec aka scatter/gather IO.

}
}

async fn inside_io_loop_default(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is pulled directly from the inside_io_loop tokio::spawn code and unchanged.

}

#[cfg(target_os = "linux")]
async fn inside_io_loop_gso(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is the GSO replacement for original inside_io_loop to handle the vnet_hdr and other GSO stuff.

# headers per segment can exceed what sendmsg(UDP_SEGMENT) accepts, causing the
# kernel to return EMSGSIZE ("Message too long") on the outside socket.
ip link set dev "${tunname}" gso_max_size 60300
ip link set dev "${tunname}" up

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is new to cap gso_max_size to not blow up sendmsg(UDP_SEGMENT) later. We also need this in deployment scripts because it's not in the lightway code anymore.

Comment thread lightway-core/src/gso.rs
/// `out` holds exactly the one segment's wire bytes.
///
/// `out.capacity()` must be ≥ one segment's maximum wire length.
pub(crate) fn build_segment(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I added some new tests below for this function - this is run to construct a complete, checksum-computed packet for wolfssl to encrypt. Could be written in IO scatter/gather style and avoid the copies if we use the wolfssl streaming/in-place encrypt APIs.

Comment thread lightway-core/src/connection/io_adapter.rs
@kp-mariappan-ramasamy

Copy link
Copy Markdown
Contributor

I tried to give it a go in my dev environments.
With namespace based dev setup, I am getting the following errors when running with tun-offload. Still the traffic passes.
We raise all error alerts to sentry. So in general, we will not have any error log in data path which will flood sentry.
Please use warn for datapath errors.

2026-06-02T08:10:04.394214Z ERROR lightway_core::connection: udp_send_gso failed error=Message too large (os error 90) segs=49
2026-06-02T08:10:04.590807Z ERROR lightway_core::connection: udp_send_gso failed error=Message too large (os error 90) segs=50            2026-06-02T08:10:04.802131Z ERROR lightway_core::connection: udp_send_gso failed error=Message too large (os error 90) segs=49
2026-06-02T08:10:05.058434Z ERROR lightway_core::connection: udp_send_gso failed error=Message too large (os error 90) segs=50
2026-06-02T08:10:05.216928Z ERROR lightway_core::connection: udp_send_gso failed error=Message too large (os error 90) segs=48

And then I tried to test in 2 VMs, where server panic'd with the following after starting the iperf3 test.

thread 'main' (324242) panicked at lightway-core/src/gso.rs:263:37:
range end index 2648 out of range for slice of length 1350
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Error: Inside IO loop panicked

Caused by:
    task 7 panicked with message "range end index 2648 out of range for slice of length 1350"

I set the tun gso properly similar to earthly script. Debugging whether it is my setup issue.
in case if you face this while dev, let me know.

@kp-samuel-tam
kp-samuel-tam force-pushed the gso-on-server branch 4 times, most recently from 0475f8d to 057ded1 Compare June 2, 2026 16:22
@kp-samuel-tam

Copy link
Copy Markdown
Member Author

Thank you all for the reviews 🙌 I have updated the PR but mistakenly rebased on a wrong branch, so now even if I fixed it, the force-push Compare button above will show a mess.

To make the review easier, please see https://github.com/expressvpn/lightway/compare/39cf489..057ded1 for changes since May 28. You can verify the two commit hashes and history with

curl -s "https://api.github.com/repos/expressvpn/lightway/issues/413/events?per_page=100" | jq '[.[] | select(.event == "head_ref_force_pushed") | {created_at, actor: .actor.login, commit_id}]'

And for the crash that @kp-mariappan-ramasamy raised, that is on me. That was not a setup issue but a subtle bug in the gso::build_segment code that I should have caught in tests, but missed when iterating the PR. It is now addressed in:

/// Linux's TUN driver writes `skb_headlen` (a hint about linearity) into
/// `virtio_net_hdr.hdr_len` — NOT the protocol header length the virtio-net
/// spec calls for. For multi-segment GSO aggregates the linearity hint is
/// roughly the size of the first segment (≈ MTU), not the headers, so any
/// code that copies a per-segment header template based on `vhdr.hdr_len`
/// will get a wildly wrong value. Parse the real length from the packet.

It's subtle but well-documented that kernel breaks the virtio-net spec on hdr_len; in our case vhdr.hdr_len = 1350, and it cannot be trusted or used as the protocol header length. The same issue was surfaced in a different way, see


// No tcp_clamp_mss — GSO packets are never SYN

// Plugins see the whole superpacket as one packet

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There can be some plugins to reject/dropwithreply individual packet, ex dns requests. This will break that.

Comment thread lightway-core/src/connection/io_adapter.rs Outdated
Comment thread lightway-server/src/lib.rs
Comment thread lightway-server/src/lib.rs Outdated
Comment on lines +364 to +388
// SAFETY: `recv_gso` wrote `len` bytes into the spare capacity
// starting at slab offset 0 (the window was just compacted back
// there). `len ≤ pkt.capacity()` because the kernel wrote into
// a slice of exactly that length.
#[allow(unsafe_code)]
unsafe {
pkt.set_len(len);
}

if len <= VIRTIO_NET_HDR_LEN {
tracing::warn!("TUN read too short ({len} <= {VIRTIO_NET_HDR_LEN})");
pkt.clear();
continue;
}

let hdr = match VirtioNetHdr::from_bytes(&pkt[..VIRTIO_NET_HDR_LEN]) {
Ok(hdr) => *hdr,
Err(err) => {
tracing::warn!("Failed to decode virtio header: {err}");
pkt.clear();
continue;
}
};
// Strip the virtio header — `pkt` is now the IP payload.
pkt.advance(VIRTIO_NET_HDR_LEN);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think these lines can be moved inside recv_gso and make the api return hdr and len along with bytesMut

async fn recv_gso(&self, buf: &mut BytesMut) -> IOCallbackResult<(usize, VirtioNetHdr)>;

Better to keep unsafe code inside the library abstractions instead of from main library code

Comment thread lightway-core/src/gso.rs Outdated
Comment thread lightway-core/src/connection.rs Outdated
Comment on lines +414 to +421
if let Some(gso_buf) = self.gso_buf.as_mut() {
// GSO: buffer raw encrypted data; udp_send_gso
// will wrap each segment with wire::Header later.
if gso_buf.is_empty() {
self.gso_size = buf.len();
}
gso_buf.extend_from_slice(buf);
IOCallbackResult::Ok(buf.len())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can move this part inside self.udp_send() so expresslane handling in connection.rs::send_expresslane_data() does not need to handle this separately.

Comment thread lightway-core/src/connection.rs Outdated
Comment thread lightway-core/src/gso.rs Outdated
Comment thread lightway-core/src/gso.rs
@kp-mariappan-ramasamy

Copy link
Copy Markdown
Contributor

There are 2 other issues i found:

  1. The dev namespace environment is broken. iperf3 fails from client to remote if i start the server without tun offload. PTAL. dev env will be created by sudo ./tests/setup.sh. Refer repo README
  2. Cargo.lock file needs some update. Rebuild and push to push lock file changes

@kp-samuel-tam
kp-samuel-tam force-pushed the gso-on-server branch 4 times, most recently from 75e8942 to a8a3690 Compare June 5, 2026 06:52
Comment thread lightway-app-utils/src/tun.rs Outdated
Comment thread lightway-server/src/io/inside/tun.rs Outdated
Comment on lines +62 to +103
// Read directly into the spare capacity. BytesMut's
// spare_capacity_mut returns &mut [MaybeUninit<u8>] so there's
// no zero-init pass on the hot path.
let n = match self.0.recv_gso(buf.spare_capacity_mut()).await {
IOCallbackResult::Ok(n) => n,
IOCallbackResult::WouldBlock => return IOCallbackResult::WouldBlock,
IOCallbackResult::Err(e) => return IOCallbackResult::Err(e),
};

if n <= VIRTIO_NET_HDR_LEN {
tracing::warn!(n, "tun recv_gso: read shorter than virtio header");
metrics::tun_recv_gso_short_read();
// Discard the partial read (buf is untouched — no set_len)
// and return WouldBlock so the caller's recv loop retries
// immediately instead of treating this as a hard error.
return IOCallbackResult::WouldBlock;
}

// SAFETY: AppUtilsTun::recv_gso wrote exactly `n` bytes into
// the spare slab; n <= buf.capacity() because the kernel wrote
// into a slice of that length.
#[allow(unsafe_code)]
unsafe {
buf.set_len(n);
}

// SAFETY for VirtioNetHdr::from_bytes: BytesMut is heap-backed
// and 8-byte aligned; `n > VIRTIO_NET_HDR_LEN` was just checked.
let hdr = match VirtioNetHdr::from_bytes(&buf[..VIRTIO_NET_HDR_LEN]) {
Ok(h) => *h,
Err(e) => {
tracing::warn!(?e, "tun recv_gso: virtio header decode failed");
buf.clear();
return IOCallbackResult::WouldBlock;
}
};
buf.advance(VIRTIO_NET_HDR_LEN);

// Note: payload bytes (post-virtio-strip), not raw kernel
// bytes — see metrics::tun_to_client doc.
metrics::tun_to_client(buf.len());
IOCallbackResult::Ok((buf.len(), hdr))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a low level processing. It should be moved inside app_utils::tun::recv_gso

Comment thread lightway-app-utils/src/tun.rs Outdated
@kp-mariappan-ramasamy

Copy link
Copy Markdown
Contributor

@kp-samuel-tam Did you test the local namespace based env.
I am facing an issue where switching between enable-run-offload/ disable makes disable mode failure to transfer traffic ?

Please let me know if you test the following cases:

  • In local dev env, run without offload, do a iperf test, then with offload, iperf test and repeat without the offload to make sure offload does not impact the orig environment.
  • Repeat the same tests using two VMs

Comment thread lightway-app-utils/src/tun.rs Outdated
Comment on lines +350 to +377
if n <= VIRTIO_NET_HDR_LEN {
tracing::warn!(n, "tun recv_gso: read shorter than virtio header");
crate::metrics::tun_recv_gso_short_read();
// Discard the partial read (buf is untouched — no set_len)
// and return WouldBlock so the caller's recv loop retries
// immediately instead of treating this as a hard error.
return IOCallbackResult::WouldBlock;
}

// SAFETY: TunDirect::recv_gso wrote exactly `n` bytes into the spare
// slab; n <= buf.capacity() because the kernel wrote into a slice of
// that length.
#[allow(unsafe_code)]
unsafe {
buf.set_len(n);
}

// SAFETY for VirtioNetHdr::from_bytes: BytesMut is heap-backed
// and 8-byte aligned; `n > VIRTIO_NET_HDR_LEN` was just checked.
let hdr = match lightway_core::VirtioNetHdr::from_bytes(&buf[..VIRTIO_NET_HDR_LEN]) {
Ok(h) => *h,
Err(e) => {
tracing::warn!(?e, "tun recv_gso: virtio header decode failed");
buf.clear();
return IOCallbackResult::WouldBlock;
}
};
buf.advance(VIRTIO_NET_HDR_LEN);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you move this implementation even further down to TunDirect ?

@kp-samuel-tam

Copy link
Copy Markdown
Member Author

@kp-mariappan-ramasamy I bumped tun-rs 2.5.1 -> 2.8.5 in this branch, which should resolve the issue properly now. Tested the offload off → on → off cycle in both real wire and local dev env.

@kp-mariappan-ramasamy kp-mariappan-ramasamy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the PR. LGTM

Add the `gso` module to lightway-core: VirtioNetHdr type, virtio-net
GSO/checksum constants, and the per-segment fixup primitives that
split a GSO superpacket into wire-format segments (IP ID, TCP seq,
checksums).

calc_hdr_len and build_segment return Result<_, Gso{Hdr,Seg}Error>
with metric_reason() labels. VirtioNetHdr has an is_tcp() method.
Five reason-labeled counters land in metrics.rs.

build_segment is in safe Rust via pnet_packet's mutable packet
types (Ipv4 / Ipv6 / Tcp / Udp); only unsafe left in the module is
VirtioNetHdr::from_bytes.
Add `send_gso` method for sending a concatenated wire buffer via
kernel GSO (UDP_SEGMENT). Stub implementations in client TCP/UDP,
server TCP, and test harnesses satisfy the trait contract.
Add a `GsoBuffer` to TlsIOAdapter: a per-connection coalescing
buffer + typed batch state (Passthrough / Pending / Coalescing). The
wolfssl send() callback coalesces raw encrypted segments via
GsoBuffer::put() when a batch is open; udp_send_gso wraps each with
wire::Header and ships the whole frame as one sendmsg via the
vectored send_gso callback.

The state machine encodes "first segment fixes gso_size" with
NonZeroUsize in the Coalescing variant — Some(0)-after-first-segment
becomes representationally impossible. Reservations are bounded by
MAX_GSO_FRAME_BYTES (= MAX_GSO_SEGS * MAX_OUTSIDE_MTU) on the first
open(); subsequent batches reuse the allocation. Connections that
never coalesce pay no heap.

Zero-copy fast path when no outside plugins are configured: scatter-
gather via iovec with a shared header buffer and borrowed slices of
the coalescing buffer. The plugin path builds each segment as its
own BytesMut and enforces the uniform-stride requirement of
UDP_SEGMENT.
Add inside_data_received_gso and send_to_outside_gso to Connection.
They process a GSO superpacket as a single packet through plugins /
encoder, then split into per-segment encrypted frames coalesced into
the connection's GsoBuffer for batch send via UDP_SEGMENT.

send_to_outside_gso drops aggregates with gso_size == 0, gso_segs >
MAX_GSO_SEGS (UDP_MAX_SEGMENTS kernel cap), or per-segment
(hdr_len + gso_size) > mtu, and increments gso_dropped_zero_gso_size,
gso_dropped_iov_overflow, gso_dropped_oversized_segment,
gso_dropped_invalid_hdr_len{reason}, or gso_send_failed accordingly.
All dropping paths surface as InvalidPacketError::InvalidGsoPacket.
GsoBuffer::reset() on every exit path ensures coalescing state
never persists into the next packet.
Add an `offload` field to TunConfig that turns on IFF_VNET_HDR on the
TUN device. Add `recv_gso` for raw reads that include the virtio_net_hdr
prefix; `try_send` prepends a zeroed virtio header when offload is on.
Extend send_to_socket with an optional gso_size and a UDP_SEGMENT
cmsg for kernel-level segmentation. Wire up the real send_gso on the
server's outside UdpSocket.
Add `enable_tun_offload` config option and wire it through ServerConfig
to main. Extract the default inside IO loop into its own function and
add `inside_io_loop_gso`, which reads virtio-framed superpackets from
TUN and dispatches GSO vs single-packet paths. Spawn picks one loop
based on the cfg flag in a single if/else.

gso_max_size capping lives in tests/server/docker-entrypoint.sh, not
in the server.
Lets the GSO recv loop use BytesMut::spare_capacity_mut() directly,
dropping the one-time 65 KB zero-init and one of the two call-site
unsafe blocks. The cast back to &mut [u8] now lives only at the
syscall boundary in TunDirect::recv_gso, with a comment pointing at
the tun-rs upstream gap to track for cleanup.
New Earthfile target `run-udp-tun-offload-test` runs the standard UDP
e2e against a server started with `--enable-tun-offload`, exercising
the TUN IFF_VNET_HDR + UDP_SEGMENT + GRO path end-to-end. Wired into
`run-all-tests` so CI picks it up alongside the other UDP variants.
tun-rs 2.8.5 issues TUNSETOFFLOAD(0) in the offload=false attach path
(tun-rs PR #141), clearing the device-wide TUN_F_* mask left behind on a
persistent TUN by a prior offload=true attach.
@kp-samuel-tam
kp-samuel-tam merged commit e831dd0 into main Jun 17, 2026
22 checks passed
@kp-samuel-tam
kp-samuel-tam deleted the gso-on-server branch June 17, 2026 10:40
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.

3 participants