25 Commits

Author SHA1 Message Date
Marvin 马维 Drees 8873b55e5d feat(tcp): add RFC 6298 RTO as a LossRecovery implementation (#169)
Provide a concrete packet-loss recovery algorithm for the LossRecovery
interface added in #168: the RFC 6298 round-trip-time estimator and single
retransmission timer.

RTO is a pure, reactive state machine. It derives RTT estimates and
retransmission decisions solely from the segments observed through the
LossRecovery hooks and the monotonic time handed in at each boundary, so it
holds no clock and allocates nothing (issue #140). It tracks a shadow of the
send sequence space (snd.UNA/snd.NXT) purely from observed segments, which is
how it manages the timer without reaching into the ControlBlock and how it
distinguishes retransmissions for Karn's algorithm.

Covered:
  - §2.2/§2.3 SRTT/RTTVAR/RTO smoothing (integer-shift form)
  - §3 Karn's algorithm: one sample in flight, never sample a retransmit
  - §5.1-§5.3 timer arm/restart/stop as data is sent and acknowledged
  - §5.4-§5.6 timeout response: exponential backoff + go-back-N retransmit
  - §5.7 backoff collapse on a valid RTT sample
  - RTO clamped to [rtoMin, rtoMax]

RTT introspection (SmoothedRTT, CurrentRTO, Running) lives on the concrete type,
not the interface, per the LossRecovery design. Install with new(RTO) on
ConnConfig.LossRecovery; the connection calls Reset on open so the zero value is
ready to use.

Generated with LLM assistance.

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>
2026-07-22 13:44:31 -03:00
Pat Whittingslow 41c7e3c445 add loss.go (#168) 2026-07-22 13:09:12 -03:00
Pat Whittingslow 1bbfd5eac3 @MDr164 trick to ignore no-extension files (#162)
* @MDr164 trick to ignore no-extension files

* catch changes to LICENSE
2026-07-21 16:14:53 -03:00
Pat Whittingslow 1a474154cb more httpraw bug fixes (#160)
* more httpraw bug fixes

* add http-linux example and add httpraw.Header.SetInt

* finish http-linux example

* fix CI vet
2026-07-19 11:09:09 -03:00
Patricio Whittingslow 347cef9ba5 README: update binary benchmark table 2026-07-17 12:03:28 -03:00
Pat Whittingslow 157a8537a2 testing.B.Loop() standardization and StackAsync.Debug improvement (#156)
* testing.B.Loop() standardization and StackAsync.Debug heap debugging improvement

* apply @MDr164 fixes

* @MDr164 great suggestions to prevent panic and print correct mallocs
2026-07-17 11:59:27 -03:00
Pat Whittingslow 766e03e90e httpraw: fix overrwrite/empty val bugs in Header.Set/SetBytes (#159) 2026-07-17 11:07:59 -03:00
Marvin Drees ab1a0c735a Add out-of-order segment reassembly (#148)
* feat(tcp): add out-of-order segment reassembly

Add an opt-in, bounded out-of-order reassembly buffer so a single lost
segment can be recovered by retransmitting the gap while later segments
are held and delivered once the gap fills.

The receiver also subtracts buffered out-of-order bytes from the
advertised receive window and avoids challenge-ACK aborts for in-window
future data. Reassembly is disabled by default.

Generated with LLM assistance.

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>

* implement review feedback around rx buffer reuse

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>

---------

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>
2026-07-10 10:36:15 -03:00
Marvin Drees 3c1f0e0281 Handle IA_NA/IA_PD requests and config options in DHCPv6 (#147)
* feat(dhcpv6): handle IA_NA/IA_PD requests and config options

Adds the DHCPv6 client request exchange with IA_NA/IA_PD handling,
reconfigure-renew (RFC 8415), DNS server and domain search options
(RFC 3646) and NTP server suboptions (RFC 5908).

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>

* fix(dhcpv6): bound server-supplied option counts to prevent OOM

A DHCPv6 server may place arbitrarily many DNS servers, search domains,
NTP entries and delegated prefixes in a single message (RFC 8415). The
client appended every entry into growable slices with no upper bound, so
a malicious or misconfigured server could drive unbounded allocation.

Add a configurable Limits to RequestConfig (with safe defaults) capping
each repeated option. The backing arrays are sized to their caps once in
BeginRequest and reused across resets, so parsing a server message now
performs no allocation and stored entries stay bounded.

Add tests asserting the caps are enforced against a flooded Reply and that
option parsing is allocation-free after BeginRequest.

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>

---------

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>
2026-07-09 14:12:30 -03:00
Pat Whittingslow 99e9d90a60 Netdev revamp (#145)
* begin working on netdev solution

* need to roll back some assumptions in next commit

* dual poll/async mode for netdev.Runner

* add wake on rx semantics

* remove TODO

* more reworking of Runner

* work on applying @MDr164 suggestions and a couple extra revamps

* add newline to end of test file
2026-07-09 14:04:10 -03:00
Marvin Drees 4fc53a84cd fix: make github action comment work right (#153)
It was previously just commenting the file name instead of the content,
this change attempts to fix it using json instead of the raw file

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>
2026-07-09 14:01:53 -03:00
Marvin Drees 104a2b0b26 feat(ci): add benchci for better visualization (#149)
This adds a small in-repo tool and updates the ci.yml file in order
to make benchmark and most importantly allocation results visible in a
PR. It also drops the 3rdparty action we had commented out.

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>
2026-07-02 20:21:49 -03:00
Patricio Whittingslow e5f8ac0687 add as of now unused af_inet6 for posterity 2026-07-01 21:09:28 -03:00
Patricio Whittingslow 3b5b010673 export xnet: AF_INET, SOCK_STREAM, SOCK_DGRAM 2026-07-01 21:07:21 -03:00
Pat Whittingslow 4d6363da00 bound MTU on Stack EgressIP (#151) 2026-07-01 18:46:24 -03:00
Pat Whittingslow cdc99fb9ff work on removing heap allocations (#150) 2026-07-01 13:26:05 -03:00
Pat Whittingslow 96a02e7d2c begin writing noipv6 test (#146) 2026-06-28 10:26:49 -03:00
Pat Whittingslow b795d6a437 use ltesto as package to contain scheduler/goroutine testing logic (#143)
* use ltesto as package to contain scheduler/goroutine testing logic

* add sched usage to another test

* testCloseTransmitsPending rewrite

* replace deadline with context
2026-06-27 10:37:46 -03:00
Ron Evans fa609ea54f fix: two fixes for dhcp/ap (#136)
* feature: ipv4 broadcast support

* stackip4 method receiver varname

* Incorporate strict test timing for TestStackGoTCPDialRetriesPendingControl (#135)

* rewrite tinygo failing test to be more real-time

* use channels to scheduler stack

* fix flakiness by increasing timeout and move implementation to top of file

* fix(dhcp): use chaddr as lookup key and patch Ethernet dst on Offer/Ack

- Client lookup in Demux() now keys on chaddr when no OptClientIdentifier
  is present. It previously used ciaddr which is 0.0.0.0 during initial lease
  acquisition, causing MsgRequest to fail to match any client (RFC 2131 §4.3.1)
- In Encapsulate(), overwrite Ethernet dst with client.hwaddr when packet is
  embedded in an IP frame (offsetToIP >= 14), per RFC 2131 §4.1. It previously
  sent to gwmac which clients without an ARP entry could not receive

Signed-off-by: deadprogram <ron@hybridgroup.com>

* fix: accept 255.255.255.255 aka bradcast dst in demux4

This fixes a problem with accept 255.255.255.255 dst in demux4 which previously dropped when stack had a static IP.

Signed-off-by: deadprogram <ron@hybridgroup.com>

---------

Signed-off-by: deadprogram <ron@hybridgroup.com>
Co-authored-by: Patricio Whittingslow <graded.sp@gmail.com>
2026-06-24 22:16:53 -03:00
Pat Whittingslow a42f0b404c feature: ipv4 broadcast support and bugfix for reset connection reset of IPv4+IPv6 stacks (#139)
* feature: ipv4 broadcast support

* stackip4 method receiver varname
2026-06-24 16:54:41 -03:00
Pat Whittingslow ef279863a9 Incorporate strict test timing for TestStackGoTCPDialRetriesPendingControl (#135)
* rewrite tinygo failing test to be more real-time

* use channels to scheduler stack

* fix flakiness by increasing timeout and move implementation to top of file
2026-06-24 13:07:19 -03:00
TuteMthCD d5ea2efdf4 Retransmit active-open SYN after RTO (#130)
* added: sync packet retransmission

* test: sync packet retransmission

* refactor: remove packet time from tcppool

* fix: return comment

* refactor: move retrying to stackRetrying

* added: retries & timeout to stackGoConfig

* test: test retries stack

* refactor: move StackRetrying to stackBlocking

* fix: line gofmt

---------

Co-authored-by: Pat Whittingslow <graded.sp@gmail.com>
2026-06-23 00:19:16 -03:00
Marvin Drees a0a5336108 Add xcurl port and debug flag (#129)
* feat: add xcurl port and debug flag

Update the docs and add new flags to xcurl to aid debugging under an OS
like Linux.

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>

* drop debugtcp flag from xcurl again

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>

---------

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>
2026-06-22 18:32:50 -03:00
Pat Whittingslow 28addf329a try to fix fmt msg (#133)
* try to fix fmt msg

* fix CI
2026-06-22 18:16:29 -03:00
Marvin Drees 860d6d00bb Add atomic id guard to prevent TOCTOU (#131)
* add atomic id guard to prevent TOCTOU

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>

* implement review feedback to tcp conn guard

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>

---------

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>
2026-06-22 09:56:38 -03:00
61 changed files with 6086 additions and 407 deletions
+90
View File
@@ -0,0 +1,90 @@
name: Benchmark Comment
# Rationale: This more privileged workflow runs in the base repo's
# context (with a write token) only after the 'untrusted' CI workflow finishes.
# It does NOT check out or execute PR code; it only consumes the benchmark
# report artifact as inert, validated data.
on:
workflow_run:
workflows: [CI]
types: [completed]
permissions:
contents: read
jobs:
comment:
# Only for PR-triggered CI runs that succeeded to suppress noise
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # needed to comment
steps:
- name: Download generated benchmark report
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.1
with:
name: bench-report
path: bench-report
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Validate report and upsert PR comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
set -euo pipefail
report="bench-report/bench-report.md"
marker='<!-- lneto-bench -->'
# Treat the artifact as untrusted input: it was produced by a job that
# ran PR code. Basic validation before posting. TODO: Make more robust.
test -f "$report"
size="$(wc -c < "$report")"
if [ "$size" -le 0 ] || [ "$size" -gt 1000000 ]; then
echo "::error::benchmark report has unexpected size: ${size} bytes"
exit 1
fi
if ! grep -qF "$marker" "$report"; then
echo "::error::benchmark report missing marker ${marker}"
exit 1
fi
# Prefer the PR number from the workflow_run payload, which ties this
# privileged workflow to the PR-triggered CI run. Fall back to the
# run's head SHA/branch only when GitHub does not populate it.
pr="$PR_NUMBER"
if [ -z "$pr" ]; then
pr="$(gh api "repos/${REPO}/commits/${HEAD_SHA}/pulls" \
--jq 'map(select(.state == "open")) | .[0].number // empty')"
fi
if [ -z "$pr" ]; then
pr="$(gh api --method GET "repos/${REPO}/pulls" \
-f state=open \
-f head="${HEAD_OWNER}:${HEAD_BRANCH}" \
--jq '.[0].number // empty')"
fi
if [ -z "$pr" ]; then
echo "No open PR found for ${HEAD_SHA}; nothing to comment."
exit 0
fi
comment_id="$(
gh api "repos/${REPO}/issues/${pr}/comments" --paginate \
--jq ".[] | select(.body | contains(\"${marker}\")) | .id" | head -n1
)"
if [ -n "$comment_id" ]; then
gh api --method PATCH "repos/${REPO}/issues/comments/${comment_id}" \
--input - < <(jq -n --rawfile body "$report" '{body: $body}')
else
gh api --method POST "repos/${REPO}/issues/${pr}/comments" \
--input - < <(jq -n --rawfile body "$report" '{body: $body}')
fi
+16 -16
View File
@@ -20,9 +20,9 @@ jobs:
- name: gofmt
run: |
unformatted=$(gofmt -l .)
unformatted=$(gofmt -l -d . 2>&1) || true
if [ -n "$unformatted" ]; then
echo "::error::Files not formatted with gofmt:"
echo "::error::File(s) not formatted with gofmt:"
echo "$unformatted"
exit 1
fi
@@ -104,7 +104,7 @@ jobs:
needs: [test]
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -115,20 +115,20 @@ jobs:
- name: Run benchmarks
run: go test -bench=. -benchmem -count=5 -shuffle=on -run='^$' -timeout=15m ./... | tee bench-results.txt
# Fails for a weird reason, will be investigated in the future
# - name: Report benchmark regressions
# uses: benchmark-action/github-action-benchmark@a887eba2af2000fda74e733fcf952aa823b65916 # v1.21.0
# with:
# tool: go
# output-file-path: bench-results.txt
# summary-always: true
# comment-on-alert: true
# alert-threshold: "120%"
# fail-on-alert: false
# auto-push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
# github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate benchmark report
run: |
go run ./internal/benchci -current bench-results.txt -out bench-report.md
cat bench-report.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload benchmark results
- name: Upload generated benchmark report
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bench-report
path: bench-report.md
retention-days: 30
- name: Upload raw benchmark results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+10 -1
View File
@@ -1,3 +1,12 @@
# Ignore files with no extensions trick:
# First ignore all.
*
# Unignore directories since they usually have no extensions
!*/
# Unignore all with extensions.
!*.*
!LICENSE
# Go workspaces.
go.work
go.work.sum
@@ -22,7 +31,7 @@ vendor/
*.dylib
*.hex
*.wasm
/http-linux
# Profiling
*.pprof
+18 -2
View File
@@ -38,7 +38,7 @@ go run ./examples/gen/gen-binary-bench # generate table
| Program | Extra Protocols | Packet capture printing | amd64 Go | WASM Go | amd64 TinyGo | WASM TinyGo | Pico TinyGo |
|---|:---:|:---:|---|---|---|---|---|
| [Lneto MWE](./examples/min-working-example/) | DNS,NTP,DHCP | ✅ | 3.9MB | 4.4MB | 1.6MB | 1.2MB | 189kB |
| [Lneto MWE](./examples/min-working-example/) | DNS,NTP,DHCP | ✅ | 3.9MB | 4.5MB | 1.6MB | 1.2MB | 192kB |
| [Gvisor MWE w/ go-net](./examples/_import_examples/gvisor-mwe/) | None | ❌ | 6.6MB | 7.5MB | DNC | DNC | DNC |
## `xcurl` example
You may try lneto out on linux with the [xcurl example](./examples/xcurl/) which gets an HTTP page by doing all the low-level networking part using absolutely no standard library.
@@ -49,6 +49,7 @@ You may try lneto out on linux with the [xcurl example](./examples/xcurl/) which
- HTTP over TCP/IPv4/Ethernet connection using
- NTP time check (optional)
- Print packet captures using lneto's [internet/pcap](./internet/pcap) package
- Optional fixed TCP source port with `-port`, useful when debugging raw-socket interactions with the host OS
See Developing section below for more information.
@@ -108,7 +109,9 @@ ok github.com/soypat/lneto/x/xnet 2.926s
| Ethernet PHY/MDIO | IEEE 802.3 cl.22/45 | ✅ | `phy` | — | Bare-metal PHY management via MDIO |
| IPv6 | RFC 8200 | ✅ | `ipv6` | — | Frame parsing and stack handling |
| ICMPv6 | RFC 4443 | ✅ | `ipv6/icmpv6` | — | Echo+NDP frame parsing and stack handling |
| DHCPv6 | RFC 8415 | 🟡 | `dhcp/dhcpv6` | — | Frame parsing and standalone handling |
| DHCPv6 DNS Configuration | RFC 3646 | 🟡 | `dhcp/dhcpv6` | — | Recursive DNS server and domain search options parsed and exposed |
| DHCPv6 NTP Configuration | RFC 5908 | ✅ | `dhcp/dhcpv6` | — | NTP server address, multicast address, and FQDN suboptions parsed and exposed |
| DHCPv6 | RFC 8415 | 🟡 | `dhcp/dhcpv6` | — | Client IA_NA/IA_PD request handling and reconfigure-renew; no relay agent or dynamic server pools |
| TLS 1.3 | RFC 8446 | ❌ | — | — | Not implemented |
¹ `BenchmarkARPExchange` — full ARP request/response exchange over Ethernet: **0 B/op, 0 allocs/op**
@@ -171,6 +174,19 @@ sudo ethtool -K <network-interface> gro off
Depending on the interface and driver, other offloading features may also need to be disabled.
When running `xcurl` directly on a normal Linux interface (for example `-i wlan0` or `-i eth0`), lneto shares the host interface IP and MAC address. The Linux TCP stack also sees incoming packets for that IP. If xcurl's TCP handshake succeeds but the HTTP request is followed by a TCP RST from the server, the host kernel may have sent its own outbound RST for xcurl's raw TCP flow because no kernel socket owns that source port.
For a short validation test, use a fixed xcurl source port and temporarily drop kernel-generated outbound RSTs for that port:
```sh
go build -o examples/xcurl/xcurl ./examples/xcurl
sudo iptables -I OUTPUT -p tcp --sport 2300 --tcp-flags RST RST -j DROP
sudo ./examples/xcurl/xcurl -i <network-interface> -port 2300 -host example.com
sudo iptables -D OUTPUT -p tcp --sport 2300 --tcp-flags RST RST -j DROP
```
This is a debugging workaround, not the preferred operating mode. Prefer `-ihttp`, a TAP interface, a network namespace, or an otherwise isolated interface/IP when testing xcurl without host TCP stack interference.
- [`examples/httptap`](./examples/httptap) (linux only, root privilidges required) Program opens a TAP interface and assigns an IP address to it and exposes the interface via a HTTP interface. This program is run with root privilidges to facilitate debugging of lneto since no root privilidges are required to interact with the HTTP interface exposed.
- `POST http://127.0.0.1:7070/send`: Receives a POST with request body containing JSON string of data to send over TAP interface. Response contains only status code.
- `GET http://127.0.0.1:7070/recv`: Receives a GET request. Response contains a JSON string of oldest unread TAP interface packet. If string is empty then there is no more data to read.
+22 -1
View File
@@ -152,7 +152,20 @@ func (sv *Server) Demux(carrierData []byte, frameOffset int) error {
var client serverEntry
var clientExists bool
if len(clientID) == 0 {
client, clientIDRaw, clientExists = sv.getClientByIP(*dfrm.CIAddr())
// No explicit client identifier: use chaddr as the stable lookup key.
// This lets the server correlate Discover→Offer→Request even when
// ciaddr=0.0.0.0 (client has no IP yet), which is the normal case
// for first-time lease acquisition (RFC 2131 §4.3.1).
chaddr := *dfrm.CHAddrAs6()
copy(clientIDRaw[:], chaddr[:])
client, clientExists = sv.getClient(clientIDRaw)
if !clientExists {
// Fallback: look up by ciaddr for clients that did send ciaddr.
ciaddr := *dfrm.CIAddr()
if ciaddr != ([4]byte{}) {
client, clientIDRaw, clientExists = sv.getClientByIP(ciaddr)
}
}
} else {
copy(clientIDRaw[:], clientID)
client, clientExists = sv.getClient(clientIDRaw)
@@ -301,6 +314,14 @@ func (sv *Server) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int)
if err != nil {
return 0, err
}
// Per RFC 2131 §4.1: unicast Offer/Ack to chaddr because the client
// does not yet have the offered IP, so no ARP entry exists.
// The Ethernet layer sets dst=gwmac before calling us; overwrite it
// here so the frame reaches the client via its hardware address.
// offsetToIP==14 means the Ethernet header is at carrierData[0:14].
if offsetToIP >= 14 {
copy(carrierData[offsetToIP-14:offsetToIP-8], client.hwaddr[:])
}
}
client.state = futureState
+252
View File
@@ -0,0 +1,252 @@
package dhcpv4
// Tests covering the AP-mode DHCP server fixes:
// 1. Client lookup by chaddr when no ClientID option is present.
// 2. Ciaddr fallback lookup when chaddr lookup finds nothing.
// 3. Ethernet dst is patched to chaddr on Offer/Ack when Encapsulate is
// called with offsetToIP >= 14.
import (
"testing"
"github.com/soypat/lneto/ipv4"
)
// doraNoClientID runs a complete DORA exchange using raw DHCP frames
// (no ClientID option) with the given chaddr. The client uses only chaddr
// as its identity, matching normal AP-mode behaviour where mobile clients
// don't send OptClientIdentifier.
func doraNoClientID(t *testing.T, sv *Server, chaddr [6]byte, xid uint32) (assignedIP [4]byte) {
t.Helper()
var cl Client
err := cl.BeginRequest(xid, RequestConfig{
ClientHardwareAddr: chaddr,
// Intentionally no ClientID forces server to key on chaddr.
})
if err != nil {
t.Fatalf("BeginRequest: %v", err)
}
var buf [1024]byte
// DISCOVER
n, err := cl.Encapsulate(buf[:], -1, 0)
if err != nil || n == 0 {
t.Fatalf("discover encapsulate: n=%d err=%v", n, err)
}
if err = sv.Demux(buf[:n], 0); err != nil {
t.Fatalf("discover demux: %v", err)
}
// OFFER
n, err = sv.Encapsulate(buf[:], -1, 0)
if err != nil || n == 0 {
t.Fatalf("offer encapsulate: n=%d err=%v", n, err)
}
frm, _ := NewFrame(buf[:n])
assignedIP = *frm.YIAddr()
if err = cl.Demux(buf[:n], 0); err != nil {
t.Fatalf("offer demux: %v", err)
}
// REQUEST
n, err = cl.Encapsulate(buf[:], -1, 0)
if err != nil || n == 0 {
t.Fatalf("request encapsulate: n=%d err=%v", n, err)
}
if err = sv.Demux(buf[:n], 0); err != nil {
t.Fatalf("request demux: %v", err)
}
// ACK
n, err = sv.Encapsulate(buf[:], -1, 0)
if err != nil || n == 0 {
t.Fatalf("ack encapsulate: n=%d err=%v", n, err)
}
if err = cl.Demux(buf[:n], 0); err != nil {
t.Fatalf("ack demux: %v", err)
}
if cl.State() != StateBound {
t.Errorf("want StateBound, got %s", cl.State())
}
return assignedIP
}
// TestServerChaddrLookup_NoClientID verifies the server can complete a full DORA
// cycle when the client sends no OptClientIdentifier option. Before the fix the
// MsgRequest Demux call would fail with "request for non existing client" because
// the server tried to look up the entry by ciaddr (0.0.0.0) instead of chaddr.
func TestServerChaddrLookup_NoClientID(t *testing.T) {
svAddr := [4]byte{192, 168, 4, 1}
var sv Server
sv.Configure(ServerConfig{
ServerAddr: svAddr,
Gateway: svAddr,
Subnet: ipv4.PrefixFrom(svAddr, 24),
})
chaddr := [6]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}
ip := doraNoClientID(t, &sv, chaddr, 0x12345678)
if ip[0] != 192 || ip[1] != 168 || ip[2] != 4 {
t.Errorf("unexpected subnet in assigned IP %v", ip)
}
}
// TestServerChaddrLookup_MultipleClientsNoClientID verifies that multiple clients
// without ClientID each get a unique address and successfully reach StateBound.
func TestServerChaddrLookup_MultipleClientsNoClientID(t *testing.T) {
svAddr := [4]byte{192, 168, 4, 1}
var sv Server
sv.Configure(ServerConfig{
ServerAddr: svAddr,
Gateway: svAddr,
Subnet: ipv4.PrefixFrom(svAddr, 24),
})
addrs := make([][4]byte, 4)
for i := range addrs {
chaddr := [6]byte{0x00, 0x11, 0x22, 0x33, 0x44, byte(i + 1)}
addrs[i] = doraNoClientID(t, &sv, chaddr, uint32(i+1))
}
for i := range addrs {
for j := i + 1; j < len(addrs); j++ {
if addrs[i] == addrs[j] {
t.Errorf("clients %d and %d got same address %v", i, j, addrs[i])
}
}
}
}
// TestServerCiaddrFallback exercises the ciaddr fallback path in Demux.
// An entry is registered under an explicit non-MAC ClientID. A subsequent
// RELEASE arrives with no ClientID (so chaddr lookup misses) but with
// ciaddr=assignedIP, which should let the server find and remove the entry.
func TestServerCiaddrFallback(t *testing.T) {
svAddr := [4]byte{192, 168, 4, 1}
chaddr := [6]byte{0xca, 0xfe, 0x00, 0x00, 0x01, 0x01}
const xid = uint32(0xaabbccdd)
var sv Server
sv.Configure(ServerConfig{
ServerAddr: svAddr,
Subnet: ipv4.PrefixFrom(svAddr, 24),
})
// DORA with an explicit ClientID — server keys the entry by that string.
var cl Client
if err := cl.BeginRequest(xid, RequestConfig{
ClientHardwareAddr: chaddr,
ClientID: "device-id",
}); err != nil {
t.Fatalf("BeginRequest: %v", err)
}
assignedIP := doraWithClient(t, &sv, &cl)
// RELEASE: no ClientID, ciaddr = assignedIP.
// chaddr lookup fails (entry is keyed by "device-id"), so server must
// fall back to getClientByIP.
var relBuf [512]byte
rfrm, _ := NewFrame(relBuf[:])
rfrm.ClearHeader()
rfrm.SetOp(OpRequest)
rfrm.SetHardware(1, 6, 0)
rfrm.SetXID(xid)
*rfrm.CIAddr() = assignedIP
copy(rfrm.CHAddrAs6()[:], chaddr[:])
rfrm.SetMagicCookie(MagicCookie)
opts := rfrm.OptionsPayload()
n, _ := EncodeOption(opts, OptMessageType, byte(MsgRelease))
opts[n] = byte(OptEnd)
n++
if err := sv.Demux(relBuf[:OptionsOffset+n], 0); err != nil {
t.Fatalf("release demux: %v", err)
}
if len(sv.hosts) != 0 {
t.Errorf("expected 0 hosts after release, got %d", len(sv.hosts))
}
}
// TestServerOfferPatchesEthernetDst verifies that Encapsulate overwrites the
// first 6 bytes of the carrier (Ethernet dst) with the client's chaddr when
// offsetToIP >= 14.
func TestServerOfferPatchesEthernetDst(t *testing.T) {
svAddr := [4]byte{192, 168, 4, 1}
clChaddr := [6]byte{0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
var sv Server
sv.Configure(ServerConfig{
ServerAddr: svAddr,
Subnet: ipv4.PrefixFrom(svAddr, 24),
})
var cl Client
cl.BeginRequest(0x11223344, RequestConfig{ClientHardwareAddr: clChaddr})
var buf [1024]byte
n, err := cl.Encapsulate(buf[:], -1, 0)
if err != nil || n == 0 {
t.Fatalf("discover encapsulate: n=%d err=%v", n, err)
}
if err = sv.Demux(buf[:n], 0); err != nil {
t.Fatalf("discover demux: %v", err)
}
// Carrier: 14 bytes Ethernet header, then a minimal IPv4 header marker.
var carrier [1024]byte
carrier[14] = 0x45 // IPv4 version+IHL so SetIPAddrs succeeds.
offerN, err := sv.Encapsulate(carrier[:], 14, 14+20+8)
if err != nil || offerN == 0 {
t.Fatalf("offer encapsulate: n=%d err=%v", offerN, err)
}
var got [6]byte
copy(got[:], carrier[0:6])
if got != clChaddr {
t.Errorf("Ethernet dst: got %v, want %v", got, clChaddr)
}
}
// doraWithClient runs a complete DORA exchange using the given pre-configured
// Client and returns the assigned IP.
func doraWithClient(t *testing.T, sv *Server, cl *Client) (assignedIP [4]byte) {
t.Helper()
var buf [1024]byte
n, err := cl.Encapsulate(buf[:], -1, 0)
if err != nil || n == 0 {
t.Fatalf("discover encapsulate: n=%d err=%v", n, err)
}
if err = sv.Demux(buf[:n], 0); err != nil {
t.Fatalf("discover demux: %v", err)
}
n, err = sv.Encapsulate(buf[:], -1, 0)
if err != nil || n == 0 {
t.Fatalf("offer encapsulate: n=%d err=%v", n, err)
}
frm, _ := NewFrame(buf[:n])
assignedIP = *frm.YIAddr()
if err = cl.Demux(buf[:n], 0); err != nil {
t.Fatalf("offer demux: %v", err)
}
n, err = cl.Encapsulate(buf[:], -1, 0)
if err != nil || n == 0 {
t.Fatalf("request encapsulate: n=%d err=%v", n, err)
}
if err = sv.Demux(buf[:n], 0); err != nil {
t.Fatalf("request demux: %v", err)
}
n, err = sv.Encapsulate(buf[:], -1, 0)
if err != nil || n == 0 {
t.Fatalf("ack encapsulate: n=%d err=%v", n, err)
}
if err = cl.Demux(buf[:n], 0); err != nil {
t.Fatalf("ack demux: %v", err)
}
if cl.State() != StateBound {
t.Errorf("want StateBound, got %s", cl.State())
}
return assignedIP
}
+337 -8
View File
@@ -6,14 +6,77 @@ import (
"net/netip"
"github.com/soypat/lneto"
"github.com/soypat/lneto/dns"
"github.com/soypat/lneto/internal"
)
// Default caps for the repeated, server-supplied options the client retains.
// A DHCPv6 server may place arbitrarily many of each in a single message
// (RFC 8415), so the client bounds them to prevent a malicious or
// misconfigured server from driving unbounded allocation (an OOM vector).
const (
defaultMaxDNSServers = 4
defaultMaxDomainSearch = 6
defaultMaxNTPServers = 4
defaultMaxNTPMulticastServers = 2
defaultMaxNTPServerNames = 4
defaultMaxDelegatedPrefixes = 4
)
// Limits bounds how many entries of each repeated, server-supplied option the
// client retains from a single exchange. The backing arrays are sized to these
// caps once in [Client.BeginRequest] and reused across resets, so parsing a
// server message performs no further allocation and a hostile server cannot
// grow client memory without bound. A zero (or negative) field selects its
// default; see the default* constants.
type Limits struct {
MaxDNSServers int // OptDNSServers addresses.
MaxDomainSearch int // OptDomainList search names.
MaxNTPServers int // OptNTPServer suboption 1 addresses.
MaxNTPMulticastServers int // OptNTPServer suboption 2 addresses.
MaxNTPServerNames int // OptNTPServer suboption 3 FQDNs.
MaxDelegatedPrefixes int // OptIAPD/OptIAPrefix delegated prefixes.
}
// withDefaults returns a copy of l with every non-positive field replaced by
// its default cap, so the resolved limits are always usable.
func (l Limits) withDefaults() Limits {
if l.MaxDNSServers <= 0 {
l.MaxDNSServers = defaultMaxDNSServers
}
if l.MaxDomainSearch <= 0 {
l.MaxDomainSearch = defaultMaxDomainSearch
}
if l.MaxNTPServers <= 0 {
l.MaxNTPServers = defaultMaxNTPServers
}
if l.MaxNTPMulticastServers <= 0 {
l.MaxNTPMulticastServers = defaultMaxNTPMulticastServers
}
if l.MaxNTPServerNames <= 0 {
l.MaxNTPServerNames = defaultMaxNTPServerNames
}
if l.MaxDelegatedPrefixes <= 0 {
l.MaxDelegatedPrefixes = defaultMaxDelegatedPrefixes
}
return l
}
// RequestConfig holds the parameters for starting a DHCPv6 exchange.
type RequestConfig struct {
// ClientHardwareAddr is the client's Ethernet MAC address.
// It is used to construct the client DUID-LL and IAID.
ClientHardwareAddr [6]byte
// Limits bounds how many of each repeated server-supplied option the client
// stores. The zero value selects safe defaults for every field.
Limits Limits
}
// DelegatedPrefix is an IPv6 prefix delegated by a DHCPv6 server.
type DelegatedPrefix struct {
Prefix netip.Prefix
PreferredLifetime uint32
ValidLifetime uint32
}
// Client is a stateful DHCPv6 client implementing the [lneto.StackNode] interface.
@@ -39,6 +102,21 @@ type Client struct {
// dns accumulates DNS recursive name server addresses (OptDNSServers).
// Client owns the backing array; cleared on reset, capacity reused.
dns []netip.Addr
// domainSearch accumulates DNS domain search names (OptDomainList).
// Client owns the backing array; cleared on reset, capacity reused.
domainSearch []dns.Name
// ntps accumulates NTP server addresses (OptNTPServer, suboption 1).
// Client owns the backing array; cleared on reset, capacity reused.
ntps []netip.Addr
// ntpMulticast accumulates NTP multicast addresses (OptNTPServer, suboption 2).
// Client owns the backing array; cleared on reset, capacity reused.
ntpMulticast []netip.Addr
// ntpNames accumulates NTP server FQDNs (OptNTPServer, suboption 3).
// Client owns the backing array; cleared on reset, capacity reused.
ntpNames []dns.Name
// delegatedPrefixes accumulates IA_PD prefixes (OptIAPD/OptIAPrefix).
// Client owns the backing array; cleared on reset, capacity reused.
delegatedPrefixes []DelegatedPrefix
assignedAddr [16]byte
assignedAddrValid bool
@@ -50,9 +128,18 @@ type Client struct {
t1, t2 uint32
preferredLifetime uint32
validLifetime uint32
// IA_PD timers from the server's Advertise/Reply.
pdT1, pdT2 uint32
reconfigureMsg MsgType
reconfigureOK bool
clientMAC [6]byte
// limits is the resolved (defaults-applied) cap on each repeated option.
// It is set in BeginRequest and preserved across resets so the bounded
// backing arrays are reused without reallocation.
limits Limits
// auxbuf is a scratch buffer used during Encapsulate to avoid allocations.
auxbuf [128]byte
}
@@ -70,23 +157,65 @@ func (c *Client) BeginRequest(xid uint32, cfg RequestConfig) error {
c.clientMAC = cfg.ClientHardwareAddr
c.iaid = [4]byte(cfg.ClientHardwareAddr[:4])
c.xid = xid & 0xFFFFFF
c.limits = cfg.Limits.withDefaults()
c.reset()
// Size the per-option backing arrays to their caps once, here, so the
// parse path on the network-facing Demux never allocates and stored
// entries stay bounded across the connection's lifetime.
c.dns = ensureAddrCap(c.dns, c.limits.MaxDNSServers)
c.ntps = ensureAddrCap(c.ntps, c.limits.MaxNTPServers)
c.ntpMulticast = ensureAddrCap(c.ntpMulticast, c.limits.MaxNTPMulticastServers)
c.domainSearch = ensureNameCap(c.domainSearch, c.limits.MaxDomainSearch)
c.ntpNames = ensureNameCap(c.ntpNames, c.limits.MaxNTPServerNames)
c.delegatedPrefixes = ensurePrefixCap(c.delegatedPrefixes, c.limits.MaxDelegatedPrefixes)
c.duid = AppendDUIDLL(c.duid[:0], cfg.ClientHardwareAddr)
c.state = StateInit
return nil
}
// ensureAddrCap returns s truncated to length 0 with capacity at least n,
// allocating a new backing array only when the existing one is too small.
func ensureAddrCap(s []netip.Addr, n int) []netip.Addr {
if cap(s) < n {
return make([]netip.Addr, 0, n)
}
return s[:0]
}
func ensureNameCap(s []dns.Name, n int) []dns.Name {
if cap(s) < n {
return make([]dns.Name, 0, n)
}
return s[:0]
}
func ensurePrefixCap(s []DelegatedPrefix, n int) []DelegatedPrefix {
if cap(s) < n {
return make([]DelegatedPrefix, 0, n)
}
return s[:0]
}
// Reset clears the DHCPv6 exchange state and invalidates stack registrations.
func (c *Client) Reset() { c.reset() }
// reset clears exchange state while preserving slice backing arrays and the
// connection ID is incremented to invalidate any existing stack registrations.
func (c *Client) reset() {
*c = Client{
connID: c.connID + 1,
xid: c.xid,
clientMAC: c.clientMAC,
iaid: c.iaid,
duid: c.duid,
serverDUID: c.serverDUID[:0],
dns: c.dns[:0],
connID: c.connID + 1,
xid: c.xid,
clientMAC: c.clientMAC,
iaid: c.iaid,
limits: c.limits,
duid: c.duid,
serverDUID: c.serverDUID[:0],
dns: c.dns[:0],
domainSearch: c.domainSearch[:0],
ntps: c.ntps[:0],
ntpMulticast: c.ntpMulticast[:0],
ntpNames: c.ntpNames[:0],
delegatedPrefixes: c.delegatedPrefixes[:0],
}
}
@@ -115,6 +244,8 @@ func (c *Client) Encapsulate(carrierData []byte, _, offsetToFrame int) (int, err
frm.SetTransactionID(c.xid)
n, _ := EncodeOption(dst[OptionsOffset+numOpts:], OptClientID, c.duid...)
numOpts += n
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptReconfAccept)
numOpts += n
n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, nil)
numOpts += n
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptORO, defaultOptRequestList...)
@@ -130,6 +261,8 @@ func (c *Client) Encapsulate(carrierData []byte, _, offsetToFrame int) (int, err
numOpts += n
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptServerID, c.serverDUID...)
numOpts += n
n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptReconfAccept)
numOpts += n
auxN, _ := EncodeOptionIAAddr(c.auxbuf[:], c.assignedAddr, 0, 0)
n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, c.auxbuf[:auxN])
numOpts += n
@@ -185,6 +318,9 @@ func (c *Client) Demux(carrierData []byte, frameOffset int) error {
if err != nil {
return err
}
if frm.MsgType() == MsgReconfigure {
return c.handleReconfigure(frm)
}
if frm.TransactionID() != c.xid {
return lneto.ErrMismatch
}
@@ -213,6 +349,41 @@ func (c *Client) Demux(carrierData []byte, frameOffset int) error {
return nil
}
func (c *Client) handleReconfigure(frm Frame) error {
if !c.state.HasIP() {
return lneto.ErrPacketDrop
}
var clientOK, serverOK bool
var reconf MsgType
err := frm.ForEachOption(func(_ int, code OptCode, data []byte) error {
switch code {
case OptClientID:
clientOK = internal.BytesEqual(data, c.duid)
case OptServerID:
serverOK = len(c.serverDUID) == 0 || internal.BytesEqual(data, c.serverDUID)
case OptReconfMsg:
if len(data) != 1 {
return lneto.ErrInvalidLengthField
}
reconf = MsgType(data[0])
}
return nil
})
if err != nil {
return err
}
if !clientOK || !serverOK {
return lneto.ErrMismatch
}
c.reconfigureMsg = reconf
c.reconfigureOK = true
if reconf != MsgRenew {
return lneto.ErrUnsupported
}
c.state = StateRenewing
return nil
}
// setOptions parses all DHCPv6 options in frm and stores relevant values.
func (c *Client) setOptions(frm Frame) error {
return frm.ForEachOption(func(_ int, code OptCode, data []byte) error {
@@ -221,18 +392,121 @@ func (c *Client) setOptions(frm Frame) error {
c.serverDUID = append(c.serverDUID[:0], data...)
case OptIANA:
c.parseIANA(data)
case OptIAPD:
c.parseIAPD(data)
case OptDNSServers:
if len(c.dns) > 0 || len(data)%16 != 0 {
break // skip if already populated or malformed
}
for i := 0; i+16 <= len(data); i += 16 {
for i := 0; i+16 <= len(data) && len(c.dns) < c.limits.MaxDNSServers; i += 16 {
c.dns = append(c.dns, netip.AddrFrom16([16]byte(data[i:i+16])))
}
case OptDomainList:
c.parseDomainSearch(data)
case OptNTPServer:
c.parseNTPServer(data)
}
return nil
})
}
func (c *Client) parseIAPD(data []byte) {
if len(c.delegatedPrefixes) > 0 {
return
}
if len(data) < 12 {
return
}
t1 := binary.BigEndian.Uint32(data[4:8])
t2 := binary.BigEndian.Uint32(data[8:12])
for ptr := 12; ptr+4 <= len(data); {
subCode := OptCode(binary.BigEndian.Uint16(data[ptr:]))
subLen := int(binary.BigEndian.Uint16(data[ptr+2:]))
if ptr+4+subLen > len(data) {
break
}
if subCode == OptIAPrefix && subLen >= 25 && len(c.delegatedPrefixes) < c.limits.MaxDelegatedPrefixes {
sub := data[ptr+4 : ptr+4+subLen]
bits := int(sub[8])
prefix := netip.PrefixFrom(netip.AddrFrom16([16]byte(sub[9:25])), bits)
if prefix.IsValid() {
c.delegatedPrefixes = append(c.delegatedPrefixes, DelegatedPrefix{
Prefix: prefix.Masked(),
PreferredLifetime: binary.BigEndian.Uint32(sub[:4]),
ValidLifetime: binary.BigEndian.Uint32(sub[4:8]),
})
}
}
ptr += 4 + subLen
}
if len(c.delegatedPrefixes) > 0 {
c.pdT1 = t1
c.pdT2 = t2
}
}
func (c *Client) parseNTPServer(data []byte) {
if len(c.ntps) > 0 || len(c.ntpMulticast) > 0 || len(c.ntpNames) > 0 {
return
}
for ptr := 0; ptr+4 <= len(data); {
subCode := binary.BigEndian.Uint16(data[ptr:])
subLen := int(binary.BigEndian.Uint16(data[ptr+2:]))
if ptr+4+subLen > len(data) {
break
}
subData := data[ptr+4 : ptr+4+subLen]
switch subCode {
case 1:
if subLen == 16 && len(c.ntps) < c.limits.MaxNTPServers {
c.ntps = append(c.ntps, netip.AddrFrom16([16]byte(data[ptr+4:ptr+20])))
}
case 2:
if subLen == 16 && len(c.ntpMulticast) < c.limits.MaxNTPMulticastServers {
c.ntpMulticast = append(c.ntpMulticast, netip.AddrFrom16([16]byte(data[ptr+4:ptr+20])))
}
case 3:
idx := len(c.ntpNames)
if idx >= c.limits.MaxNTPServerNames {
break // suboption switch: cap reached, drop further names.
}
if idx < cap(c.ntpNames) {
c.ntpNames = c.ntpNames[:idx+1]
} else {
c.ntpNames = append(c.ntpNames, dns.Name{})
}
next, err := c.ntpNames[idx].Decode(subData, 0)
if err != nil || next != uint16(len(subData)) {
c.ntpNames[idx].Reset()
c.ntpNames = c.ntpNames[:idx]
}
}
ptr += 4 + subLen
}
}
func (c *Client) parseDomainSearch(data []byte) {
if len(c.domainSearch) > 0 {
return
}
for off := uint16(0); off < uint16(len(data)) && len(c.domainSearch) < c.limits.MaxDomainSearch; {
idx := len(c.domainSearch)
if idx < cap(c.domainSearch) {
c.domainSearch = c.domainSearch[:idx+1]
} else {
c.domainSearch = append(c.domainSearch, dns.Name{})
}
next, err := c.domainSearch[idx].Decode(data, off)
if err != nil || next <= off {
c.domainSearch[idx].Reset()
c.domainSearch = c.domainSearch[:idx]
return
}
off = next
}
}
// parseIANA processes the payload of an OptIANA option, extracting the
// assigned address and lease timers from any embedded OptIAAddr sub-option.
func (c *Client) parseIANA(data []byte) {
@@ -273,15 +547,70 @@ func (c *Client) isClosed() bool { return c.state == 0 || c.xid == 0 }
// State returns the current client state.
func (c *Client) State() ClientState { return c.state }
// LastReconfigure returns the last accepted Reconfigure message target.
func (c *Client) LastReconfigure() (MsgType, bool) { return c.reconfigureMsg, c.reconfigureOK }
// AssignedAddr returns the IPv6 address assigned by the server and whether it is valid.
func (c *Client) AssignedAddr() ([16]byte, bool) { return c.assignedAddr, c.assignedAddrValid }
// RebindingSeconds returns the IA_NA T2 timer from the server.
func (c *Client) RebindingSeconds() uint32 { return c.t2 }
// RenewalSeconds returns the IA_NA T1 timer from the server.
func (c *Client) RenewalSeconds() uint32 { return c.t1 }
// PreferredLifetimeSeconds returns the preferred lifetime of the assigned address.
func (c *Client) PreferredLifetimeSeconds() uint32 { return c.preferredLifetime }
// ValidLifetimeSeconds returns the valid lifetime of the assigned address.
func (c *Client) ValidLifetimeSeconds() uint32 { return c.validLifetime }
// PrefixDelegationRebindingSeconds returns the IA_PD T2 timer from the server.
func (c *Client) PrefixDelegationRebindingSeconds() uint32 { return c.pdT2 }
// PrefixDelegationRenewalSeconds returns the IA_PD T1 timer from the server.
func (c *Client) PrefixDelegationRenewalSeconds() uint32 { return c.pdT1 }
// AppendDelegatedPrefixes appends delegated IPv6 prefixes received from the server to dst.
func (c *Client) AppendDelegatedPrefixes(dst []DelegatedPrefix) []DelegatedPrefix {
return append(dst, c.delegatedPrefixes...)
}
// NumDelegatedPrefixes returns the number of delegated IPv6 prefixes received.
func (c *Client) NumDelegatedPrefixes() int { return len(c.delegatedPrefixes) }
// AppendDNSServers appends the DNS server addresses received from the server to dst.
func (c *Client) AppendDNSServers(dst []netip.Addr) []netip.Addr { return append(dst, c.dns...) }
// NumDNSServers returns the number of DNS server addresses received.
func (c *Client) NumDNSServers() int { return len(c.dns) }
// AppendDomainSearch appends the DNS domain search names received from the server to dst.
func (c *Client) AppendDomainSearch(dst []dns.Name) []dns.Name { return append(dst, c.domainSearch...) }
// NumDomainSearch returns the number of DNS domain search names received.
func (c *Client) NumDomainSearch() int { return len(c.domainSearch) }
// AppendNTPServers appends the NTP server addresses received from the server to dst.
func (c *Client) AppendNTPServers(dst []netip.Addr) []netip.Addr { return append(dst, c.ntps...) }
// NumNTPServers returns the number of NTP server addresses received.
func (c *Client) NumNTPServers() int { return len(c.ntps) }
// AppendNTPMulticastServers appends NTP multicast addresses received from the server to dst.
func (c *Client) AppendNTPMulticastServers(dst []netip.Addr) []netip.Addr {
return append(dst, c.ntpMulticast...)
}
// NumNTPMulticastServers returns the number of NTP multicast addresses received.
func (c *Client) NumNTPMulticastServers() int { return len(c.ntpMulticast) }
// AppendNTPServerNames appends NTP server FQDNs received from the server to dst.
func (c *Client) AppendNTPServerNames(dst []dns.Name) []dns.Name { return append(dst, c.ntpNames...) }
// NumNTPServerNames returns the number of NTP server FQDNs received.
func (c *Client) NumNTPServerNames() int { return len(c.ntpNames) }
// ConnectionID returns a pointer to the client's connection ID.
// The value increments on each reset; callers should discard registrations when it changes.
// Implements [lneto.StackNode].
+476
View File
@@ -2,7 +2,10 @@ package dhcpv6
import (
"encoding/binary"
"net/netip"
"testing"
"github.com/soypat/lneto/dns"
)
// writeOpt6 encodes a single DHCPv6 option into dst using the 4-byte TLV header
@@ -124,6 +127,9 @@ func TestClientSolicitRequest(t *testing.T) {
if n == 0 {
t.Fatal("Encapsulate (Solicit): wrote 0 bytes")
}
if !frameHasOption(t, buf[:n], OptReconfAccept) {
t.Fatal("Solicit missing Reconfigure Accept option")
}
if cl.State() != StateSoliciting {
t.Fatalf("after Solicit: want StateSoliciting, got %v", cl.State())
}
@@ -164,6 +170,291 @@ func TestClientSolicitRequest(t *testing.T) {
}
}
func TestClientReconfigureRenew(t *testing.T) {
const xid = 0x112233
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
var cl Client
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
t.Fatal(err)
}
var buf [1024]byte
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
t.Fatal(err)
}
if err := cl.Demux(buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr), 0); err != nil {
t.Fatal(err)
}
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
t.Fatal(err)
}
if err := cl.Demux(buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), 0); err != nil {
t.Fatal(err)
}
if cl.State() != StateBound {
t.Fatalf("state before reconfigure = %v, want %v", cl.State(), StateBound)
}
reconf := buildReconfigureFrame(0x445566, serverDUID, cl.duid, MsgRenew)
if err := cl.Demux(reconf, 0); err != nil {
t.Fatal(err)
}
if cl.State() != StateRenewing {
t.Fatalf("state after reconfigure = %v, want %v", cl.State(), StateRenewing)
}
msg, ok := cl.LastReconfigure()
if !ok || msg != MsgRenew {
t.Fatalf("LastReconfigure = %v, %v, want %v, true", msg, ok, MsgRenew)
}
n, err := cl.Encapsulate(buf[:], -1, 0)
if err != nil {
t.Fatal(err)
}
frm, err := NewFrame(buf[:n])
if err != nil {
t.Fatal(err)
}
if frm.MsgType() != MsgRenew {
t.Fatalf("Encapsulate after Reconfigure type = %v, want %v", frm.MsgType(), MsgRenew)
}
}
func buildReconfigureFrame(xid uint32, serverDUID, clientDUID []byte, msg MsgType) []byte {
buf := make([]byte, 128)
buf[0] = byte(MsgReconfigure)
buf[1] = byte(xid >> 16)
buf[2] = byte(xid >> 8)
buf[3] = byte(xid)
n := OptionsOffset
n += writeOpt6(buf[n:], OptServerID, serverDUID...)
n += writeOpt6(buf[n:], OptClientID, clientDUID...)
n += writeOpt6(buf[n:], OptReconfMsg, byte(msg))
return buf[:n]
}
func frameHasOption(t testing.TB, b []byte, want OptCode) bool {
t.Helper()
frm, err := NewFrame(b)
if err != nil {
t.Fatal(err)
}
found := false
if err := frm.ForEachOption(func(_ int, code OptCode, _ []byte) error {
if code == want {
found = true
}
return nil
}); err != nil {
t.Fatal(err)
}
return found
}
func TestClientParsesNTPServerOption(t *testing.T) {
const xid = 0x102030
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
ntpAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x7b}
ntpMulticast := [16]byte{0xff, 0x05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0x01}
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
var cl Client
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
t.Fatal("BeginRequest:", err)
}
var buf [1024]byte
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
t.Fatal("Encapsulate (Solicit):", err)
}
advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr)
if err := cl.Demux(advFrame, 0); err != nil {
t.Fatal("Demux (Advertise):", err)
}
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
t.Fatal("Encapsulate (Request):", err)
}
replyFrame := appendNTPServerOption(t, buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), ntpAddr, ntpMulticast, "time.example.com")
if err := cl.Demux(replyFrame, 0); err != nil {
t.Fatal("Demux (Reply):", err)
}
var ntps []netip.Addr
ntps = cl.AppendNTPServers(ntps)
if len(ntps) != 1 || ntps[0] != netip.AddrFrom16(ntpAddr) {
t.Fatalf("AppendNTPServers = %v, want %v", ntps, netip.AddrFrom16(ntpAddr))
}
if n := cl.NumNTPServers(); n != 1 {
t.Fatalf("NumNTPServers = %d, want 1", n)
}
var multicasts []netip.Addr
multicasts = cl.AppendNTPMulticastServers(multicasts)
if len(multicasts) != 1 || multicasts[0] != netip.AddrFrom16(ntpMulticast) {
t.Fatalf("AppendNTPMulticastServers = %v, want %v", multicasts, netip.AddrFrom16(ntpMulticast))
}
if n := cl.NumNTPMulticastServers(); n != 1 {
t.Fatalf("NumNTPMulticastServers = %d, want 1", n)
}
var names []dns.Name
names = cl.AppendNTPServerNames(names)
if len(names) != 1 || !names[0].EqualString("time.example.com") {
t.Fatalf("AppendNTPServerNames = %v, want time.example.com", names)
}
if n := cl.NumNTPServerNames(); n != 1 {
t.Fatalf("NumNTPServerNames = %d, want 1", n)
}
}
func TestClientParsesDomainSearchOption(t *testing.T) {
const xid = 0x203040
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
var cl Client
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
t.Fatal("BeginRequest:", err)
}
var buf [1024]byte
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
t.Fatal("Encapsulate (Solicit):", err)
}
advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr)
if err := cl.Demux(advFrame, 0); err != nil {
t.Fatal("Demux (Advertise):", err)
}
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
t.Fatal("Encapsulate (Request):", err)
}
replyFrame := appendDomainSearchOption(t, buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), "example.com", "corp.example.com")
if err := cl.Demux(replyFrame, 0); err != nil {
t.Fatal("Demux (Reply):", err)
}
var domains []dns.Name
domains = cl.AppendDomainSearch(domains)
if len(domains) != 2 {
t.Fatalf("AppendDomainSearch len = %d, want 2", len(domains))
}
if !domains[0].EqualString("example.com") || !domains[1].EqualString("corp.example.com") {
t.Fatalf("AppendDomainSearch = %q, %q", domains[0].String(), domains[1].String())
}
if n := cl.NumDomainSearch(); n != 2 {
t.Fatalf("NumDomainSearch = %d, want 2", n)
}
}
func TestClientParsesDelegatedPrefix(t *testing.T) {
const xid = 0x304050
clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
delegated := netip.MustParsePrefix("2001:db8:abcd::/48")
iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]}
var cl Client
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil {
t.Fatal("BeginRequest:", err)
}
var buf [1024]byte
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
t.Fatal("Encapsulate (Solicit):", err)
}
advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr)
if err := cl.Demux(advFrame, 0); err != nil {
t.Fatal("Demux (Advertise):", err)
}
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
t.Fatal("Encapsulate (Request):", err)
}
replyFrame := appendIAPDOption(buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), iaid, delegated)
if err := cl.Demux(replyFrame, 0); err != nil {
t.Fatal("Demux (Reply):", err)
}
var prefixes []DelegatedPrefix
prefixes = cl.AppendDelegatedPrefixes(prefixes)
if len(prefixes) != 1 {
t.Fatalf("AppendDelegatedPrefixes len = %d, want 1", len(prefixes))
}
if prefixes[0].Prefix != delegated || prefixes[0].PreferredLifetime != 1800 || prefixes[0].ValidLifetime != 3600 {
t.Fatalf("delegated prefix = %+v, want %s preferred=1800 valid=3600", prefixes[0], delegated)
}
if n := cl.NumDelegatedPrefixes(); n != 1 {
t.Fatalf("NumDelegatedPrefixes = %d, want 1", n)
}
if cl.PrefixDelegationRenewalSeconds() != 900 || cl.PrefixDelegationRebindingSeconds() != 1800 {
t.Fatalf("IA_PD timers = renewal:%d rebind:%d, want 900/1800", cl.PrefixDelegationRenewalSeconds(), cl.PrefixDelegationRebindingSeconds())
}
}
func appendNTPServerOption(t testing.TB, frame []byte, addr, multicast [16]byte, fqdn string) []byte {
t.Helper()
var ntpPayload []byte
ntpPayload = appendNTPServerAddrSuboption(ntpPayload, 1, addr)
ntpPayload = appendNTPServerAddrSuboption(ntpPayload, 2, multicast)
name, err := dns.NewName(fqdn)
if err != nil {
t.Fatal(err)
}
nameStart := len(ntpPayload) + 4
ntpPayload = append(ntpPayload, 0, 3, 0, 0)
ntpPayload, err = name.AppendTo(ntpPayload)
if err != nil {
t.Fatal(err)
}
binary.BigEndian.PutUint16(ntpPayload[nameStart-2:nameStart], uint16(len(ntpPayload)-nameStart))
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(ntpPayload))...)
writeOpt6(buf[len(frame):], OptNTPServer, ntpPayload...)
return buf
}
func appendNTPServerAddrSuboption(dst []byte, code uint16, addr [16]byte) []byte {
start := len(dst)
dst = append(dst, 0, 0, 0, 16)
binary.BigEndian.PutUint16(dst[start:start+2], code)
return append(dst, addr[:]...)
}
func appendDomainSearchOption(t testing.TB, frame []byte, domains ...string) []byte {
t.Helper()
var payload []byte
for _, domain := range domains {
name, err := dns.NewName(domain)
if err != nil {
t.Fatal(err)
}
payload, err = name.AppendTo(payload)
if err != nil {
t.Fatal(err)
}
}
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
writeOpt6(buf[len(frame):], OptDomainList, payload...)
return buf
}
func appendIAPDOption(frame []byte, iaid [4]byte, prefix netip.Prefix) []byte {
var iaPrefix [29]byte
binary.BigEndian.PutUint16(iaPrefix[0:2], uint16(OptIAPrefix))
binary.BigEndian.PutUint16(iaPrefix[2:4], 25)
binary.BigEndian.PutUint32(iaPrefix[4:8], 1800)
binary.BigEndian.PutUint32(iaPrefix[8:12], 3600)
iaPrefix[12] = byte(prefix.Bits())
prefixAddr := prefix.Addr().As16()
copy(iaPrefix[13:], prefixAddr[:])
var iapd [41]byte
copy(iapd[:4], iaid[:])
binary.BigEndian.PutUint32(iapd[4:8], 900)
binary.BigEndian.PutUint32(iapd[8:12], 1800)
copy(iapd[12:], iaPrefix[:])
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(iapd))...)
writeOpt6(buf[len(frame):], OptIAPD, iapd[:]...)
return buf
}
// TestClientEncapsulateSolicit verifies that the first Encapsulate call
// writes a Solicit message with at least OptClientID and OptIANA.
func TestClientEncapsulateSolicit(t *testing.T) {
@@ -242,3 +533,188 @@ func TestClientDoubleTapEncapsulate(t *testing.T) {
t.Errorf("second Encapsulate: want 0 bytes (idempotent), got %d", n2)
}
}
// driveToRequesting advances a fresh client through Solicit→Advertise→Request so
// the next Demux of a Reply runs the option-parsing path.
func driveToRequesting(t testing.TB, cl *Client, xid uint32, serverDUID []byte, iaid [4]byte, addr [16]byte) {
t.Helper()
var buf [1024]byte
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
t.Fatal("Encapsulate (Solicit):", err)
}
if err := cl.Demux(buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, addr), 0); err != nil {
t.Fatal("Demux (Advertise):", err)
}
if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil {
t.Fatal("Encapsulate (Request):", err)
}
}
// floodDNSServersOption appends an OptDNSServers carrying n distinct addresses.
func floodDNSServersOption(frame []byte, n int) []byte {
payload := make([]byte, 0, n*16)
for i := range n {
var a [16]byte
a[0], a[1], a[15] = 0x20, 0x01, byte(i+1)
payload = append(payload, a[:]...)
}
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
writeOpt6(buf[len(frame):], OptDNSServers, payload...)
return buf
}
// floodIAPDOption appends an OptIAPD carrying n OptIAPrefix sub-options.
func floodIAPDOption(frame []byte, iaid [4]byte, n int) []byte {
payload := make([]byte, 12)
copy(payload[:4], iaid[:])
binary.BigEndian.PutUint32(payload[4:8], 900)
binary.BigEndian.PutUint32(payload[8:12], 1800)
for i := range n {
var iaPrefix [29]byte
binary.BigEndian.PutUint16(iaPrefix[0:2], uint16(OptIAPrefix))
binary.BigEndian.PutUint16(iaPrefix[2:4], 25)
binary.BigEndian.PutUint32(iaPrefix[4:8], 1800)
binary.BigEndian.PutUint32(iaPrefix[8:12], 3600)
iaPrefix[12] = 48
iaPrefix[13], iaPrefix[14], iaPrefix[15] = 0x20, 0x01, byte(i+1)
payload = append(payload, iaPrefix[:]...)
}
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
writeOpt6(buf[len(frame):], OptIAPD, payload...)
return buf
}
// floodNTPOption appends an OptNTPServer carrying nAddr unicast (suboption 1),
// nMulticast (suboption 2) and nNames FQDN (suboption 3) entries.
func floodNTPOption(t testing.TB, frame []byte, nAddr, nMulticast, nNames int) []byte {
t.Helper()
var payload []byte
for i := range nAddr {
var a [16]byte
a[0], a[15] = 0x20, byte(i+1)
payload = appendNTPServerAddrSuboption(payload, 1, a)
}
for i := range nMulticast {
var a [16]byte
a[0], a[1], a[15] = 0xff, 0x05, byte(i+1)
payload = appendNTPServerAddrSuboption(payload, 2, a)
}
name, err := dns.NewName("ntp.example.com")
if err != nil {
t.Fatal(err)
}
for range nNames {
start := len(payload) + 4
payload = append(payload, 0, 3, 0, 0)
payload, err = name.AppendTo(payload)
if err != nil {
t.Fatal(err)
}
binary.BigEndian.PutUint16(payload[start-2:start], uint16(len(payload)-start))
}
buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...)
writeOpt6(buf[len(frame):], OptNTPServer, payload...)
return buf
}
func repeatStrings(s string, n int) []string {
out := make([]string, n)
for i := range out {
out[i] = s
}
return out
}
// TestClientLimitsCapServerData verifies that the client stores no more than
// the configured number of each repeated option, so a server flooding a Reply
// cannot drive unbounded allocation (the OOM concern raised in the PR review).
func TestClientLimitsCapServerData(t *testing.T) {
const xid = 0x515151
mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
iaid := [4]byte{mac[0], mac[1], mac[2], mac[3]}
addr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
limits := Limits{
MaxDNSServers: 2, MaxDomainSearch: 2, MaxNTPServers: 2,
MaxNTPMulticastServers: 1, MaxNTPServerNames: 2, MaxDelegatedPrefixes: 2,
}
var cl Client
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: mac, Limits: limits}); err != nil {
t.Fatal("BeginRequest:", err)
}
driveToRequesting(t, &cl, xid, serverDUID, iaid, addr)
// A Reply far exceeding every configured cap.
reply := buildServerFrame(MsgReply, xid, serverDUID, iaid, addr)
reply = floodDNSServersOption(reply, 8)
reply = floodIAPDOption(reply, iaid, 8)
reply = appendDomainSearchOption(t, reply, repeatStrings("example.com", 8)...)
reply = floodNTPOption(t, reply, 8, 8, 8)
if err := cl.Demux(reply, 0); err != nil {
t.Fatal("Demux (Reply):", err)
}
for _, tc := range []struct {
name string
got int
want int
}{
{"DNS servers", cl.NumDNSServers(), limits.MaxDNSServers},
{"domain search", cl.NumDomainSearch(), limits.MaxDomainSearch},
{"NTP servers", cl.NumNTPServers(), limits.MaxNTPServers},
{"NTP multicast", cl.NumNTPMulticastServers(), limits.MaxNTPMulticastServers},
{"NTP names", cl.NumNTPServerNames(), limits.MaxNTPServerNames},
{"delegated prefixes", cl.NumDelegatedPrefixes(), limits.MaxDelegatedPrefixes},
} {
if tc.got != tc.want {
t.Errorf("%s stored = %d, want capped at %d", tc.name, tc.got, tc.want)
}
}
}
// TestClientParseNoAllocs verifies that once BeginRequest has sized the option
// backing arrays, parsing a server message performs no further allocation: the
// bounded slices are reused, keeping the network-facing path off the heap.
func TestClientParseNoAllocs(t *testing.T) {
const xid = 0x123456
mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}
iaid := [4]byte{mac[0], mac[1], mac[2], mac[3]}
addr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
var cl Client
if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: mac}); err != nil {
t.Fatal("BeginRequest:", err)
}
reply := buildServerFrame(MsgReply, xid, serverDUID, iaid, addr)
reply = floodDNSServersOption(reply, cl.limits.MaxDNSServers)
reply = floodIAPDOption(reply, iaid, cl.limits.MaxDelegatedPrefixes)
reply = appendDomainSearchOption(t, reply, repeatStrings("example.com", cl.limits.MaxDomainSearch)...)
reply = floodNTPOption(t, reply, cl.limits.MaxNTPServers, cl.limits.MaxNTPMulticastServers, cl.limits.MaxNTPServerNames)
frm, err := NewFrame(reply)
if err != nil {
t.Fatal("NewFrame:", err)
}
clearStores := func() {
cl.serverDUID = cl.serverDUID[:0]
cl.dns = cl.dns[:0]
cl.domainSearch = cl.domainSearch[:0]
cl.ntps = cl.ntps[:0]
cl.ntpMulticast = cl.ntpMulticast[:0]
cl.ntpNames = cl.ntpNames[:0]
cl.delegatedPrefixes = cl.delegatedPrefixes[:0]
}
clearStores()
if err := cl.setOptions(frm); err != nil { // warm up the dns.Name backing arrays.
t.Fatal("setOptions:", err)
}
allocs := testing.AllocsPerRun(50, func() {
clearStores()
_ = cl.setOptions(frm)
})
if allocs != 0 {
t.Errorf("setOptions must not allocate after BeginRequest sizing, got %v allocs/op", allocs)
}
}
+1
View File
@@ -24,6 +24,7 @@ const (
ErrAlreadyRegistered // protocol already registered
ErrTruncatedFrame // truncated frame
ErrMissingHALConfig // missing HAL configuration
ErrBadState // operation invalid in current state
// Below are potentially good future error additions
// based on one or two encountered use cases, example use case included.
/*
@@ -83,7 +83,17 @@ func main() {
var runner netdev.Runner[espradio.STAConfig]
go func() {
if err := runner.Run(context.Background(), iface, &stack, backoff); err != nil {
// EthPoll drains the C ring buffer and delivers frames through the
// receive handler, so the device is async but still needs the pump.
err := runner.Configure(netdev.RunnerConfig[espradio.STAConfig]{
Buffers: iface.RunnerBuffers(2),
Backoff: backoff,
Flags: netdev.RunnerInterfaceAsync | netdev.RunnerInterfacePoll,
})
if err != nil {
failIfErr("runnerconfig", err)
}
if err := runner.Run(context.Background(), &iface, &stack); err != nil {
failIfErr("runner", err)
}
}()
@@ -2,10 +2,12 @@ module piconetdev
go 1.25.7
require github.com/soypat/cyw43439 v0.1.1
require (
github.com/soypat/cyw43439 v0.1.1
github.com/soypat/lneto v0.1.1-0.20260425023453-aa77403a2b32
)
require (
github.com/soypat/lneto v0.1.0 // indirect
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect
github.com/tinygo-org/pio v0.2.0 // indirect
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect
@@ -13,4 +15,7 @@ require (
// This is an example taken grom github.com/soypat/lneto
// Remove this replace directive when using as own program.
replace github.com/soypat/lneto => ../../../.
replace github.com/soypat/lneto => ../../../.
// Local cyw43439 with the poll-based EthPoll API.
replace github.com/soypat/cyw43439 => ../../../../cyw43439
@@ -1,7 +1,3 @@
github.com/soypat/cyw43439 v0.1.1 h1:vcaTiVzfuz3keK7lJpVxStZ6tV8HCw7Ugzsh1k4mneE=
github.com/soypat/cyw43439 v0.1.1/go.mod h1:R2uSILRwSPmcmmKy5Z0FtK4ypgiPf5YqK+F+IKmXqxc=
github.com/soypat/lneto v0.1.0 h1:VAHCJ33hvC3wDqhM0Vm7w0k6vwNsOCAsQ8XTrXJpS7I=
github.com/soypat/lneto v0.1.0/go.mod h1:g/8Lk+hIsMZydyWDJjK2YfsCuG6jA5mWCO6U+4S7w1U=
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 h1:Y9fBuiR/urFY/m76+SAZTxk2xAOS2n85f+H1CugajeA=
github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8=
github.com/tinygo-org/pio v0.2.0 h1:vo3xa6xDZ2rVtxrks/KcTZHF3qq4lyWOntvEvl2pOhU=
+21 -9
View File
@@ -70,14 +70,25 @@ func main() {
failIfErr("init iface", err)
go func() {
if err := runner.Run(context.Background(), iface, &stack, backoff); err != nil {
err := runner.Configure(netdev.RunnerConfig[ConnectParams]{
Buffers: iface.RunnerBuffers(1),
Backoff: backoff,
Flags: netdev.RunnerInterfaceAsync | netdev.RunnerInterfacePoll,
})
if err != nil {
failIfErr("runnerconfig", err)
}
if err := runner.Run(context.Background(), &iface, &stack); err != nil {
failIfErr("runner", err)
}
}()
assigned, gatewayRt, subnetBits, err := stack.EnableDHCP(context.Background(), true, netip.Addr{})
failIfErr("enable dhcp", err)
println("assigned=", assigned.String(), "gateway=", gatewayRt.String(), "subnet", subnetBits)
select {}
for {
runner.PrintDebug()
time.Sleep(2 * time.Second)
}
}
// compile-time guarantee of interface implementation.
@@ -142,23 +153,24 @@ func (d *Netdev) SendOffsetEthFrame(offsetTxEthFrame []byte) error {
}
// SetRecvHandler implements [netdev.DevEthernet].
//
// The cyw43439 delivers received Ethernet frames through this callback whenever
// the bus is serviced (including ioctl/control transactions outside EthPoll), so
// the runner must drive it in async mode. EthPoll is still used to pump the device.
func (d *Netdev) SetEthRecvHandler(handler func(rxEthframe []byte)) {
d.dev.RecvEthHandle(func(pkt []byte) error {
handler(pkt)
return nil
})
d.dev.RecvEthHandle(handler)
}
// EthPoll implements [netdev.DevEthernet].
func (d *Netdev) EthPoll(buf []byte) (ethFrameOff, ethernetBytes int, err error) {
_, err = d.dev.PollOne()
return 0, 0, err
return d.dev.EthPoll(buf)
}
// MaxFrameSizeAndOffset implements [netdev.DevEthernet].
func (d *Netdev) MaxFrameSizeAndOffset() (maxFrameSize int, frameOff int) {
return cyw43439.MaxFrameSize, 0
return 2048, 0
}
func failIfErr(msg string, err error) {
if err != nil {
fail(msg, err)
+96
View File
@@ -0,0 +1,96 @@
//go:build !tinygo && linux
package main
import (
"net/netip"
"syscall"
)
// Conn wraps an accepted TCP connection from a raw Linux socket file descriptor.
// It implements io.Reader/io.Writer/io.Closer over syscall.Read/Write/Close.
type Conn struct {
fd int
remote netip.AddrPort
}
// Read reads bytes from the connection into b.
func (c *Conn) Read(b []byte) (int, error) {
if len(b) == 0 {
return 0, nil
}
n, err := syscall.Read(c.fd, b)
if err != nil {
return 0, err
}
if n == 0 {
return 0, syscall.ECONNRESET // Peer closed.
}
return n, nil
}
// Write writes b to the connection, looping until all bytes are sent.
func (c *Conn) Write(b []byte) (int, error) {
total := 0
for total < len(b) {
n, err := syscall.Write(c.fd, b[total:])
if err != nil {
return total, err
}
total += n
}
return total, nil
}
// Close closes the underlying file descriptor.
func (c *Conn) Close() error {
return syscall.Close(c.fd)
}
// RemoteAddr returns the peer address of the connection.
func (c *Conn) RemoteAddr() netip.AddrPort { return c.remote }
// Listener wraps a listening TCP socket bound to a local port.
type Listener struct {
fd int
}
// Listen creates a listening TCP socket bound to port on all interfaces.
func Listen(port uint16) (*Listener, error) {
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, syscall.IPPROTO_TCP)
if err != nil {
return nil, err
}
// Allow quick rebind after restart.
if err = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); err != nil {
syscall.Close(fd)
return nil, err
}
addr := &syscall.SockaddrInet4{Port: int(port)}
if err = syscall.Bind(fd, addr); err != nil {
syscall.Close(fd)
return nil, err
}
if err = syscall.Listen(fd, syscall.SOMAXCONN); err != nil {
syscall.Close(fd)
return nil, err
}
return &Listener{fd: fd}, nil
}
// Accept blocks until an incoming connection arrives and returns it as a Conn.
func (l *Listener) Accept(conn *Conn) error {
nfd, sa, err := syscall.Accept(l.fd)
if err != nil {
return err
}
conn.fd = nfd
if sa4, ok := sa.(*syscall.SockaddrInet4); ok {
conn.remote = netip.AddrPortFrom(netip.AddrFrom4(sa4.Addr), uint16(sa4.Port))
}
return nil
}
// Close closes the listening socket.
func (l *Listener) Close() error { return syscall.Close(l.fd) }
+107
View File
@@ -0,0 +1,107 @@
//go:build !tinygo && linux
package main
import (
"os"
"strconv"
"sync/atomic"
"time"
"github.com/soypat/lneto/http/httpraw"
)
const listenPort = 8080
func main() {
if err := run(); err != nil {
println("Error: ", err)
os.Exit(1)
}
println("DONE")
}
func run() error {
ln, err := Listen(listenPort)
if err != nil {
return err
}
defer ln.Close()
println("listening on port", listenPort)
conn := new(Conn)
for {
err := ln.Accept(conn)
if err != nil {
return err
}
visits.Add(1)
if err := handle(conn); err != nil {
println("handle:", conn.RemoteAddr().String(), err.Error())
}
conn.Close()
}
}
const (
htmlHead = `<html><body bgcolor="#000080" text="#00FF00"><center>` +
`<marquee><font face="Comic Sans MS" size="5" color="#FFFF00">` +
`*** WELCOME TO MY HOMEPAGE ***</font></marquee>` +
`<h1><blink>YOU ARE VISITOR #`
htmlTail = `!</blink></h1>` +
`<font color="#FF00FF">Sign my guestbook!</font>` +
`<br><hr>Best viewed in Netscape Navigator</center></body></html>`
)
const maxHTTPHeader = 1024
var (
hdr httpraw.Header
httpbuf [maxHTTPHeader]byte
htmlbuf [512]byte
visits atomic.Uint64
)
func handle(conn *Conn) error {
hdr.Reset(httpbuf[:0])
hdr.EnableBufferGrowth(false) // Limit memory to buffer capacity.
const incomingIsResponse = false // We get HTTP requests from clients.
deadline := time.Now().Add(50 * time.Millisecond)
for time.Until(deadline) > 0 {
if _, err := hdr.ReadFromLimited(conn, maxHTTPHeader); err != nil {
return err
}
needmoredata, err := hdr.TryParse(incomingIsResponse)
if err != nil {
return err
}
if needmoredata {
continue
}
break
}
println("\n\n================\n\n", hdr.String())
if time.Since(deadline) > 0 {
print("DEADLINE EXCEED: ", hdr.BufferParsed(), "/", hdr.BufferReceived(), " bytes parsed/read\n")
return nil
}
// Prepare tacky HTML response.
n := copy(htmlbuf[:], htmlHead)
n += len(strconv.AppendUint(htmlbuf[n:n], visits.Load(), 10))
n += copy(htmlbuf[n:], htmlTail)
contentLen := n
hdr.Reset(httpbuf[:0])
hdr.SetProtocol("HTTP/1.1")
hdr.SetStatus("200", "OK")
hdr.Set("Content-Type", "text/html")
hdr.SetInt("Content-Length", int64(contentLen), 10)
// Here we do some buffer juggling. We use remaining space
// of HTTP Header buffer to write the response that will be written over the wire.
respbuf := httpbuf[hdr.BufferUsed():]
header, err := hdr.AppendResponse(respbuf[:0])
if err != nil {
return err
}
conn.Write(header)
_, err = conn.Write(htmlbuf[:contentLen])
return err
}
+27 -2
View File
@@ -50,6 +50,7 @@ func run() (err error) {
flagUseHTTP = false
flagHostToResolve = ""
flagRequestedIP = ""
flagLocalTCPPort = 0
flagDoNTP = false
flagNoPcap = false
flagPprof = false
@@ -58,6 +59,7 @@ func run() (err error) {
flag.BoolVar(&flagUseHTTP, "ihttp", flagUseHTTP, "Use HTTP tap interface.")
flag.StringVar(&flagHostToResolve, "host", flagHostToResolve, "Hostname to resolve via DNS.")
flag.StringVar(&flagRequestedIP, "addr", flagRequestedIP, "IP address to request via DHCP.")
flag.IntVar(&flagLocalTCPPort, "port", flagLocalTCPPort, "Local TCP source port to use. Zero chooses a pseudo-random port.")
flag.BoolVar(&flagDoNTP, "ntp", flagDoNTP, "Do NTP round and print result time")
flag.BoolVar(&flagNoPcap, "nopcap", flagNoPcap, "Disable pcap logging.")
flag.BoolVar(&flagPprof, "pprof", flagPprof, "Enable CPU profiling.")
@@ -79,6 +81,21 @@ func run() (err error) {
flag.Usage()
return err
}
reqAddr := [4]byte{192, 168, 1, 96}
requestedAddrSet := false
if flagRequestedIP != "" {
addr, err := netip.ParseAddr(flagRequestedIP)
if err != nil || !addr.Is4() {
flag.Usage()
return fmt.Errorf("invalid requested IPv4 address %q", flagRequestedIP)
}
reqAddr = addr.As4()
requestedAddrSet = true
}
if flagLocalTCPPort < 0 || flagLocalTCPPort > math.MaxUint16 {
flag.Usage()
return fmt.Errorf("invalid local TCP port %d", flagLocalTCPPort)
}
fmt.Println("softrand", softRand)
var iface ltesto.Interface
if flagUseHTTP {
@@ -223,11 +240,14 @@ func run() (err error) {
dhcpRetries = 2
)
timeDHCP := timer("DHCP request completed")
results, err := rstack.DoDHCPv4([4]byte{192, 168, 1, 96}, dhcpTimeout, dhcpRetries)
results, err := rstack.DoDHCPv4(reqAddr, dhcpTimeout, dhcpRetries)
if err != nil {
return fmt.Errorf("DHCP failed: %w", err)
}
timeDHCP()
if requestedAddrSet && results.AssignedAddr4 != reqAddr {
return fmt.Errorf("DHCP assigned %s, not requested %s", netip.AddrFrom4(results.AssignedAddr4), netip.AddrFrom4(reqAddr))
}
err = stack.AssimilateDHCPResults(results)
if err != nil {
@@ -302,7 +322,12 @@ func run() (err error) {
timeTCPDial := timer("TCP dial (handshake)")
const tcpDialTimeout = 8 * time.Second // Was 60 * time.Minute causing 3.6s sleep per iteration!
target := netip.AddrPortFrom(addrs[0], 80)
err = rstack.DoDialTCP(&conn, uint16(softRand&0xefff)+1024, target, tcpDialTimeout, internetRetries)
localPort := uint16(flagLocalTCPPort)
if localPort == 0 {
localPort = uint16(softRand&0xefff) + 1024
}
fmt.Printf("TCP target %s local-port=%d\n", target, localPort)
err = rstack.DoDialTCP(&conn, localPort, target, tcpDialTimeout, internetRetries)
if err != nil {
return fmt.Errorf("TCP failed: %w", err)
}
+44 -10
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"io"
"slices"
"unsafe"
)
const (
@@ -165,6 +164,7 @@ func (h *Header) ReadFromBytes(b []byte) (int, error) {
}
// BufferReceived returns the amoung of bytes read during calls to Read* methods.
// Returns 0 if buffer is invalid/mangled.
func (h *Header) BufferReceived() int {
if h.flags.hasAny(flagMangledBuffer | flagOOMReached) {
return 0
@@ -182,12 +182,23 @@ func (h *Header) BufferParsed() int {
return h.hbuf.off
}
// BufferUsed returns the raw memory used.
//
// BufferUsed + BufferFree == BufferCapacity
func (h *Header) BufferUsed() int {
return len(h.hbuf.buf)
}
// BufferFree returns amount of bytes free in underlying buffer.
//
// BufferUsed + BufferFree == BufferCapacity
func (h *Header) BufferFree() int {
return h.hbuf.free()
}
// BufferCapacity returns the total capacity of the underlying buffer.
//
// BufferUsed + BufferFree == BufferCapacity
func (h *Header) BufferCapacity() int {
return cap(h.hbuf.buf)
}
@@ -223,7 +234,7 @@ func (hb *headerBuf) forEach(cb func(key, value []byte) error) error {
// h.Reset(httpHeader); h.Parse() // Parse bytes in place with no copying.
// h.Reset(nil) // Reuse buffer previously set in a call to Reset.
func (h *Header) Reset(buf []byte) {
if h.flags.hasAny(flagNoBufferGrow) && len(buf) < 32 {
if h.flags.hasAny(flagNoBufferGrow) && cap(buf) < 32 {
panic("small buffer and flagNoBufferGrow set")
}
const persistentFlags = flagNoBufferGrow
@@ -250,18 +261,45 @@ func (h *Header) Body() ([]byte, error) {
// SetBytes is equivalent to [Header.Set] but with a []byte value. Does not keep reference to value slice.
// Calling SetBytes Mangles the buffer.
func (h *Header) SetBytes(key string, value []byte) {
h.Set(key, unsafe.String(&value[0], len(value)))
h.Set(key, b2s(value))
}
// SetInt is equivalent to [Header.Set] but with an integer value i.e: Content-Length header key.
// base must be in the range 2..36 (as accepted by [strconv.AppendInt]); other bases are dropped.
// SetInt formats the value directly into the header buffer without heap allocation.
func (h *Header) SetInt(key string, value int64, base int) {
if base < 2 || base > 36 {
return // strconv.AppendInt only supports base 2..36.
}
useKv := h.takeReusableSlot(key)
if useKv == nil {
h.appendHeaderInt(key, value, base)
} else {
useKv.value = h.reuseOrAppendInt(useKv.value, value, base)
}
}
// Set sets a key-value pair in the HTTP header.
// Calling Set mangles the buffer.
func (h *Header) Set(key, value string) {
useKv := h.takeReusableSlot(key)
if useKv == nil {
h.appendHeader(key, value)
} else {
useKv.value = h.reuseOrAppend(useKv.value, value)
}
}
// takeReusableSlot returns the valid key-value entry for key with the largest
// value buffer (best candidate for in-place reuse) and invalidates any other
// entries sharing the key. Returns nil if the key is not present.
func (h *Header) takeReusableSlot(key string) *argsKV {
hb := &h.hbuf
var useKv *argsKV
for i := len(hb.headers); len(hb.headers) > 0 && i <= 0; i++ {
for i := 0; i < len(hb.headers); i++ {
// Search for key-value with largest buffer for value to store value reusing buffer.
gotkv := &hb.headers[i]
if b2s(hb.musttoken(gotkv.key)) == key {
if gotkv.isValid() && b2s(hb.musttoken(gotkv.key)) == key {
if useKv == nil {
useKv = gotkv
} else if gotkv.value.len > useKv.value.len {
@@ -272,11 +310,7 @@ func (h *Header) Set(key, value string) {
}
}
}
if useKv == nil {
h.appendHeader(key, value)
} else {
useKv.value = h.reuseOrAppend(useKv.value, value)
}
return useKv
}
// Get gets the first value of a key found in the headers. Use [Header.ForEach] to find multiple values corresponding to same key.
+216 -1
View File
@@ -124,7 +124,7 @@ func BenchmarkParseBytes(b *testing.B) {
var hdr Header
b.StartTimer()
for i := 0; i < b.N; i++ {
for b.Loop() {
err := hdr.ParseBytes(asRequest, data)
if err != nil {
b.Fatal(err)
@@ -208,3 +208,218 @@ func TestCopyNormalizedHeaderValue(t *testing.T) {
}
}
}
func TestHeaderSetOverwrite(t *testing.T) {
var h Header
h.Reset(nil)
h.SetMethod("GET")
h.SetRequestURI("/")
h.SetProtocol("HTTP/1.1")
h.Set("Host", "first.example.com")
h.Set("Host", "second.example.com")
got := string(h.Get("Host"))
if got != "second.example.com" {
t.Errorf("want Host %q, got %q", "second.example.com", got)
}
req, err := h.AppendRequest(nil)
if err != nil {
t.Fatal(err)
}
if n := strings.Count(string(req), "Host:"); n != 1 {
t.Errorf("want 1 Host field in request, got %d:\n%s", n, req)
}
}
func TestHeaderSetBytesEmptyValue(t *testing.T) {
var h Header
h.Reset(nil)
h.SetBytes("X-Empty", nil)
if got := h.Get("X-Empty"); len(got) != 0 {
t.Errorf("want empty value, got %q", got)
}
}
// buffer/offset past uint16 range silently corrupts or panics.
// A header block > 64KiB pushes a field's offset past 65535. tokint truncation
// makes Get return the wrong window (silent corruption) or panic on slice bounds.
func TestHeader_LargeBufferOverflow(t *testing.T) {
const wantVal = "the-canary-value"
// Pad with a big header so the canary field lands past offset 65535.
pad := strings.Repeat("x", 70000)
raw := "GET / HTTP/1.1\r\n" +
"X-Pad: " + pad + "\r\n" +
"X-Canary: " + wantVal + "\r\n" +
"\r\n"
var h Header
err := h.ParseBytes(false, []byte(raw))
if err != nil {
// Clean rejection of the oversized header is the intended behavior:
// no panic, no silent corruption.
return
}
// If it did parse, the value must be correct (never a truncated-offset window).
got := string(h.Get("X-Canary"))
if got != wantVal {
t.Fatalf("overflow corruption: want X-Canary %q, got %q", wantVal, got)
}
}
// a complete but malformed header line with no colon must be a hard error,
// not errNeedMore (which makes a streaming parser wait forever).
func TestHeader_ColonlessLineIsHardError(t *testing.T) {
raw := "GET / HTTP/1.1\r\nBadHeaderNoColon\r\n\r\n"
var h Header
err := h.ParseBytes(false, []byte(raw))
if err == nil {
t.Fatal("want error on colonless header line, got nil")
}
if err == errNeedMore {
t.Fatalf("colonless line reported as errNeedMore (parser would hang); want a hard error like errInvalidName")
}
}
// Regression for a TCP split landing BEFORE the colon (no newline yet) must
// stay "need more data" and parse once the rest arrives. This is the case the
// Colonless fix must NOT turn into a hard error.
func TestHeader_SplitBeforeColonStillParses(t *testing.T) {
const part1 = "GET / HTTP/1.1\r\nHost" // split mid-key, before colon+newline
const part2 = ": example.com\r\n\r\n"
var h Header
h.Reset(nil)
if _, err := h.ReadFromBytes([]byte(part1)); err != nil {
t.Fatal(err)
}
needMore, err := h.TryParse(false)
if err != nil && err != errNeedMore {
t.Fatalf("split before colon: want errNeedMore/nil, got %v", err)
}
if !needMore {
t.Fatal("want needMoreData=true after partial input")
}
if _, err := h.ReadFromBytes([]byte(part2)); err != nil {
t.Fatal(err)
}
needMore, err = h.TryParse(false)
if err != nil {
t.Fatalf("after full input: %v", err)
}
if needMore {
t.Fatal("want needMoreData=false after full input")
}
if got := string(h.Get("Host")); got != "example.com" {
t.Fatalf("want Host %q, got %q", "example.com", got)
}
}
// appendHeader must reserve the +1 byte that mustAppendSlice consumes on an
// empty buffer. Force cap == len(key)+len(value) to defeat allocator rounding.
func TestHeader_AppendHeaderExactCapNoPanic(t *testing.T) {
const key, value = "K", "V"
buf := make([]byte, 0, len(key)+len(value)) // exact cap, no slack.
var h Header
h.Reset(buf)
defer func() {
if r := recover(); r != nil {
t.Fatalf("appendHeader panicked on exact-cap buffer: %v", r)
}
}()
h.Add(key, value)
if got := string(h.Get(key)); got != value {
t.Fatalf("want %q, got %q", value, got)
}
}
// Add/Set on a full buffer with growth disabled must drop gracefully (flag OOM),
// never panic. Panicking is unacceptable for this package.
func TestHeader_AddFullBufferNoPanic(t *testing.T) {
buf := make([]byte, 0, 40) // Small cap; enough for Reset (len 0) but not the field below.
var h Header
h.Reset(buf)
h.EnableBufferGrowth(false)
h.SetMethod("GET")
h.SetRequestURI("/")
h.SetProtocol("HTTP/1.1")
defer func() {
if r := recover(); r != nil {
t.Fatalf("Add on full no-grow buffer panicked: %v", r)
}
}()
h.Add("X-Very-Long-Header-Key", "a-value-that-cannot-possibly-fit-in-the-buffer")
// Graceful drop: request marshalling reports OOM instead of emitting bad data.
if _, err := h.AppendRequest(nil); err == nil {
t.Fatal("want OOM error after dropped Add, got nil")
}
}
func TestHeader_SetInt(t *testing.T) {
for _, tc := range []struct {
name string
value int64
base int
want string
}{
{"decimal", 1234, 10, "1234"},
{"zero", 0, 10, "0"},
{"negative", -42, 10, "-42"},
{"maxint64", 9223372036854775807, 10, "9223372036854775807"},
{"minint64", -9223372036854775808, 10, "-9223372036854775808"},
{"hex", 255, 16, "ff"},
} {
t.Run(tc.name, func(t *testing.T) {
var h Header
h.Reset(nil)
h.SetInt("Content-Length", tc.value, tc.base)
if got := string(h.Get("Content-Length")); got != tc.want {
t.Fatalf("want %q, got %q", tc.want, got)
}
})
}
}
// SetInt on an existing key must reuse the slot in place (single field, latest value).
func TestHeader_SetIntOverwrite(t *testing.T) {
var h Header
h.Reset(nil)
h.SetMethod("GET")
h.SetRequestURI("/")
h.SetProtocol("HTTP/1.1")
h.SetInt("Content-Length", 100, 10)
h.SetInt("Content-Length", 5, 10) // shorter, must fit in old slot.
if got := string(h.Get("Content-Length")); got != "5" {
t.Fatalf("want Content-Length %q, got %q", "5", got)
}
req, err := h.AppendRequest(nil)
if err != nil {
t.Fatal(err)
}
if n := strings.Count(string(req), "Content-Length:"); n != 1 {
t.Errorf("want 1 Content-Length field, got %d:\n%s", n, req)
}
}
// SetInt must not heap-allocate: it must format directly into the header buffer.
func TestHeader_SetIntNoAlloc(t *testing.T) {
buf := make([]byte, 0, 256)
var h Header
h.Reset(buf)
h.EnableBufferGrowth(false)
h.Add("Content-Length", "0000000000000000000000") // pre-size a reusable slot.
allocs := testing.AllocsPerRun(100, func() {
h.SetInt("Content-Length", 1234567890, 10)
})
if allocs != 0 {
t.Fatalf("SetInt allocated %v times, want 0", allocs)
}
if got := string(h.Get("Content-Length")); got != "1234567890" {
t.Fatalf("want %q, got %q", "1234567890", got)
}
}
+117 -20
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"errors"
"slices"
"strconv"
"unsafe"
"github.com/soypat/lneto/internal"
@@ -27,8 +28,14 @@ var (
errNeedMethodURI = errors.New("need method/request URI to create request header")
errBadStatusCodeTxt = errors.New("invalid status code or text")
errCookiesParsed = errors.New("cookies already parsed, reset before parsing again")
errBufferTooLarge = errors.New("httpraw: buffer exceeds max size (offsets are uint16)")
)
// maxBufLen bounds the header buffer. Offsets/lengths are stored as uint16
// (tokint); a buffer past this would truncate/overflow those, silently
// returning the wrong bytes or panicking on a wrapped slice bound.
const maxBufLen = 0xffff
type headerBuf struct {
// buf[:len] holds entire HTTP header data, which may be normalized by [flags]. buf[off:len] holds data not yet processed during parsing.
buf []byte
@@ -92,6 +99,9 @@ func (h *Header) parse(asResponse bool) (err error) {
}
func (h *Header) parseFirstLine(asResponse bool) (err error) {
if len(h.hbuf.buf) > maxBufLen {
return errBufferTooLarge // Offsets would overflow uint16 tokint.
}
if asResponse {
h.statusCode, h.statusText, h.flags, err = h.hbuf.parseFirstLineResponse(h.flags)
} else {
@@ -302,32 +312,22 @@ func (h *Header) reuseOrAppend(tok headerSlice, value string) headerSlice {
func (h *Header) appendSlice(value string) headerSlice {
debuglog("http:appendslice:start")
free := h.hbuf.free()
if len(value) > free {
if h.flags.hasAny(flagNoBufferGrow) {
h.flags |= flagOOMReached
return headerSlice{}
}
debuglog("http:appendslice:grow-buf")
h.hbuf.buf = slices.Grow(h.hbuf.buf, len(value)+1) // Grow 1 beyond due to slice validity.
if !h.reserve(len(value)) {
return headerSlice{}
}
h.flags |= flagMangledBuffer
return h.hbuf.mustAppendSlice(value)
}
func (h *Header) appendHeader(key, value string) {
hb := &h.hbuf
free := hb.free()
buf := h.hbuf.buf
if len(key)+len(value) > free {
if h.flags.hasAny(flagNoBufferGrow) {
panic(errSmallBuffer)
}
debuglog("http:appendhdr:grow-buf")
hb.buf = slices.Grow(buf, len(key)+len(value))
// reserve accounts for the byte-0 reservation mustAppendSlice makes on an
// empty buffer, and drops (flagging OOM) rather than panicking when growth
// is disabled and space runs out.
if !h.reserve(len(key) + len(value)) {
return
}
h.flags |= flagMangledBuffer
hb := &h.hbuf
k := hb.mustAppendSlice(key)
v := hb.mustAppendSlice(value)
debuglog("http:appendhdr:grow-hdrs")
@@ -337,6 +337,100 @@ func (h *Header) appendHeader(key, value string) {
})
}
// appendHeaderInt is appendHeader's integer counterpart: it appends key and the
// formatted integer value as a new header field.
func (h *Header) appendHeaderInt(key string, value int64, base int) {
n := intLen(value, base)
if !h.reserve(len(key) + n) {
return // Drop and flag OOM; never panic.
}
h.flags |= flagMangledBuffer
hb := &h.hbuf
k := hb.mustAppendSlice(key)
v := hb.mustAppendInt(value, base)
hb.headers = append(hb.headers, argsKV{
key: k,
value: v,
})
}
// reserve ensures need free bytes are available in the buffer, growing it when
// permitted. It accounts for the byte-0 reservation on an empty buffer (see
// mustAppendSlice). It returns false and sets flagOOMReached when the space
// cannot be guaranteed: a tokint offset overflow, or a full buffer with
// flagNoBufferGrow set.
func (h *Header) reserve(need int) bool {
hb := &h.hbuf
if len(hb.buf) == 0 {
need++ // mustAppend* reserves byte 0 on an empty buffer.
}
if len(hb.buf)+need > maxBufLen {
h.flags |= flagOOMReached // Offsets would overflow uint16 tokint.
return false
}
if need > hb.free() {
if h.flags.hasAny(flagNoBufferGrow) {
h.flags |= flagOOMReached
return false
}
hb.buf = slices.Grow(hb.buf, need)
}
return true
}
// reuseOrAppendInt writes value into tok's slot in place when it fits, avoiding
// any buffer growth; otherwise it appends a fresh slot.
func (h *Header) reuseOrAppendInt(tok headerSlice, value int64, base int) headerSlice {
n := intLen(value, base)
if int(tok.len) >= n {
// Reuse: format directly over the existing slot. No free space needed
// since n <= tok.len and the slot already lives inside buf.
v := strconv.AppendInt(h.hbuf.buf[tok.start:tok.start], value, base)
tok.len = tokint(len(v))
h.flags |= flagMangledBuffer
return tok
}
return h.appendInt(value, base, n)
}
// appendInt reserves space (growing or flagging OOM) and appends value as a new slot.
func (h *Header) appendInt(value int64, base, n int) headerSlice {
if !h.reserve(n) {
return headerSlice{} // Drop and flag OOM; never panic.
}
h.flags |= flagMangledBuffer
return h.hbuf.mustAppendInt(value, base)
}
// mustAppendInt formats value into the buffer's free region and commits it.
// The caller must have reserved at least intLen(value, base) free bytes.
func (hb *headerBuf) mustAppendInt(value int64, base int) headerSlice {
L := len(hb.buf)
if L == 0 {
L++ // Valid key-values start after byte 0.
}
v := strconv.AppendInt(hb.buf[L:L], value, base)
hb.buf = hb.buf[:L+len(v)]
return hb.slice(hb.buf[L : L+len(v)])
}
// intLen returns the number of bytes strconv.AppendInt would emit for value in
// the given base (including a leading minus sign for negatives). Used to size
// the buffer and to test whether a value fits an existing slot without writing.
func intLen(value int64, base int) int {
n := 1
u := uint64(value)
if value < 0 {
n++ // Leading minus sign.
u = -u // Two's-complement magnitude; correct even for math.MinInt64.
}
for u >= uint64(base) {
u /= uint64(base)
n++
}
return n
}
func (hb *headerBuf) noKV() argsKV { return argsKV{} }
func (hb *headerBuf) next(ss *scannerState) argsKV {
@@ -373,8 +467,11 @@ func (hb *headerBuf) next(ss *scannerState) argsKV {
ss.err = errInvalidName
return hb.noKV()
} else if n < 0 {
// No colon found, probably missing data.
ss.err = errNeedMore
// A newline is present (x>=0 reached here) but the line has no
// colon: malformed, not incomplete. A split arriving before the
// colon has no newline yet and is caught by the x<0 branch above,
// so it still returns errNeedMore.
ss.err = errInvalidName
return hb.noKV()
}
}
+219
View File
@@ -0,0 +1,219 @@
// Command benchci parses `go test -bench` output and renders a Markdown
// report. It is repo-owned tooling so CI does not depend on third-party
// benchmark actions. With -count>1 it reports the median of each metric to
// reduce noise.
package main
import (
"bufio"
"flag"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
)
// commentMarker is a stable HTML marker so a PR commenter can locate and
// update an existing report comment instead of posting duplicates.
const commentMarker = "<!-- lneto-bench -->"
func main() {
var (
currentPath = flag.String("current", "", "path to `go test -bench` output (default stdin)")
outPath = flag.String("out", "", "path to write Markdown report (default stdout)")
title = flag.String("title", "Benchmark results", "report heading")
)
flag.Parse()
in := io.Reader(os.Stdin)
if *currentPath != "" {
f, err := os.Open(*currentPath)
if err != nil {
fatal(err)
}
defer f.Close()
in = f
}
results, err := parse(in)
if err != nil {
fatal(err)
}
out := io.Writer(os.Stdout)
if *outPath != "" {
f, err := os.Create(*outPath)
if err != nil {
fatal(err)
}
defer f.Close()
out = f
}
if err := render(out, *title, results); err != nil {
fatal(err)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "benchci:", err)
os.Exit(1)
}
// result is the aggregated metrics for a single benchmark.
type result struct {
pkg string
name string // benchmark name including the GOMAXPROCS suffix, e.g. BenchmarkFoo-12
nsPerOp []float64
bytesPerOp []float64
allocsPerOp []float64
}
// parse reads `go test -bench -benchmem` output and groups metric samples by
// package and benchmark name. Repeated lines (from -count) accumulate samples.
func parse(r io.Reader) ([]result, error) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
byKey := make(map[string]*result)
var order []string
var pkg string
for sc.Scan() {
line := sc.Text()
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
if fields[0] == "pkg:" && len(fields) >= 2 {
pkg = fields[1]
continue
}
if !strings.HasPrefix(fields[0], "Benchmark") || len(fields) < 4 {
continue
}
// fields: name iters value unit [value unit]...
name := fields[0]
if _, err := strconv.Atoi(fields[1]); err != nil {
continue // second field must be the iteration count
}
key := pkg + "\x00" + name
res := byKey[key]
if res == nil {
res = &result{pkg: pkg, name: name}
byKey[key] = res
order = append(order, key)
}
parseMetrics(res, fields[2:])
}
if err := sc.Err(); err != nil {
return nil, err
}
out := make([]result, 0, len(order))
for _, k := range order {
out = append(out, *byKey[k])
}
sort.Slice(out, func(i, j int) bool {
if out[i].pkg != out[j].pkg {
return out[i].pkg < out[j].pkg
}
return out[i].name < out[j].name
})
return out, nil
}
// parseMetrics consumes (value, unit) pairs and appends the metrics benchci
// reports on. Unknown metrics are ignored.
func parseMetrics(res *result, tokens []string) {
for i := 0; i+1 < len(tokens); i += 2 {
v, err := strconv.ParseFloat(tokens[i], 64)
if err != nil {
continue
}
switch tokens[i+1] {
case "ns/op":
res.nsPerOp = append(res.nsPerOp, v)
case "B/op":
res.bytesPerOp = append(res.bytesPerOp, v)
case "allocs/op":
res.allocsPerOp = append(res.allocsPerOp, v)
}
}
}
// median returns the median of samples. ok is false when there are no samples.
func median(samples []float64) (value float64, ok bool) {
if len(samples) == 0 {
return 0, false
}
s := append([]float64(nil), samples...)
sort.Float64s(s)
n := len(s)
if n%2 == 1 {
return s[n/2], true
}
return (s[n/2-1] + s[n/2]) / 2, true
}
func render(w io.Writer, title string, results []result) error {
bw := bufio.NewWriter(w)
fmt.Fprintf(bw, "%s\n\n", commentMarker)
fmt.Fprintf(bw, "### %s\n\n", title)
if len(results) == 0 {
fmt.Fprintln(bw, "_No benchmarks found._")
return bw.Flush()
}
fmt.Fprintln(bw, "_Timing results (`ns/op`) depend on the host CPU and are only a rough guideline. Memory results (`B/op` and `allocs/op`) are not affected._")
fmt.Fprintln(bw)
fmt.Fprintln(bw, "| Package | Benchmark | ns/op | B/op | allocs/op |")
fmt.Fprintln(bw, "|---|---|---:|---:|---:|")
for _, r := range results {
fmt.Fprintf(bw, "| %s | %s | %s | %s | %s |\n",
shortPkg(r.pkg), r.name,
formatNs(median(r.nsPerOp)),
formatCount(median(r.bytesPerOp)),
formatCount(median(r.allocsPerOp)),
)
}
return bw.Flush()
}
// shortPkg trims the well-known module prefix for readability.
func shortPkg(pkg string) string {
const prefix = "github.com/soypat/lneto/"
if pkg == "" {
return "-"
}
return strings.TrimPrefix(pkg, prefix)
}
func formatCount(v float64, ok bool) string {
if !ok {
return "-"
}
return strconv.FormatFloat(v, 'f', -1, 64)
}
// formatNs renders a ns/op value using a human-friendly time unit.
func formatNs(v float64, ok bool) string {
if !ok {
return "-"
}
switch {
case v >= 1e9:
return fmt.Sprintf("%.3f s", v/1e9)
case v >= 1e6:
return fmt.Sprintf("%.3f ms", v/1e6)
case v >= 1e3:
return fmt.Sprintf("%.3f µs", v/1e3)
default:
return fmt.Sprintf("%.3f ns", v)
}
}
+29 -14
View File
@@ -2,16 +2,16 @@ package internal
import (
"log/slog"
"os"
"runtime"
"strconv"
"sync"
"unsafe"
)
const (
LevelTrace slog.Level = slog.LevelDebug - 2
usePrintLogAllocs = true
usePrintLogAllocs = HeapAllocDebugging
)
var (
@@ -19,9 +19,13 @@ var (
lastAllocs uint64
lastMallocs uint64
allocmu sync.Mutex
allocbuf [256]byte
allocbuf [128]byte
)
func LogAttrsAndAllocs(allocmsg string, l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
logAttrsAndAllocs(allocmsg, l, level, msg, attrs...)
}
func LogAllocs(msg string) {
allocmu.Lock()
runtime.ReadMemStats(&memstats)
@@ -29,23 +33,34 @@ func LogAllocs(msg string) {
allocmu.Unlock()
return
}
inc := int64(memstats.TotalAlloc) - int64(lastAllocs)
numAlloc := int64(memstats.Mallocs) - int64(lastMallocs)
free := memstats.HeapSys - memstats.HeapInuse
if usePrintLogAllocs {
// Branch used when debugheaplog enabled
print("[ALLOC] ", msg)
print(" inc=", int64(memstats.TotalAlloc)-int64(lastAllocs))
print(" n=", int64(memstats.Mallocs)-int64(lastMallocs))
print(" inc=", inc)
print(" n=", numAlloc)
print(" heap=", memstats.HeapAlloc)
print(" free=", memstats.HeapSys-memstats.HeapInuse)
print(" free=", free)
print(" tot=", memstats.TotalAlloc)
println()
} else {
n := copy(allocbuf[:], "[ALLOC] ")
n += copy(allocbuf[n:], msg)
n += copyValueInt(allocbuf[n:], "inc", int64(memstats.TotalAlloc)-int64(lastAllocs))
n += copyValueInt(allocbuf[n:], "n", int64(memstats.Mallocs)-int64(lastMallocs))
n += copyValueUint(allocbuf[n:], "heap", memstats.HeapAlloc)
n += copyValueUint(allocbuf[n:], "free", memstats.HeapSys-memstats.HeapInuse)
n += copyValueUint(allocbuf[n:], "tot", memstats.TotalAlloc)
println(unsafe.String(&allocbuf[0], n))
buf := allocbuf[:len(allocbuf)-1] // keep one character for newline.
n := copy(buf[:], "[ALLOC] ")
n += copy(buf[n:], msg)
n += copyValueInt(buf[n:], "inc", inc)
n += copyValueInt(buf[n:], "n", numAlloc)
n += copyValueUint(buf[n:], "heap", memstats.HeapAlloc)
n += copyValueUint(buf[n:], "free", free)
lastN := copyValueUint(buf[n:], "tot", memstats.TotalAlloc)
n += lastN
allocbuf[n] = '\n' // n will never be larger than len(allocbuf)-1
os.Stdout.Write(allocbuf[:n+1])
if lastN == 0 {
n2 := copy(allocbuf[:], "[WARN] ALLOC BUF OVERRUN\n")
os.Stdout.Write(allocbuf[:n2])
}
}
lastAllocs = memstats.TotalAlloc
lastMallocs = memstats.Mallocs
+5 -11
View File
@@ -4,7 +4,6 @@ package internal
import (
"log/slog"
"runtime"
"time"
"unsafe"
)
@@ -22,10 +21,13 @@ func LogEnabled(l *slog.Logger, lvl slog.Level) bool {
return true
}
func logAttrsAndAllocs(allocmsg string, l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
LogAttrs(nil, level, msg, attrs...) // already logs attributes
}
func LogAttrs(_ *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
now := time.Now()
n := len(now.AppendFormat(timebuf[:0], timefmt))
LogAllocs(msg)
print("time=", unsafe.String(&timebuf[0], n), " ")
if level == LevelTrace {
print("TRACE ")
@@ -35,7 +37,6 @@ func LogAttrs(_ *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr)
print(level.String(), " ")
}
print(msg)
for _, a := range attrs {
switch a.Value.Kind() {
case slog.KindString:
@@ -49,12 +50,5 @@ func LogAttrs(_ *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr)
}
}
println()
allocmu.Lock()
runtime.ReadMemStats(&memstats)
if lastAllocs != memstats.TotalAlloc {
print("alloc increase in heaplog")
}
lastAllocs = memstats.TotalAlloc
lastMallocs = memstats.Mallocs
allocmu.Unlock()
LogAllocs(msg)
}
+6
View File
@@ -21,3 +21,9 @@ func LogAttrs(l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr)
l.LogAttrs(context.Background(), level, msg, attrs...)
}
}
func logAttrsAndAllocs(allocmsg string, l *slog.Logger, level slog.Level, msg string, attrs ...slog.Attr) {
LogAllocs(allocmsg)
LogAttrs(l, level, msg, attrs...)
LogAllocs(msg) // Should not log again unless LogAttrs allocated.
}
+131
View File
@@ -0,0 +1,131 @@
package ltesto
import (
"sync/atomic"
"testing"
"time"
"github.com/soypat/lneto"
)
// NewSched creates a cooperative two-goroutine scheduler modelling a
// coroutine handoff: the scheduled (stack) goroutine drives the [SchedGoro] handle
// while the controlling test thread drives the [SchedDriver] handle. Splitting the
// API across two handles makes it impossible to call a goroutine-side method
// from the test thread, or vice versa.
func NewSched(t testing.TB) *Sched {
return &Sched{
t: t,
goroYieldSignal: make(chan struct{}),
goroContinueSignal: make(chan struct{}),
finishChan: make(chan error, 1),
timeout: time.Second,
}
}
// Sched is the shared state behind a [SchedGoro]/[SchedDriver] pair. It exposes no
// handoff methods directly; obtain a handle with [Sched.Goro] (for the
// scheduled goroutine) or [Sched.Driver] (for the test thread).
type Sched struct {
t testing.TB
// when stack backs off it signals here and waits until channel read or timeout.
goroYieldSignal chan struct{}
// when main goroutine is ready for more information this channel is written to to signal waiting on stack activity.
goroContinueSignal chan struct{}
finishChan chan error
finishcalled atomic.Bool
coroCalls atomic.Int32
timeout time.Duration
}
// AwaitGoroYield blocks until the coroutine suspends itself via [SchedGoro.Yield].
func (ss *Sched) AwaitGoroYield() {
select {
case <-ss.goroYieldSignal:
case <-time.After(ss.timeout):
ss.t.Fatal("timeout waiting for stack to backoff")
}
}
// AwaitGoroYieldOrDone blocks until the coroutine either parks itself via
// [SchedGoro.Yield] (returning done=false) or terminates via [SchedGoro.FinishWithErr]
// /[SchedGoro.Finish] (returning done=true and the terminal error). It lets a driver
// loop service an a-priori-unknown number of yields and still observe completion in
// the same select, avoiding the deadlock of guessing whether the goroutine will yield
// again. Do not mix with [Sched.Done] on the same scheduler.
func (ss *Sched) AwaitGoroYieldOrDone() (done bool, err error) {
select {
case <-ss.goroYieldSignal:
return false, nil
case err = <-ss.finishChan:
return true, err
case <-time.After(ss.timeout):
ss.t.Fatal("timeout waiting for stack to yield or finish")
return true, nil
}
}
// YieldToGoro wakes a coroutine parked in [SchedGoro.Yield], letting the goroutine run on.
func (ss *Sched) YieldToGoro() {
select {
case ss.goroContinueSignal <- struct{}{}:
case <-time.After(ss.timeout):
ss.t.Fatal("timeout while trying to yield to stack")
}
}
// Done returns the channel that receives the coroutine's terminal error from
// [SchedGoro.FinishWithErr]. It may only be called once.
func (ss *Sched) Done() <-chan error {
if ss.finishcalled.CompareAndSwap(false, true) {
return ss.finishChan
}
panic("Done called twice")
}
// Goro returns the handle whose methods must be called from inside the
// scheduled (stack) goroutine.
func (ss *Sched) Goro() SchedGoro {
if !ss.coroCalls.CompareAndSwap(0, 1) {
panic("only one goroutine supported for now")
}
return SchedGoro{ss: ss}
}
// SchedGoro is the coroutine-side handle of a [Sched]. Every method MUST be
// called from inside the scheduled goroutine and never from the test thread.
type SchedGoro struct{ ss *Sched }
// Yield suspends the goroutine at a backoff point and parks until the driver
// calls [SchedDriver.YieldToGoro]. Its signature satisfies [lneto.BackoffStrategy] so it
// can be passed directly as the stack's backoff strategy.
func (c SchedGoro) Yield(consecutiveBackoffs uint) time.Duration {
ss := c.ss
timeout := time.After(ss.timeout)
select {
case ss.goroYieldSignal <- struct{}{}:
case <-timeout:
ss.t.Fatal("timeout backing off, possible race condition? Multiple stacks using same backoff is unexpected pattern")
}
select {
case <-ss.goroContinueSignal:
case <-timeout:
ss.t.Fatal("timeout waiting for continue")
}
return lneto.BackoffFlagNop // backoff yield implemented on our side.
}
// FinishWithErr terminates the coroutine, handing err to the driver's [SchedDriver.Done]
// channel. It must be called at most once.
func (c SchedGoro) FinishWithErr(err error) {
ss := c.ss
if len(ss.finishChan) != 0 {
ss.t.Fatal("Coro.FinishWithErr can be called once only")
}
ss.finishChan <- err
}
// Finish is just shorthand for c.FinishWithErr(nil).
func (c SchedGoro) Finish() {
c.FinishWithErr(nil)
}
+53
View File
@@ -13,6 +13,7 @@ import (
var (
ErrRingBufferFull = lneto.ErrBufferFull
errRingNoData = errors.New("lneto/ring: empty write")
errInvalidCommit = errors.New("lneto/ring: invalid commit amount")
errInvalidDiscard = errors.New("lneto/ring: invalid discard amount")
errDiscardExceeds = errors.New("lneto/ring: discard exceeds length")
errOffsetOverflow = errors.New("lneto/ring: offset too large (32 bit overflow)")
@@ -92,6 +93,58 @@ func (r *Ring) Write(b []byte) (int, error) {
return n, nil
}
// writeStart returns the buffer index where the next [Ring.Write] or
// [Ring.Commit] begins, matching [Ring.Write]'s placement (including wrap).
func (r *Ring) writeStart() int {
if r.End == 0 {
return r.Off // Empty: writing begins at Off.
}
if r.End == len(r.Buf) {
return 0 // Tail full: next byte wraps to the start.
}
return r.End
}
// PeekWrite stages b offset bytes past the write position (see
// [Ring.writeStart]) without advancing it, so the bytes are not yet readable; a
// later [Ring.Commit] reveals them. It reports false, writing nothing, when
// offset is negative or offset+len(b) exceeds [Ring.Free]. Used to place
// out-of-order data ahead of a gap that a normal Write later fills.
func (r *Ring) PeekWrite(b []byte, offset int) bool {
if offset < 0 || offset+len(b) > r.Free() {
return false
}
off := r.writeStart() + offset
if off >= len(r.Buf) {
off -= len(r.Buf)
}
n := copy(r.Buf[off:], b)
if n < len(b) {
copy(r.Buf, b[n:])
}
return true
}
// Commit advances the write pointer by n bytes, making readable any bytes
// previously staged with [Ring.PeekWrite]. It copies nothing and errors if n is
// not positive or exceeds [Ring.Free].
func (r *Ring) Commit(n int) error {
if n <= 0 {
return errInvalidCommit
} else if n > r.Free() {
return ErrRingBufferFull
}
if r.End == 0 {
r.End = r.Off // Match Write: commit begins at Off when empty.
}
end := r.End + n
if end > len(r.Buf) {
end -= len(r.Buf)
}
r.End = end // Never 0 here: end==len(Buf) is kept.
return nil
}
// ReadDiscard is a performance auxiliary method that performs a dummy read or no-op read
// for advancing the read pointer n bytes without actually copying data.
// This method panics if amount of bytes is more than buffered (see [Ring.Buffered]).
+81
View File
@@ -580,3 +580,84 @@ func canonRing(r *Ring) {
r.onReadEnd(1)
}
}
// TestRingPeekWriteCommit verifies that bytes staged ahead of a gap with
// PeekWrite become readable in order once the gap is filled and committed.
func TestRingPeekWriteCommit(t *testing.T) {
r := &Ring{Buf: make([]byte, 16)}
// Stage "BBBB" 4 bytes ahead of the write position (the gap).
if !r.PeekWrite([]byte("BBBB"), 4) {
t.Fatal("PeekWrite should fit")
}
// Staged bytes are not yet readable.
if r.Buffered() != 0 {
t.Fatalf("staged bytes must not be readable, buffered=%d", r.Buffered())
}
// Fill the gap with a normal write, then commit the staged tail.
if _, err := r.Write([]byte("AAAA")); err != nil {
t.Fatal("gap write:", err)
}
if err := r.Commit(4); err != nil {
t.Fatal("commit:", err)
}
got := make([]byte, 8)
n, err := r.Read(got)
if err != nil {
t.Fatal("read:", err)
}
if string(got[:n]) != "AAAABBBB" {
t.Fatalf("read %q, want AAAABBBB", got[:n])
}
testRingSanity(t, r)
}
// TestRingPeekWriteWrap exercises PeekWrite/Commit when the staged region wraps
// across the end of the backing buffer. Existing data at Off=2,End=6 puts the
// write position at index 6, so a 2-byte gap fills indices 6,7 and the staged
// tail wraps to indices 0,1.
func TestRingPeekWriteWrap(t *testing.T) {
r := &Ring{Buf: make([]byte, 8)}
setRingData(t, r, 2, []byte("WXYZ")) // Off=2, End=6, 4 bytes buffered.
if r.writeStart() != 6 {
t.Fatalf("writeStart=%d, want 6", r.writeStart())
}
// Stage "CD" 2 bytes ahead of the write position (6) → wraps to indices 0,1.
if !r.PeekWrite([]byte("CD"), 2) {
t.Fatal("PeekWrite (wrap) should fit")
}
// Fill the 2-byte gap at indices 6,7, then commit the wrapped tail.
if _, err := r.Write([]byte("AB")); err != nil {
t.Fatal("gap write:", err)
}
if err := r.Commit(2); err != nil {
t.Fatal("commit:", err)
}
got := make([]byte, 8)
n, err := r.Read(got)
if err != nil {
t.Fatal("read:", err)
}
if string(got[:n]) != "WXYZABCD" {
t.Fatalf("read %q, want WXYZABCD", got[:n])
}
testRingSanity(t, r)
}
func TestRingPeekWriteRejects(t *testing.T) {
r := &Ring{Buf: make([]byte, 8)}
if r.PeekWrite([]byte("toolong!!"), 0) {
t.Error("PeekWrite must reject data larger than the buffer")
}
if r.PeekWrite([]byte("data"), 5) { // 5+4 > 8 free.
t.Error("PeekWrite must reject offset+len beyond free space")
}
if r.PeekWrite([]byte("x"), -1) {
t.Error("PeekWrite must reject negative offset")
}
if err := r.Commit(0); err == nil {
t.Error("Commit(0) must error")
}
if err := r.Commit(9); err == nil {
t.Error("Commit beyond free must error")
}
}
+214
View File
@@ -0,0 +1,214 @@
package internet
import (
"bytes"
"errors"
"io"
"math/rand"
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/soypat/lneto/tcp"
)
// TestConn_ConcurrentCloseDoesNotDropWrite is a regression test for issue #82:
// when one goroutine has a Write in progress (blocked because the TX buffer is
// full) and another goroutine calls Close, the in-flight write data must not be
// silently dropped. With the atomic write-lock, Close waits for the in-progress
// write to finish queueing all of its data before tearing the connection down.
//
// The scenario is made deterministic by filling the TX buffer before any packets
// are exchanged: the writer blocks holding the write lock, Close is then issued
// (and must wait), and only afterwards is the packet pump started so the buffer
// can drain and the writer can run to completion.
func TestConn_ConcurrentCloseDoesNotDropWrite(t *testing.T) {
rng := rand.New(rand.NewSource(1))
var sbCl, sbSv StackIPv4
var connCl, connSv tcp.Conn
setupClientServerEstablished(t, rng, &sbCl, &sbSv, &connCl, &connSv)
// Payload several times larger than the 2048-byte TX buffer so the writer
// must block waiting for the buffer to drain across many segments.
payload := make([]byte, 4*2048)
for i := range payload {
payload[i] = byte(i)
}
// Watchdog: break any deadlock so a regression fails fast instead of hanging.
watchdog := time.AfterFunc(10*time.Second, func() {
t.Error("test timed out: Close likely blocked waiting on a stuck write")
connCl.Abort()
connSv.Abort()
})
defer watchdog.Stop()
// Writer (G1): writes the whole payload. Blocks once the TX buffer fills.
type writeResult struct {
n int
err error
}
writeDone := make(chan writeResult, 1)
go func() {
n, err := connCl.Write(payload)
writeDone <- writeResult{n, err}
}()
// Wait until the writer has filled the TX buffer and is blocked. At this
// point it owns the write lock, reproducing "Write in progress".
for connCl.FreeOutput() != 0 {
runtime.Gosched()
}
// Close (G2): issued while the write is in progress. Must wait for the
// writer to drain instead of dropping its data.
closeDone := make(chan error, 1)
go func() {
closeDone <- connCl.Close()
}()
// Reader: drains the server side until EOF, accumulating everything received.
readDone := make(chan []byte, 1)
go func() {
got := make([]byte, 0, len(payload))
rbuf := make([]byte, 1024)
for {
n, err := connSv.Read(rbuf)
got = append(got, rbuf[:n]...)
if err != nil {
break
}
}
readDone <- got
}()
// Packet pump: only now do we move packets between the two stacks, letting
// the buffer drain so the blocked writer can finish.
var stop atomic.Bool
pumpStopped := make(chan struct{})
go func() {
defer close(pumpStopped)
var buf [2048]byte
for !stop.Load() {
progressed := false
if n, err := sbCl.Encapsulate(buf[:], 0, 0); err == nil && n > 0 {
_ = sbSv.Demux(buf[:n], 0)
progressed = true
}
if n, err := sbSv.Encapsulate(buf[:], 0, 0); err == nil && n > 0 {
_ = sbCl.Demux(buf[:n], 0)
progressed = true
}
if !progressed {
runtime.Gosched()
}
}
}()
wr := <-writeDone
if wr.err != nil {
t.Errorf("issue #82: Write returned error %v; concurrent Close dropped in-flight data", wr.err)
}
if wr.n != len(payload) {
t.Errorf("issue #82: Write wrote %d of %d bytes; concurrent Close truncated the write", wr.n, len(payload))
}
if err := <-closeDone; err != nil {
t.Errorf("Close returned error: %v", err)
}
got := <-readDone
stop.Store(true)
<-pumpStopped
if len(got) != len(payload) {
t.Fatalf("server received %d of %d bytes; data was dropped by concurrent Close", len(got), len(payload))
}
if !bytes.Equal(got, payload) {
t.Fatal("server received corrupted data")
}
}
// TestConn_ConcurrentWritesSerialize verifies that the atomic write-lock
// serializes concurrent Write calls on the same connection so their payloads are
// not interleaved on the wire, and that all bytes from both writers are delivered.
func TestConn_ConcurrentWritesSerialize(t *testing.T) {
rng := rand.New(rand.NewSource(2))
var sbCl, sbSv StackIPv4
var connCl, connSv tcp.Conn
setupClientServerEstablished(t, rng, &sbCl, &sbSv, &connCl, &connSv)
const chunk = 1500
a := bytes.Repeat([]byte{0xAA}, chunk)
b := bytes.Repeat([]byte{0xBB}, chunk)
watchdog := time.AfterFunc(10*time.Second, func() {
t.Error("test timed out")
connCl.Abort()
connSv.Abort()
})
defer watchdog.Stop()
var stop atomic.Bool
pumpStopped := make(chan struct{})
go func() {
defer close(pumpStopped)
var buf [2048]byte
for !stop.Load() {
progressed := false
if n, err := sbCl.Encapsulate(buf[:], 0, 0); err == nil && n > 0 {
_ = sbSv.Demux(buf[:n], 0)
progressed = true
}
if n, err := sbSv.Encapsulate(buf[:], 0, 0); err == nil && n > 0 {
_ = sbCl.Demux(buf[:n], 0)
progressed = true
}
if !progressed {
runtime.Gosched()
}
}
}()
writeErr := make(chan error, 2)
writer := func(b []byte) {
n, err := connCl.Write(b)
if err == nil && n != len(b) {
err = io.ErrShortWrite
}
writeErr <- err
}
go writer(a)
go writer(b)
got := make([]byte, 0, 2*chunk)
rbuf := make([]byte, 1024)
for len(got) < 2*chunk {
n, err := connSv.Read(rbuf)
got = append(got, rbuf[:n]...)
if err != nil && !errors.Is(err, io.EOF) {
break
}
}
for range 2 {
if err := <-writeErr; err != nil {
t.Errorf("concurrent write failed: %v", err)
}
}
stop.Store(true)
<-pumpStopped
if len(got) != 2*chunk {
t.Fatalf("received %d bytes, want %d", len(got), 2*chunk)
}
// Serialized writes mean one chunk's bytes appear fully before the other's;
// the boundary is a single transition, never interleaved.
transitions := 0
for i := 1; i < len(got); i++ {
if got[i] != got[i-1] {
transitions++
}
}
if transitions != 1 {
t.Fatalf("expected exactly one A/B boundary (serialized writes), got %d transitions", transitions)
}
}
+12 -8
View File
@@ -4,8 +4,8 @@ package pcap
import (
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"strings"
"unsafe"
@@ -980,18 +980,22 @@ func (frm Frame) String() string {
func (frm Frame) AppendString(b []byte) []byte {
bitlen := frm.LenBits()
b = fmt.Appendf(b, "%s", frm.Protocol)
b = append(b, frm.Protocol...)
if bitlen%8 == 0 {
b = fmt.Appendf(b, " len=%d", bitlen/8)
b = append(b, " len="...)
b = strconv.AppendInt(b, int64(bitlen/8), 10)
} else {
b = fmt.Appendf(b, " bits=%d", bitlen)
b = append(b, " bits="...)
b = strconv.AppendInt(b, int64(bitlen), 10)
}
iopt, err := frm.FieldByClass(FieldClassOptions)
if err == nil {
b = fmt.Appendf(b, " optlen=%d", (frm.Fields[iopt].BitLength+7)/8)
b = append(b, " optlen="...)
b = strconv.AppendInt(b, int64((frm.Fields[iopt].BitLength+7)/8), 10)
}
for _, err := range frm.Errors {
b = fmt.Appendf(b, " %s", err.Error())
b = append(b, ' ')
b = append(b, err.Error()...)
}
return b
}
@@ -1005,10 +1009,10 @@ func (frm Frame) LenBits() (totalBitlen int) {
func (ff FrameField) String() string {
if ff.Class == FieldClassPayload {
return fmt.Sprintf("Payload len=%d", ff.BitLength/8)
return "Payload len=" + strconv.Itoa(ff.BitLength/8)
}
if ff.Name != "" {
return fmt.Sprintf("%s (%s)", ff.Name, ff.Class.String())
return ff.Name + " (" + ff.Class.String() + ")"
}
return ff.Class.String()
}
+209
View File
@@ -0,0 +1,209 @@
package pcap
import (
"testing"
"time"
"github.com/soypat/lneto"
"github.com/soypat/lneto/dhcp/dhcpv4"
"github.com/soypat/lneto/dns"
"github.com/soypat/lneto/ethernet"
"github.com/soypat/lneto/ipv4"
"github.com/soypat/lneto/udp"
)
const benchSubfieldLimit = 32
// buildDHCPPacket builds an Ethernet+IPv4+UDP+DHCPv4 Discover packet, exercising
// the option-heavy DHCP path (hostname, client id, requested address, param list).
func buildDHCPPacket(b testing.TB) []byte {
const (
ethSize = 14
ipv4Size = 20
udpSize = 8
)
pkt := make([]byte, 600)
efrm, _ := ethernet.NewFrame(pkt)
*efrm.DestinationHardwareAddr() = [6]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}
*efrm.SourceHardwareAddr() = [6]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe}
efrm.SetEtherType(ethernet.TypeIPv4)
ifrm, _ := ipv4.NewFrame(pkt[ethSize:])
ifrm.SetVersionAndIHL(4, 5)
ifrm.SetID(0x1234)
ifrm.SetFlags(0x4000)
ifrm.SetTTL(64)
ifrm.SetProtocol(lneto.IPProtoUDP)
ufrm, _ := udp.NewFrame(pkt[ethSize+ipv4Size:])
ufrm.SetSourcePort(dhcpv4.DefaultClientPort)
ufrm.SetDestinationPort(dhcpv4.DefaultServerPort)
var cl dhcpv4.Client
err := cl.BeginRequest(0xdeadbeef, dhcpv4.RequestConfig{
RequestedAddr: [4]byte{192, 168, 1, 100},
ClientHardwareAddr: [6]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe},
Hostname: "myhost",
ClientID: "lneto-test",
})
if err != nil {
b.Fatal("begin request:", err)
}
dhcpLen, err := cl.Encapsulate(pkt, ethSize, ethSize+ipv4Size+udpSize)
if err != nil {
b.Fatal("encapsulate:", err)
}
totalLen := ipv4Size + udpSize + dhcpLen
ifrm.SetTotalLength(uint16(totalLen))
ufrm.SetLength(uint16(udpSize + dhcpLen))
ifrm.SetCRC(ifrm.CalculateHeaderCRC())
return pkt[:ethSize+totalLen]
}
// buildDNSPacket builds an Ethernet+IPv4+UDP+DNS message with multiple questions
// and answers, exercising name encoding and resource record rendering.
func buildDNSPacket(b testing.TB) []byte {
const (
ethSize = 14
ipv4Size = 20
udpSize = 8
)
var msg dns.Message
msg.Questions = []dns.Question{
{Name: dns.MustNewName("example.com"), Type: dns.TypeA, Class: dns.ClassINET},
{Name: dns.MustNewName("temu.com"), Type: dns.TypeAAAA, Class: dns.ClassANY},
}
msg.Answers = []dns.Resource{
dns.NewResource(dns.MustNewName("abc.com"), dns.TypeALL, dns.ClassANY, 64, []byte{10, 0, 11, 1}),
dns.NewResource(dns.MustNewName("123.com"), dns.TypeA, dns.ClassINET, 64, []byte{20, 0, 22, 2}),
}
dnsPayload, err := msg.AppendTo(nil, 0x1234, dns.NewClientHeaderFlags(dns.OpCodeQuery, true))
if err != nil {
b.Fatal("dns encode:", err)
}
pkt := make([]byte, ethSize+ipv4Size+udpSize+len(dnsPayload))
efrm, _ := ethernet.NewFrame(pkt)
*efrm.DestinationHardwareAddr() = [6]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x01}
*efrm.SourceHardwareAddr() = [6]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x02}
efrm.SetEtherType(ethernet.TypeIPv4)
ifrm, _ := ipv4.NewFrame(pkt[ethSize:])
ifrm.SetVersionAndIHL(4, 5)
ifrm.SetTTL(64)
ifrm.SetProtocol(lneto.IPProtoUDP)
ifrm.SetTotalLength(uint16(ipv4Size + udpSize + len(dnsPayload)))
ifrm.SetCRC(ifrm.CalculateHeaderCRC())
ufrm, _ := udp.NewFrame(pkt[ethSize+ipv4Size:])
ufrm.SetSourcePort(58200)
ufrm.SetDestinationPort(dns.ServerPort)
ufrm.SetLength(uint16(udpSize + len(dnsPayload)))
copy(pkt[ethSize+ipv4Size+udpSize:], dnsPayload)
return pkt
}
func configureBenchFormatter(f *Formatter) {
f.SubfieldLimit = benchSubfieldLimit
f.FrameSep = "\n"
f.FieldSep = "; "
f.SubfieldSep = "\n\t"
}
// BenchmarkPcap measures the decode, format, and decode+format (roundtrip) phases
// separately for the string-heavy DHCP and DNS frames. Run with -benchmem for
// per-phase allocs/op.
func BenchmarkPcap(b *testing.B) {
cases := []struct {
name string
pkt []byte
}{
{"DHCP", buildDHCPPacket(b)},
{"DNS", buildDNSPacket(b)},
}
for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
b.Run("decode", func(b *testing.B) {
var pb PacketBreakdown
pb.SubfieldLimit = benchSubfieldLimit
var frames []Frame
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
frames, _ = pb.CaptureEthernet(frames[:0], tc.pkt, 0)
}
})
b.Run("format", func(b *testing.B) {
var pb PacketBreakdown
pb.SubfieldLimit = benchSubfieldLimit
frames, err := pb.CaptureEthernet(nil, tc.pkt, 0)
if err != nil {
b.Fatal(err)
}
var f Formatter
configureBenchFormatter(&f)
var buf []byte
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
buf, _ = f.FormatFrames(buf[:0], frames, tc.pkt)
}
})
b.Run("roundtrip", func(b *testing.B) {
var pb PacketBreakdown
pb.SubfieldLimit = benchSubfieldLimit
var f Formatter
configureBenchFormatter(&f)
var frames []Frame
var buf []byte
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
frames, _ = pb.CaptureEthernet(frames[:0], tc.pkt, 0)
buf, _ = f.FormatFrames(buf[:0], frames, tc.pkt)
}
})
})
}
}
// BenchmarkPcapPhases runs decode+format in a single benchmark loop while
// reporting per-phase wall time via custom metrics (decode-ns/op, format-ns/op).
// decode-ns/op + format-ns/op approximates ns/op minus time.Now overhead.
// Per-phase allocs are not split here (ReadMemStats is STW and skews timing);
// use BenchmarkPcap's decode/format sub-benchmarks with -benchmem for that.
func BenchmarkPcapPhases(b *testing.B) {
cases := []struct {
name string
pkt []byte
}{
{"DHCP", buildDHCPPacket(b)},
{"DNS", buildDNSPacket(b)},
}
for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
var pb PacketBreakdown
pb.SubfieldLimit = benchSubfieldLimit
var f Formatter
configureBenchFormatter(&f)
var frames []Frame
var buf []byte
var decNs, fmtNs int64
b.ResetTimer()
for b.Loop() {
t0 := time.Now()
frames, _ = pb.CaptureEthernet(frames[:0], tc.pkt, 0)
t1 := time.Now()
buf, _ = f.FormatFrames(buf[:0], frames, tc.pkt)
t2 := time.Now()
decNs += t1.Sub(t0).Nanoseconds()
fmtNs += t2.Sub(t1).Nanoseconds()
}
b.ReportMetric(float64(decNs)/float64(b.N), "decode-ns/op")
b.ReportMetric(float64(fmtNs)/float64(b.N), "format-ns/op")
})
}
}
+6 -1
View File
@@ -158,7 +158,12 @@ func (ls *StackEthernet) Demux(carrierData []byte, frameOffset int) (err error)
return err
}
DROP:
ls.handlers.info("LinkStack:drop-packet", internal.SlogAddr6("dsthw", dstaddr), slog.String("ethertype", efrm.EtherTypeOrSize().String()))
// Frames not addressed to us are routine noise on a shared LAN (multicast/
// broadcast spam). Log at debug so a production logger at info level gates it
// out instead of allocating slog attrs per dropped packet.
if internal.LogEnabled(ls.handlers.logger.log, slog.LevelDebug) {
ls.handlers.debug("LinkStack:drop-packet", internal.SlogAddr6("dsthw", dstaddr), slog.String("ethertype", efrm.EtherTypeOrSize().String()))
}
return lneto.ErrPacketDrop
}
+23 -12
View File
@@ -22,34 +22,35 @@ type StackIPv4 struct {
}
func (stackip4 *StackIPv4) Reset(vld *lneto.Validator, maxNodes int) error {
stackip4.connID++
stackip4.reset4(vld, maxNodes)
return nil
}
func (stackip *StackIPv4) ConnectionID() *uint64 {
return &stackip.connID
func (stackip4 *StackIPv4) ConnectionID() *uint64 {
return &stackip4.connID
}
func (stackip *StackIPv4) Protocol() uint64 {
func (stackip4 *StackIPv4) Protocol() uint64 {
return uint64(ethernet.TypeIPv4)
}
func (stackip *StackIPv4) LocalPort() uint16 { return 0 }
func (stackip4 *StackIPv4) LocalPort() uint16 { return 0 }
func (stackip *StackIPv4) SetLogger(logger *slog.Logger) {
stackip.stackip4.handlers.log = logger
func (stackip4 *StackIPv4) SetLogger(logger *slog.Logger) {
stackip4.stackip4.handlers.log = logger
}
func (stackip *StackIPv4) Demux(carrierData []byte, offset int) error {
func (stackip4 *StackIPv4) Demux(carrierData []byte, offset int) error {
debugLog("ip:demux")
return stackip.stackip4.demux4(carrierData, offset)
return stackip4.stackip4.demux4(carrierData, offset)
}
func (stackip *StackIPv4) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (n int, err error) {
func (stackip4 *StackIPv4) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (n int, err error) {
if offsetToFrame != offsetToIP {
return 0, lneto.ErrBug
}
return stackip.stackip4.encapsulate4(carrierData, offsetToIP)
return stackip4.stackip4.encapsulate4(carrierData, offsetToIP)
}
type stackip4 struct {
@@ -58,6 +59,7 @@ type stackip4 struct {
ipID uint16
ip4 [4]byte
acceptMulticast bool
acceptBroadcast bool
}
func (si4 *stackip4) reset4(vld *lneto.Validator, maxNodes int) {
@@ -86,6 +88,10 @@ func (si4 *stackip4) IsRegistered4(proto lneto.IPProto) bool {
func (si4 *stackip4) SetAcceptMulticast4(accept bool) {
si4.acceptMulticast = accept
}
func (si4 *stackip4) SetAcceptBroadcast4(accept bool) {
si4.acceptBroadcast = accept
}
func (si4 *stackip4) Addr4() [4]byte { return si4.ip4 }
func (si4 *stackip4) SetAddr4(ip4 [4]byte) {
si4.ip4 = ip4
@@ -100,8 +106,13 @@ func (si4 *stackip4) demux4(carrierData []byte, offset int) error {
return err
}
dst := ifrm.DestinationAddr()
if si4.ip4 != ([4]byte{}) && *dst != si4.ip4 {
if !si4.acceptMulticast || !internal.IsMulticastIPAddr(dst[:]) {
if si4.ip4 != ([4]byte{}) && *dst != si4.ip4 { // Accept all packets when IP zeroed.
switch {
case si4.acceptMulticast && ipv4.IsMulticast(*dst):
// accept multicast.
case si4.acceptBroadcast && ipv4.IsBroadcast(*dst):
// accept broadcast.
default:
si4.handlers.debug("ip:not-for-us")
return lneto.ErrPacketDrop // Not meant for us.
}
+14 -12
View File
@@ -19,35 +19,36 @@ type StackIPv6 struct {
stackip6
}
func (stackip4 *StackIPv6) Reset(vld *lneto.Validator, maxNodes int) error {
stackip4.reset6(vld, maxNodes)
func (stackip6 *StackIPv6) Reset(vld *lneto.Validator, maxNodes int) error {
stackip6.connID++
stackip6.reset6(vld, maxNodes)
return nil
}
func (stackip *StackIPv6) ConnectionID() *uint64 {
return &stackip.connID
func (stackip6 *StackIPv6) ConnectionID() *uint64 {
return &stackip6.connID
}
func (stackip *StackIPv6) Protocol() uint64 {
func (stackip6 *StackIPv6) Protocol() uint64 {
return uint64(ethernet.TypeIPv6)
}
func (stackip *StackIPv6) LocalPort() uint16 { return 0 }
func (stackip6 *StackIPv6) LocalPort() uint16 { return 0 }
func (stackip *StackIPv6) SetLogger(logger *slog.Logger) {
stackip.stackip6.handlers.log = logger
func (stackip6 *StackIPv6) SetLogger(logger *slog.Logger) {
stackip6.stackip6.handlers.log = logger
}
func (stackip *StackIPv6) Demux(carrierData []byte, offset int) error {
func (stackip6 *StackIPv6) Demux(carrierData []byte, offset int) error {
debugLog("ip:demux")
return stackip.stackip6.demux6(carrierData, offset)
return stackip6.stackip6.demux6(carrierData, offset)
}
func (stackip *StackIPv6) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (n int, err error) {
func (stackip6 *StackIPv6) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (n int, err error) {
if offsetToFrame != offsetToIP {
return 0, lneto.ErrBug
}
return stackip.stackip6.encapsulate6(carrierData, offsetToIP)
return stackip6.stackip6.encapsulate6(carrierData, offsetToIP)
}
type stackip6 struct {
@@ -55,6 +56,7 @@ type stackip6 struct {
vld *lneto.Validator
ip6 [16]byte
acceptMulticast bool
acceptBroadcast bool
}
func (si6 *stackip6) Register6(h lneto.StackNode) error {
+11
View File
@@ -18,6 +18,17 @@ func IsMulticast(addr [4]byte) bool {
return addr[0]&0xf0 == 0xe0
}
// IsBroadcast reports whether addr is the limited broadcast address
// 255.255.255.255 used to address all hosts on the local network segment
// as defined in [RFC919]. It does not detect directed (subnet) broadcast
// addresses, which depend on the network mask and cannot be determined from
// the address alone.
//
// [RFC919]: https://datatracker.ietf.org/doc/html/rfc919
func IsBroadcast(addr [4]byte) bool {
return addr == [4]byte{255, 255, 255, 255}
}
// IsLinkLocal reports whether addr is within the IPv4 link-local prefix
// 169.254.0.0/16 reserved for dynamic link-local configuration as defined in [RFC3927].
//
+7 -4
View File
@@ -38,6 +38,10 @@ type Client struct {
raddr [4]byte
}
responseRing internal.Ring
// addrScratch holds the remote address for SetIPAddrs during Encapsulate.
// Kept on the (heap-resident) Client to avoid a per-call escape of a local
// [4]byte, which TinyGo would otherwise heap-allocate on every poll.
addrScratch [4]byte
}
type ClientConfig struct {
@@ -155,7 +159,6 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
// Put n bytes of ICMP data.
var n int
var raddr [4]byte
if len(client.incomingEcho) > 0 {
// Priority: send echo reply.1
inc := client.incomingEcho[0]
@@ -170,7 +173,7 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
}
client.incomingEcho = slices.Delete(client.incomingEcho, 0, 1)
n = sizeHeader + dataLen
raddr = inc.raddr
client.addrScratch = inc.raddr
} else if len(client.outgoingEcho) > 0 {
idx := 0
for idx < len(client.outgoingEcho) {
@@ -199,7 +202,7 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
copy(data[written:written+size%len(pattern)], pattern)
n = sizeHeader + size
out.key |= keyHashSentBit
raddr = out.raddr
client.addrScratch = out.raddr
} else {
return 0, nil
}
@@ -210,7 +213,7 @@ func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int)
sum := crc.PayloadSum16(carrierData[frameOffset : frameOffset+n])
ifrm.SetCRC(sum)
if ipOffset >= 0 {
err = internal.SetIPAddrs(carrierData[ipOffset:], 0, nil, raddr[:])
err = internal.SetIPAddrs(carrierData[ipOffset:], 0, nil, client.addrScratch[:])
}
return n, err
}
+74 -10
View File
@@ -8,6 +8,7 @@ import (
"net/netip"
"os"
"sync"
"sync/atomic"
"time"
"github.com/soypat/lneto"
@@ -29,6 +30,13 @@ type Conn struct {
h Handler
remoteAddr []byte
// writeLock holds the connection ID ([Handler.connid]) of the goroutine
// that currently owns the write side via [Conn.Write] or [Conn.Flush].
// A zero value means no write is in progress. It serializes concurrent
// writers and lets [Conn.Close] acquire the write side before closing so it
// does not drop in-flight data (see issue #82).
writeLock atomic.Uint64
_backoff lneto.BackoffStrategy
rdead time.Time
wdead time.Time
@@ -49,6 +57,7 @@ func (conn *Conn) reset(h Handler) {
conn.wdead = time.Time{}
conn.abortErr = nil
conn.ipID = 0
conn.writeLock.Store(0)
}
// ConnConfig provides configuration parameters for [Conn].
@@ -67,6 +76,16 @@ type ConnConfig struct {
// Logger sets the [Conn] logger.
// Lower level logging available at [Handler.SetLoggers] via [Conn.InternalHandler].
Logger *slog.Logger
// LossRecovery is the optional packet-loss recovery algorithm (RTO,
// congestion control, ...) for the connection. If set, Nanotime must also be
// set (else Configure returns an error). Leaving it nil disables loss
// recovery. See [LossRecovery].
LossRecovery LossRecovery
// Nanotime is the monotonic time source in nanoseconds (the func() int64
// convention used across lneto) that drives LossRecovery. It is required when
// LossRecovery is set and unused otherwise. The tcp package reads it only to
// stamp the loss-recovery hooks; it holds no clock itself.
Nanotime func() int64
}
// Configure should be called on any newly created connection before usage. See [ConnConfig].
@@ -74,6 +93,10 @@ func (conn *Conn) Configure(config ConnConfig) (err error) {
if config.RWBackoff == nil {
return lneto.ErrMissingHALConfig
}
if config.LossRecovery != nil && config.Nanotime == nil {
// The tcp package holds no clock: a loss-recovery algorithm cannot run without it.
return lneto.ErrInvalidConfig
}
conn.mu.Lock()
defer conn.mu.Unlock()
err = conn.h.SetBuffers(config.TxBuf, config.RxBuf, config.TxPacketQueueSize)
@@ -82,6 +105,7 @@ func (conn *Conn) Configure(config ConnConfig) (err error) {
}
conn._backoff = config.RWBackoff
conn.logger.log = config.Logger
conn.h.SetLossRecovery(config.LossRecovery, config.Nanotime)
return nil
}
@@ -99,6 +123,22 @@ func (conn *Conn) RemotePort() uint16 {
return conn.h.RemotePort()
}
// IsAwaitingControl reports whether the connection is waiting for a response to
// a control segment that can be retransmitted to advance connection state.
func (conn *Conn) IsAwaitingControl() bool {
conn.mu.Lock()
defer conn.mu.Unlock()
return conn.h.IsAwaitingControl()
}
// RequeueControl asks the next packet emission to retransmit the outstanding
// control segment, if the connection is waiting for one.
func (conn *Conn) RequeueControl() {
conn.mu.Lock()
defer conn.mu.Unlock()
conn.h.RequeueControl()
}
// RemoteAddr returns the address of the peer Conn is exchanging data with.
func (conn *Conn) RemoteAddr() []byte {
conn.mu.Lock()
@@ -213,6 +253,11 @@ func (conn *Conn) CloseRead() error {
// Close will initiate TCP close sequence. After Close is called future [Conn.Write] calls will fail with [net.ErrClosed].
func (conn *Conn) Close() error {
connid, err := conn.acquireWriteLock(nil)
if err != nil {
return err
}
defer conn.releaseWriteLock(connid)
conn.mu.Lock()
defer conn.mu.Unlock()
conn.trace("TCPConn.Close", slog.Uint64("lport", uint64(conn.h.localPort)), slog.Uint64("rport", uint64(conn.h.remotePort)))
@@ -237,10 +282,11 @@ func (conn *Conn) InternalHandler() *Handler {
// Write writes argument data to the TCPConns's output buffer which is queued to be sent.
func (conn *Conn) Write(b []byte) (int, error) {
connid, err := conn.lockPipeConnID()
connid, err := conn.acquireWriteLock(&conn.wdead)
if err != nil {
return 0, err
}
defer conn.releaseWriteLock(connid)
rport := conn.RemotePort()
plen := len(b)
lport := conn.LocalPort()
@@ -286,10 +332,11 @@ func (conn *Conn) Write(b []byte) (int, error) {
// Flush blocks until all buffered TCP data has been sent.
func (conn *Conn) Flush() error {
connid, err := conn.lockPipeConnID()
connid, err := conn.acquireWriteLock(&conn.wdead)
if err != nil {
return err
}
defer conn.releaseWriteLock(connid)
var backoffs uint
for conn.BufferedUnsent() != 0 {
if err := conn.checkPipe(connid, &conn.wdead); err != nil {
@@ -350,14 +397,31 @@ func (conn *Conn) Read(b []byte) (int, error) {
return n, nil
}
func (conn *Conn) lockPipeConnID() (uint64, error) {
// acquireWriteLock validates the connection is open and acquires the write lock
// for the current connection, returning its connection ID. It serializes
// concurrent writers: while another goroutine owns the write side it blocks with
// backoff until that writer releases, the connection closes, or deadline elapses.
// The returned connID must be released with [Conn.releaseWriteLock].
func (conn *Conn) acquireWriteLock(deadline *time.Time) (connID uint64, err error) {
conn.mu.Lock()
defer conn.mu.Unlock()
err := conn.checkPipeOpen()
if err != nil {
return 0, err
connID = conn.h.connid
conn.mu.Unlock()
var backoffs uint
for {
if err := conn.checkPipe(connID, deadline); err != nil {
return 0, err
}
if conn.writeLock.CompareAndSwap(0, connID) {
return connID, nil
}
conn.backoff(backoffs)
backoffs++
}
return conn.h.connid, nil
}
// releaseWriteLock releases a write lock previously acquired by [Conn.acquireWriteLock].
func (conn *Conn) releaseWriteLock(connID uint64) {
conn.writeLock.CompareAndSwap(connID, 0)
}
func (conn *Conn) checkPipe(connID uint64, deadline *time.Time) (err error) {
@@ -365,9 +429,9 @@ func (conn *Conn) checkPipe(connID uint64, deadline *time.Time) (err error) {
defer conn.mu.Unlock()
if conn.abortErr != nil {
err = conn.abortErr
} else if connID != conn.h.connid {
} else if connID != conn.h.connid || conn.h.State().IsClosed() {
err = net.ErrClosed
} else if !deadline.IsZero() && time.Since(*deadline) > 0 {
} else if deadline != nil && !deadline.IsZero() && time.Since(*deadline) > 0 {
err = errDeadlineExceeded
}
return err
+10
View File
@@ -257,6 +257,16 @@ func (tcb *ControlBlock) HasPendingRetransmit() bool {
return tcb._state.TxDataOpen() && tcb.dupack >= retransmitAfterDupacks && tcb.nRetransmit <= tcb.dupack-retransmitAfterDupacks
}
// RetransmitAll rewinds snd.NXT back to snd.UNA so the next PendingSegment and
// Send calls retransmit all unacknowledged data from the oldest sequence number
// (go-back-N). It must be paired with ringTx.RetransmitFromUNA to rewind the
// transmit buffer. Implements RFC 9293 §3.10.8 (RETRANSMISSION TIMEOUT).
func (tcb *ControlBlock) RetransmitAll() {
tcb.snd.NXT = tcb.snd.UNA
tcb.dupack = 0
tcb.nRetransmit = 0
}
// PendingSegment calculates a suitable next segment to send from a payload length.
// It does not modify the ControlBlock state or pending segment queue.
func (tcb *ControlBlock) PendingSegment(payloadLen int) (_ Segment, ok bool) {
+188 -14
View File
@@ -28,11 +28,23 @@ type Handler struct {
// connection is established via Open calls. This disambiguates whether
// Read and Write calls belong to the current connection.
optcodec OptionCodec
optcodec OptionCodec
// reasm tracks out-of-order segments staged in bufRx's free region. Always
// enabled once buffers are set (see [Handler.SetBuffers]).
reasm reassembly
// loss is the optional packet-loss recovery algorithm (RTO, congestion
// control, ...) driven from the rx/tx hooks. nil disables loss recovery, in
// which case the connection behaves as if no timing existed. nanotime is the
// monotonic time source (nanoseconds) passed to those hooks; it is non-nil
// whenever loss is non-nil (enforced by [Conn.Configure]). See [LossRecovery].
loss LossRecovery
nanotime func() int64
closing bool
shutdownRx bool
// nRetransmit stores the number of times the oldest packet was retransmit.
nRetransmit uint8
nRetransmit uint8
requeueControl bool
}
// SetLoggers sets the [slog.Logger] for the Handler and internal [ControlBlock].
@@ -63,9 +75,32 @@ func (h *Handler) SetBuffers(txbuf, rxbuf []byte, packets int) error {
}
h.scb.SetRecvWindow(Size(h.bufRx.Size()))
h.bufRx.Reset()
h.reasm.reset(maxReasmSegments)
return h.bufTx.ResetOrReuse(txbuf, packets, 0)
}
// SetLossRecovery installs the packet-loss recovery algorithm and the monotonic
// time source (nanoseconds, the func() int64 convention used across lneto) that
// drives it. The tcp package keeps no clock of its own; nanotime is read only to
// stamp the rx/tx hooks (see [LossRecovery]). Passing loss == nil disables loss
// recovery. It should be set before the connection is opened.
func (h *Handler) SetLossRecovery(loss LossRecovery, nanotime func() int64) {
h.loss = loss
h.nanotime = nanotime
}
func (h *Handler) lossEnabled() bool { return h.loss != nil }
// NextDeadline returns the monotonic-nanosecond instant at which the connection
// must next be serviced by a transmit attempt (e.g. an RTO expiry), or 0 when
// there is no deadline or no loss recovery is configured. See [LossRecovery].
func (h *Handler) NextDeadline() int64 {
if h.loss == nil {
return 0
}
return h.loss.NextDeadline()
}
// LocalPort returns the local port of the connection. Returns 0 if the connection is closed and uninitialized.
func (h *Handler) LocalPort() uint16 {
return h.localPort
@@ -124,15 +159,24 @@ func (h *Handler) reset(localPort, remotePort uint16, iss Value) {
*h = Handler{
connid: h.connid + 1,
scb: h.scb,
bufTx: h.bufTx,
bufRx: h.bufRx,
localPort: localPort,
remotePort: remotePort,
validator: h.validator,
logger: h.logger,
closing: false,
shutdownRx: false,
// Persist configuration across reopen:
validator: h.validator,
loss: h.loss,
nanotime: h.nanotime,
logger: h.logger,
// persist memory across repoen:
bufTx: h.bufTx,
bufRx: h.bufRx,
reasm: h.reasm,
}
if h.lossEnabled() {
h.loss.Reset()
}
h.reasm.clear() // preserve metadata capacity across reopen, drop held segments.
h.bufTx.ResetOrReuse(nil, 0, iss)
h.bufRx.Reset()
}
@@ -162,15 +206,28 @@ func (h *Handler) Recv(incomingPacket []byte) error {
return lneto.ErrMismatch
}
payload := tfrm.Payload()
if !h.shutdownRx && len(payload) > h.bufRx.Free() {
return lneto.ErrBufferFull
}
segIncoming := tfrm.Segment(len(payload))
if h.scb.IncomingIsKeepalive(segIncoming) {
h.info("tcp.Handler:rx-keepalive", slog.Uint64("port", uint64(h.localPort)))
return nil
}
// Notify loss recovery of the received segment (RTT sampling, timer
// management) and let it drop the segment before processing if it asks to.
if h.lossEnabled() && !h.loss.PreRx(segIncoming, h.nanotime()).Keep {
return nil
}
// Out-of-order reassembly: buffer in-window data that arrived ahead of the
// next expected sequence number before the ControlBlock (sequential-only)
// would reject it. Buffered segments live in bufRx's free region.
if h.reasm.enabled() && h.handleOutOfOrder(segIncoming, payload) {
return nil
}
if !h.shutdownRx && len(payload) > h.bufRx.Free() {
return lneto.ErrBufferFull
}
prevState := h.scb.State()
prevUNA := h.scb.snd.UNA // Capture before Recv updates snd.UNA (RFC 6298 §5.3).
err = h.scb.Recv(segIncoming)
@@ -203,6 +260,11 @@ func (h *Handler) Recv(incomingPacket []byte) error {
return err
}
}
if segIncoming.DATALEN != 0 {
// The just-accepted in-order segment may have filled a gap; deliver any
// now-contiguous buffered segments.
h.deliverReassembled()
}
if segIncoming.Flags.HasAny(FlagACK) {
if segIncoming.ACK == prevUNA {
// scb keeping track of duplicate acks.
@@ -241,6 +303,54 @@ func (h *Handler) Recv(incomingPacket []byte) error {
return nil
}
// handleOutOfOrder buffers an in-window data segment that arrived ahead of the
// next expected sequence number and queues a duplicate ACK so the sender fast-
// retransmits the gap. It returns true when it has consumed the segment; false
// leaves the segment to the ControlBlock (in-order data, control segments, old
// or out-of-window segments, or when the reassembly buffer cannot hold it).
func (h *Handler) handleOutOfOrder(seg Segment, payload []byte) bool {
if h.shutdownRx {
// Discard mode drops payloads, which would break the ring/rcv.NXT
// lockstep reassemble relies on; do not buffer.
return false
}
if seg.DATALEN == 0 || seg.Flags.HasAny(flagctl) {
return false // only pure data segments are buffered out of order.
}
rcvNxt := h.scb.RecvNext()
if seg.SEQ == rcvNxt {
return false // in order: the ControlBlock handles it normally.
}
rcvWnd := h.scb.RecvWindow()
if !seg.SEQ.InWindow(rcvNxt, rcvWnd) || !seg.Last().InWindow(rcvNxt, rcvWnd) {
return false // old or out of window: let the ControlBlock decide.
}
if !h.reasm.store(&h.bufRx, rcvNxt, seg.SEQ, payload) {
return false // no room: fall back to ControlBlock (challenge ACK).
}
h.scb.pending[0] |= FlagACK // duplicate ACK advertises the gap at rcv.NXT.
h.trace("tcp.Handler:rx-ooo", slog.Uint64("seg.seq", uint64(seg.SEQ)), slog.Uint64("rcv.nxt", uint64(rcvNxt)))
return true
}
// deliverReassembled hands any now-contiguous out-of-order segments to the
// receive stream, advancing rcv.NXT and queuing an ACK for what was delivered.
func (h *Handler) deliverReassembled() {
if h.reasm.buffered() == 0 {
return
}
if h.shutdownRx {
// Discard mode skips the gap-filling write, so staged bytes can no
// longer be committed coherently; drop them (the peer retransmits).
h.reasm.clear()
return
}
if delivered := h.reasm.reassemble(&h.bufRx, h.scb.RecvNext()); delivered > 0 {
h.scb.rcv.NXT.UpdateForward(delivered)
h.scb.pending[0] |= FlagACK
}
}
// ShutdownRead activates local discard mode: incoming payload bytes are dropped
// (ACK/SEQ still advance normally) and Read returns [io.EOF] immediately.
// Not reversible within the lifetime of a connection.
@@ -270,7 +380,20 @@ func (h *Handler) Send(b []byte) (int, error) {
if h.IsTxOver() {
return 0, net.ErrClosed
}
var now int64
if h.lossEnabled() {
now = h.nanotime()
if h.loss.PreTx(now).RetransmitAll {
// Go-back-N retransmission directed by loss recovery: rewind the
// send sequence and transmit buffer so unacknowledged data is resent
// from snd.UNA. Done before the early short-circuit below so an
// expired RTO retransmits even with no new data queued.
h.scb.RetransmitAll()
h.bufTx.RetransmitFromUNA()
}
}
awaitingSyn := h.AwaitingSynSend()
requeueControl := h.requeueControl
buffered := h.bufTx.BufferedUnsent()
if h.scb.State() == StateCloseWait && !h.closing && buffered == 0 && !h.scb.HasPending() {
// Remote closed with no application data left to send: initiate our own close.
@@ -278,7 +401,7 @@ func (h *Handler) Send(b []byte) (int, error) {
// before Send is called, implementing the half-close per RFC 9293 §3.5.
h.closing = true
}
if !awaitingSyn && buffered == 0 && !h.closing && !h.scb.HasPending() {
if !awaitingSyn && !requeueControl && buffered == 0 && !h.closing && !h.scb.HasPending() {
// Early nop short circuit.
return 0, nil
}
@@ -301,16 +424,32 @@ func (h *Handler) Send(b []byte) (int, error) {
offset := uint8(5)
mss := uint16(len(b) - sizeHeaderTCP)
var segment Segment
if awaitingSyn {
if awaitingSyn || requeueControl && h.scb.State() == StateSynSent {
// Handling init syn segment.
segment = ClientSynSegment(h.bufTx.iss, Size(h.bufRx.Size()))
h.optcodec.PutOption16(b[sizeHeaderTCP:], OptMaxSegmentSize, mss)
offset++
if requeueControl {
h.info("tcp.Handler:requeue-syn", slog.Uint64("port", uint64(h.localPort)), slog.Uint64("rport", uint64(h.remotePort)))
}
} else if requeueControl && h.scb.State() == StateSynRcvd {
segment = Segment{
SEQ: h.scb.snd.UNA,
ACK: h.scb.rcv.NXT,
WND: Size(h.bufRx.Free()),
Flags: synack,
}
h.optcodec.PutOption16(b[sizeHeaderTCP:], OptMaxSegmentSize, mss)
offset++
h.info("tcp.Handler:requeue-synack", slog.Uint64("port", uint64(h.localPort)), slog.Uint64("rport", uint64(h.remotePort)))
} else if requeueControl {
h.requeueControl = false
return 0, nil
} else {
var ok bool
maxPayload := len(b) - sizeHeaderTCP
segment, ok = h.scb.PendingSegment(maxPayload)
segment.WND = Size(h.bufRx.Free())
segment.WND = h.recvWindow()
if !ok {
// No pending control segment or data to send. Yield.
return 0, nil
@@ -335,6 +474,10 @@ func (h *Handler) Send(b []byte) (int, error) {
} else if prevState != h.scb.State() && h.logenabled(slog.LevelInfo) {
h.info("tcp.Handler:tx-statechange", slog.Uint64("port", uint64(h.localPort)), slog.String("oldState", prevState.String()), slog.String("newState", h.scb.State().String()), slog.String("txflags", segment.Flags.String()))
}
if h.lossEnabled() {
h.loss.PostTx(segment, now)
}
h.requeueControl = false
tfrm.SetSourcePort(h.localPort)
tfrm.SetDestinationPort(h.remotePort)
tfrm.SetSegment(segment, offset)
@@ -372,6 +515,9 @@ func (h *Handler) Read(b []byte) (n int, err error) {
n, err = h.bufRx.Read(b)
}
if n > 0 {
// Reading freed receive-buffer space; deliver any contiguous
// out-of-order data that was waiting for room.
h.deliverReassembled()
h.maybeQueueWindowUpdate()
}
if n == 0 && err == nil {
@@ -394,7 +540,7 @@ func (h *Handler) Read(b []byte) (n int, err error) {
// space >= min(bufferSize/2, MSS). This applies uniformly including zero-window
// recovery — the remote uses zero-window probes until enough space opens.
func (h *Handler) maybeQueueWindowUpdate() {
currentFree := Size(h.bufRx.Free())
currentFree := h.recvWindow()
lastAdvertised := h.scb.RecvWindow()
if currentFree <= lastAdvertised {
return // Window hasn't grown.
@@ -435,7 +581,21 @@ func (h *Handler) FreeOutput() int {
// FreeInput returns the number of free bytes in the receive buffer.
func (h *Handler) FreeInput() int {
return h.bufRx.Free()
return int(h.recvWindow())
}
// recvWindow returns the receive window to advertise: free receive-buffer space
// minus the bytes already held out of order. Subtracting them prevents the
// sender from overrunning the receiver while a gap is open.
func (h *Handler) recvWindow() Size {
free := Size(h.bufRx.Free())
if !h.reasm.enabled() {
return free
}
if ooo := Size(h.reasm.bufferedBytes()); ooo < free {
return free - ooo
}
return 0
}
// AwaitingSynResponse returns true if the Handler is an active client opened with [Handler.OpenActive] and has already sent out the first SYN packet to the remote client.
@@ -443,6 +603,20 @@ func (h *Handler) AwaitingSynResponse() bool {
return h.remotePort != 0 && h.scb.State() == StateSynSent
}
// IsAwaitingControl reports whether the connection is waiting for a response to
// a control segment that can be retransmitted to advance connection state.
func (h *Handler) IsAwaitingControl() bool {
return h.AwaitingSynResponse() || h.scb.State() == StateSynRcvd
}
// RequeueControl asks the next Send call to retransmit the outstanding control
// segment, if the connection is waiting for one.
func (h *Handler) RequeueControl() {
if h.IsAwaitingControl() {
h.requeueControl = true
}
}
// AwaitingSynAck returns true if the Handler is a passive server opened with [Handler.OpenListen] and not yet received a valid SYN remote packet.
func (h *Handler) AwaitingSynAck() bool {
return h.remotePort == 0 && h.scb.State() == StateListen
+198
View File
@@ -3,6 +3,7 @@ package tcp
import (
"bytes"
"fmt"
"io"
"math/rand"
"testing"
@@ -20,6 +21,103 @@ func TestHandler(t *testing.T) {
sendDataFull(t, client, server, []byte("hello"), rawbuf[:])
}
func TestHandler_RequeueControlRetransmitsSYN(t *testing.T) {
const mtu = ethernet.MaxMTU
const maxpackets = 3
rng := rand.New(rand.NewSource(1))
client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets)
setupClientServer(t, rng, client, server)
var rawbuf [mtu]byte
n, err := client.Send(rawbuf[:])
if err != nil {
t.Fatal("client sending initial SYN:", err)
} else if n < sizeHeaderTCP {
t.Fatalf("initial SYN size=%d, want at least %d", n, sizeHeaderTCP)
}
initial := mustSegment(t, rawbuf[:n], 0)
if initial.Flags != FlagSYN {
t.Fatalf("initial flags=%s, want SYN", initial.Flags)
}
if client.State() != StateSynSent {
t.Fatalf("client state=%s, want SYN-SENT", client.State())
}
clear(rawbuf[:])
n, err = client.Send(rawbuf[:])
if err != nil {
t.Fatal("client sending before RequeueControl:", err)
} else if n != 0 {
t.Fatalf("Send before RequeueControl wrote %d bytes, want 0", n)
}
client.RequeueControl()
clear(rawbuf[:])
n, err = client.Send(rawbuf[:])
if err != nil {
t.Fatal("client retransmitting SYN:", err)
} else if n < sizeHeaderTCP {
t.Fatalf("retransmitted SYN size=%d, want at least %d", n, sizeHeaderTCP)
}
retransmit := mustSegment(t, rawbuf[:n], 0)
if retransmit.Flags != FlagSYN {
t.Fatalf("retransmit flags=%s, want SYN", retransmit.Flags)
}
if retransmit.SEQ != initial.SEQ {
t.Fatalf("retransmit SEQ=%d, want initial SEQ=%d", retransmit.SEQ, initial.SEQ)
}
if err := server.Recv(rawbuf[:n]); err != nil {
t.Fatal("server receiving retransmitted SYN:", err)
}
clear(rawbuf[:])
n, err = server.Send(rawbuf[:])
if err != nil {
t.Fatal("server sending SYN-ACK:", err)
}
segSynAck := mustSegment(t, rawbuf[:n], 0)
if segSynAck.Flags != synack {
t.Fatalf("server flags=%s, want SYN-ACK", segSynAck.Flags)
}
if err := client.Recv(rawbuf[:n]); err != nil {
t.Fatal("client receiving SYN-ACK:", err)
}
if client.State() != StateEstablished {
t.Fatalf("client state=%s, want ESTABLISHED", client.State())
}
clear(rawbuf[:])
n, err = client.Send(rawbuf[:]) // final ACK.
if err != nil {
t.Fatal("client sending final ACK:", err)
}
if ack := mustSegment(t, rawbuf[:n], 0); ack.Flags != FlagACK {
t.Fatalf("client final flags=%s, want ACK", ack.Flags)
}
if err := server.Recv(rawbuf[:n]); err != nil {
t.Fatal("server receiving final ACK:", err)
}
client.RequeueControl()
clear(rawbuf[:])
n, err = client.Send(rawbuf[:])
if err != nil {
t.Fatal("client sending after establishment:", err)
} else if n != 0 {
seg := mustSegment(t, rawbuf[:n], 0)
t.Fatalf("Send after establishment wrote %d bytes (%s), want 0", n, seg.Flags)
}
}
func mustSegment(t *testing.T, b []byte, payloadLen int) Segment {
t.Helper()
frame, err := NewFrame(b)
if err != nil {
t.Fatal("parse TCP frame:", err)
}
return frame.Segment(payloadLen)
}
func sendDataFull(t *testing.T, client, server *Handler, data, packetBuf []byte) {
n, err := client.Write(data)
if err != nil {
@@ -1390,3 +1488,103 @@ func TestRetransmit_CumulativeACK_NoSpurious(t *testing.T) {
t.Fatalf("spurious retransmission after cumulative ACK: SEQ=%d len=%d (issue #57)", seg.SEQ, seg.DATALEN)
}
}
// emitClientData writes payload to the client and emits it as one data packet,
// returning a copy of the wire bytes (the caller controls delivery order).
func emitClientData(t *testing.T, client *Handler, buf []byte, payload string) []byte {
t.Helper()
if _, err := client.Write([]byte(payload)); err != nil {
t.Fatal("client write:", err)
}
clear(buf)
n, err := client.Send(buf)
if err != nil {
t.Fatal("client send:", err)
}
if n <= sizeHeaderTCP {
t.Fatal("expected a data segment, got header-only")
}
return append([]byte(nil), buf[:n]...)
}
// TestHandler_OutOfOrderReassembly drives the full out-of-order path: a later
// segment delivered before the gap-filling one is staged, then delivered
// contiguously once the gap arrives, without go-back-N.
func TestHandler_OutOfOrderReassembly(t *testing.T) {
const mtu = ethernet.MaxMTU
const maxpackets = 4
rng := rand.New(rand.NewSource(99))
client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets)
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:])
pkt1 := emitClientData(t, client, buf[:], "AAAA") // seq S, covers S..S+4.
pkt2 := emitClientData(t, client, buf[:], "BBBB") // seq S+4, covers S+4..S+8.
full := server.SizeInput()
// Deliver the second segment first: accepted, buffered, not yet readable.
if err := server.Recv(pkt2); err != nil {
t.Fatalf("out-of-order segment must be accepted, got: %v", err)
}
if server.BufferedInput() != 0 {
t.Fatalf("OOO data must not be readable yet, buffered=%d", server.BufferedInput())
}
// Advertised window shrinks by the held bytes so the peer cannot overrun.
if got := server.FreeInput(); got != full-4 {
t.Fatalf("FreeInput=%d, want %d (window reduced by held OOO bytes)", got, full-4)
}
// Deliver the gap-filling first segment: both become contiguous.
if err := server.Recv(pkt1); err != nil {
t.Fatalf("gap-filling segment: %v", err)
}
if server.BufferedInput() != 8 {
t.Fatalf("buffered=%d, want 8 after gap fill", server.BufferedInput())
}
if got := server.FreeInput(); got != full-8 {
t.Fatalf("FreeInput=%d, want %d after delivery", got, full-8)
}
var rd [16]byte
n, err := server.Read(rd[:])
if err != nil {
t.Fatal("server read:", err)
}
if string(rd[:n]) != "AAAABBBB" {
t.Fatalf("reassembled %q, want AAAABBBB", rd[:n])
}
}
// TestHandler_OutOfOrderDiscardedAfterShutdownRead verifies staged segments are
// dropped, not delivered, when the read side is shut down before the gap fills.
func TestHandler_OutOfOrderDiscardedAfterShutdownRead(t *testing.T) {
const mtu = ethernet.MaxMTU
const maxpackets = 4
rng := rand.New(rand.NewSource(100))
client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets)
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:])
pkt1 := emitClientData(t, client, buf[:], "AAAA")
pkt2 := emitClientData(t, client, buf[:], "BBBB")
if err := server.Recv(pkt2); err != nil { // buffer out of order.
t.Fatalf("OOO segment: %v", err)
}
server.ShutdownRead() // application done reading; staged data must be dropped.
if err := server.Recv(pkt1); err != nil && !IsDroppedErr(err) {
t.Fatalf("gap-filling segment after shutdown: %v", err)
}
var rd [16]byte
n, err := server.Read(rd[:])
if n != 0 || err != io.EOF {
t.Fatalf("read after ShutdownRead = %d,%v want 0,EOF", n, err)
}
if server.BufferedInput() != 0 {
t.Fatalf("discard mode must hold no data, buffered=%d", server.BufferedInput())
}
}
+83
View File
@@ -0,0 +1,83 @@
package tcp
// LossRecovery abstracts TCP packet-loss recovery: RTO, congestion control and
// any similar algorithm that observes segment traffic and steers the
// connection's transmit behaviour. As far as the tcp package is concerned these
// are all the same thing — packet-loss recovery algorithms — so they share one
// interface (see discussion #157).
//
// The tcp package stays free of any time source: the current monotonic time in
// nanoseconds (the func() int64 convention used across lneto) is passed in at
// each hook boundary. It originates from [ConnConfig.Nanotime] and satisfies the
// "WHEN was this segment rx/tx'd" requirement without a clock living inside the
// state machine, which also keeps implementations deterministic for testing
// (see issue #140).
//
// The interface is intentionally free of errors: an implementation handles or
// reports its own errors rather than propagating them into lneto internals.
//
// Introspection (smoothed RTT, current window, ...) is deliberately left off the
// interface; expose it on the concrete implementation the caller constructs and
// hands to [ConnConfig].
type LossRecovery interface {
// Reset returns the implementation to its initial, pre-connection state. It
// is invoked whenever the connection is (re)opened or aborted so a single
// LossRecovery value can be reused across the lifetime of connection reuse
// (see discussion #115).
Reset()
// NextDeadline returns the monotonic-nanosecond instant at which the
// connection must next be serviced by a transmit attempt — typically the RTO
// expiry. A return of 0 means there is no pending deadline. It replaces a
// poll/atomic-flag scheme with a deadline the caller's event loop can
// schedule against.
NextDeadline() int64
// PreRx is called for every segment received on the TCP port before the
// state machine processes it, with the monotonic time the segment arrived. It
// returns whether the segment should be kept (processed) or dropped.
PreRx(incoming Segment, now int64) RxDirective
// PreTx is called on entering the transmit path (Encapsulate), before a
// segment is built, with the current monotonic time. Its directive tells the
// connection whether to retransmit unacknowledged data, rewind the send
// pointer, or hold back new data.
PreTx(now int64) TxDirective
// PostTx is called on leaving the transmit path with the segment that was
// actually emitted and the monotonic time it was sent. This is where segment
// timing (for RTT sampling and the retransmission timer) is recorded.
PostTx(outgoing Segment, now int64)
}
// TxDirective is returned by [LossRecovery.PreTx] to steer the transmit path.
// The zero value directs the connection to proceed normally (send new data if
// available, no retransmission).
type TxDirective struct {
// RewindNXT is the number of sequence-space octets to rewind snd.NXT by
// before transmitting, for partial (e.g. selective) retransmission. Zero
// means no rewind. It is independent of Retransmit, which rewinds fully to
// snd.UNA.
// RewindNXT uint32
// RetransmitAll requests go-back-N retransmission: the connection rewinds
// snd.NXT to snd.UNA and resends unacknowledged data from the oldest
// sequence number.
RetransmitAll bool
// HoldNew pauses transmission of new data (for example when the congestion
// window is exhausted). Retransmissions already directed by this same
// directive still proceed.
// HoldNew bool
}
// RxDirective is returned by [LossRecovery.PreRx].
//
// NOTE: its shape is the minimum viable contract — it mirrors the original
// PreRx "keep" boolean from discussion #157 — and is the one element of the
// interface not yet fully settled there. It is a struct (rather than a bare
// bool) so fields can be added without breaking implementations.
type RxDirective struct {
// Keep reports whether the received segment should be handed to the state
// machine. A false value drops the segment before it is processed.
Keep bool
}
+262
View File
@@ -0,0 +1,262 @@
package tcp
import (
"math/rand"
"testing"
"github.com/soypat/lneto/ethernet"
)
// recordingLoss is a test LossRecovery that records every hook invocation and
// lets the test steer the directives returned to the Handler. It is the
// interface counterpart driven by the Handler under test.
type recordingLoss struct {
resets int
preRx []hookCall
preTx []int64
postTx []hookCall
deadline int64 // value NextDeadline reports back.
// Directives handed back to the Handler.
keep bool // PreRx result. Default true (see newRecordingLoss).
tx TxDirective // PreTx result.
}
type hookCall struct {
seg Segment
now int64
}
func newRecordingLoss() *recordingLoss { return &recordingLoss{keep: true} }
var _ LossRecovery = (*recordingLoss)(nil)
func (l *recordingLoss) Reset() { l.resets++ }
func (l *recordingLoss) NextDeadline() int64 { return l.deadline }
func (l *recordingLoss) PreRx(incoming Segment, now int64) RxDirective {
l.preRx = append(l.preRx, hookCall{seg: incoming, now: now})
return RxDirective{Keep: l.keep}
}
func (l *recordingLoss) PreTx(now int64) TxDirective {
l.preTx = append(l.preTx, now)
return l.tx
}
func (l *recordingLoss) PostTx(outgoing Segment, now int64) {
l.postTx = append(l.postTx, hookCall{seg: outgoing, now: now})
}
// TestLossRecovery_DisabledByDefault verifies the Handler runs normally with no
// loss recovery installed: NextDeadline reports no deadline and the transmit/
// receive paths never touch a nil LossRecovery.
func TestLossRecovery_DisabledByDefault(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(1))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
setupClientServer(t, rng, client, server)
if d := client.NextDeadline(); d != 0 {
t.Fatalf("NextDeadline with no loss recovery = %d, want 0", d)
}
var buf [mtu]byte
establish(t, client, server, buf[:]) // must not panic on nil loss recovery.
}
// TestLossRecovery_HooksInvoked verifies the Handler drives the full hook
// contract across a handshake: Reset on open, PreTx+PostTx on every transmit,
// PreRx on every receive, each stamped with the configured monotonic clock.
func TestLossRecovery_HooksInvoked(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(2))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
loss := newRecordingLoss()
const clockNow = 1_000_000
client.SetLossRecovery(loss, func() int64 { return clockNow })
setupClientServer(t, rng, client, server) // OpenActive → reset → Reset().
if loss.resets == 0 {
t.Fatal("Reset not called on open")
}
var buf [mtu]byte
establish(t, client, server, buf[:])
// Client emitted SYN and the final ACK: both paths must have hit PreTx/PostTx.
if len(loss.preTx) == 0 {
t.Fatal("PreTx never called on transmit")
}
if len(loss.postTx) == 0 {
t.Fatal("PostTx never called on transmit")
}
if len(loss.preTx) != len(loss.postTx) {
t.Fatalf("PreTx calls=%d, PostTx calls=%d, want equal", len(loss.preTx), len(loss.postTx))
}
// Client received the SYN-ACK: PreRx must have seen it.
if len(loss.preRx) == 0 {
t.Fatal("PreRx never called on receive")
}
// The Handler holds no clock: every hook must be stamped from the supplied
// nanotime source.
for i, c := range loss.postTx {
if c.now != clockNow {
t.Fatalf("PostTx[%d].now = %d, want clock %d", i, c.now, clockNow)
}
}
for i, now := range loss.preTx {
if now != clockNow {
t.Fatalf("PreTx[%d].now = %d, want clock %d", i, now, clockNow)
}
}
for i, c := range loss.preRx {
if c.now != clockNow {
t.Fatalf("PreRx[%d].now = %d, want clock %d", i, c.now, clockNow)
}
}
// PostTx receives the segment actually emitted: the first is the SYN.
if !loss.postTx[0].seg.Flags.HasAny(FlagSYN) {
t.Fatalf("first PostTx segment flags=%s, want SYN", loss.postTx[0].seg.Flags)
}
}
// TestLossRecovery_NextDeadlineDelegates verifies NextDeadline is forwarded to
// the installed LossRecovery unchanged.
func TestLossRecovery_NextDeadlineDelegates(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(3))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
loss := newRecordingLoss()
loss.deadline = 4242
client.SetLossRecovery(loss, func() int64 { return 1 })
setupClientServer(t, rng, client, server)
if d := client.NextDeadline(); d != 4242 {
t.Fatalf("NextDeadline = %d, want delegated 4242", d)
}
}
// TestLossRecovery_PreRxDropsSegment verifies a PreRx directive of Keep=false
// drops the segment before the state machine sees it: the payload is not
// buffered and connection state is untouched.
func TestLossRecovery_PreRxDropsSegment(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(4))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
loss := newRecordingLoss()
server.SetLossRecovery(loss, func() int64 { return 1 })
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:]) // keep=true so handshake completes.
// Now start dropping everything the server receives.
loss.keep = false
preRxBefore := len(loss.preRx)
data := []byte("dropme")
if _, err := client.Write(data); err != nil {
t.Fatal("client write:", err)
}
clear(buf[:])
n, err := client.Send(buf[:])
if err != nil {
t.Fatal("client send:", err)
}
if err := server.Recv(buf[:n]); err != nil {
t.Fatalf("dropped segment must return nil, got %v", err)
}
if len(loss.preRx) != preRxBefore+1 {
t.Fatalf("PreRx calls=%d, want %d (segment must reach PreRx)", len(loss.preRx), preRxBefore+1)
}
if server.BufferedInput() != 0 {
t.Fatalf("dropped segment must not be buffered, got %d bytes", server.BufferedInput())
}
if server.State() != StateEstablished {
t.Fatalf("dropped segment must not change state, got %s", server.State())
}
}
// TestLossRecovery_PreTxRetransmitAll verifies a PreTx directive of
// RetransmitAll drives go-back-N: the Handler rewinds and re-emits already-sent,
// unacknowledged data from snd.UNA on the next transmit.
func TestLossRecovery_PreTxRetransmitAll(t *testing.T) {
const mtu = ethernet.MaxMTU
rng := rand.New(rand.NewSource(5))
client, server := newHandler(t, mtu, 3), newHandler(t, mtu, 3)
loss := newRecordingLoss()
client.SetLossRecovery(loss, func() int64 { return 1 })
setupClientServer(t, rng, client, server)
var buf [mtu]byte
establish(t, client, server, buf[:])
// Emit one data segment; server never ACKs, so it stays unacknowledged.
data := []byte("payload")
if _, err := client.Write(data); err != nil {
t.Fatal("client write:", err)
}
clear(buf[:])
n, err := client.Send(buf[:])
if err != nil {
t.Fatal("client send data:", err)
}
if n <= sizeHeaderTCP {
t.Fatal("expected data segment")
}
firstSeg := mustSegment(t, buf[:n], n-sizeHeaderTCP)
// Direct go-back-N on the next transmit.
loss.tx = TxDirective{RetransmitAll: true}
clear(buf[:])
n, err = client.Send(buf[:])
if err != nil {
t.Fatal("client send retransmit:", err)
}
if n <= sizeHeaderTCP {
t.Fatal("expected retransmitted data segment")
}
rtSeg := mustSegment(t, buf[:n], n-sizeHeaderTCP)
if rtSeg.SEQ != firstSeg.SEQ {
t.Fatalf("retransmit SEQ=%d, want original UNA SEQ=%d (go-back-N)", rtSeg.SEQ, firstSeg.SEQ)
}
if rtSeg.DATALEN != firstSeg.DATALEN {
t.Fatalf("retransmit DATALEN=%d, want %d", rtSeg.DATALEN, firstSeg.DATALEN)
}
}
// TestLossRecovery_ResetOnReopen verifies Reset fires on every (re)open and on
// Abort, so a single LossRecovery value can be reused across connection reuse.
func TestLossRecovery_ResetOnReopen(t *testing.T) {
const mtu = ethernet.MaxMTU
client := newHandler(t, mtu, 3)
loss := newRecordingLoss()
client.SetLossRecovery(loss, func() int64 { return 1 })
if err := client.OpenActive(1234, 5678, 0); err != nil {
t.Fatal("open 1:", err)
}
afterOpen := loss.resets
if afterOpen == 0 {
t.Fatal("Reset not called on first open")
}
client.Abort()
if loss.resets <= afterOpen {
t.Fatalf("Reset not called on Abort: resets=%d, want >%d", loss.resets, afterOpen)
}
afterAbort := loss.resets
if err := client.OpenActive(1234, 5678, 0); err != nil {
t.Fatal("open 2:", err)
}
if loss.resets <= afterAbort {
t.Fatalf("Reset not called on reopen: resets=%d, want >%d", loss.resets, afterAbort)
}
}
+135
View File
@@ -0,0 +1,135 @@
package tcp
import "github.com/soypat/lneto/internal"
// maxReasmSegments bounds how many distinct out-of-order segments may be held.
// It caps only fixed metadata; payload bytes live in the receive ring, bounded
// by its free space. Independent of the transmit queue depth.
const maxReasmSegments = 8
// reassembly holds in-window TCP segments that arrived ahead of the next
// expected sequence number, so that once the gap is filled the buffered tail is
// delivered without go-back-N. Payloads are staged in the free region of the
// Handler receive ring (see [internal.Ring.PeekWrite]); only fixed, reused
// metadata lives here, so the data path allocates nothing.
//
// held is kept ordered by ascending sequence number (oldest to newest), which
// lets [reassembly.store] locate insertions and overlaps by neighbour and lets
// [reassembly.reassemble] deliver a contiguous prefix and truncate it in one
// pass.
type reassembly struct {
held []reasmSeg
}
// reasmSeg records a held segment by sequence number and payload length. No
// buffer offset is kept: the ring write pointer advances in lockstep with
// rcv.NXT, so the staged bytes are always where seq implies (see
// [reassembly.reassemble]).
type reasmSeg struct {
seq Value
n int
}
// reset (re)configures bounded metadata for up to maxSegs held segments, or
// disables reassembly when maxSegs is not positive. Held state is cleared;
// metadata capacity persists across connection reopens.
func (r *reassembly) reset(maxSegs int) {
if maxSegs <= 0 {
r.held = nil
return
}
internal.SliceReuse(&r.held, maxSegs)
}
// clear drops all held segments without changing configuration.
func (r *reassembly) clear() { r.held = r.held[:0] }
// enabled reports whether out-of-order buffering is configured.
func (r *reassembly) enabled() bool { return cap(r.held) > 0 }
// buffered reports the number of out-of-order segments currently held.
func (r *reassembly) buffered() int { return len(r.held) }
// bufferedBytes reports the total payload bytes currently held out of order.
// The receiver subtracts these from its advertised window so the sender cannot
// overrun the space the held segments already consume.
func (r *reassembly) bufferedBytes() int {
n := 0
for i := range r.held {
n += r.held[i].n
}
return n
}
// store stages payload at the offset it will occupy in rx once the gap from
// rcvNxt fills, keeping held ordered by seq. It returns true when held,
// including when already held (storing is idempotent), and false when disabled,
// the payload is empty, metadata is full, it does not fit rx's free region, or
// it overlaps a held segment.
func (r *reassembly) store(rx *internal.Ring, rcvNxt, seq Value, payload []byte) bool {
if !r.enabled() || len(payload) == 0 || len(r.held) >= cap(r.held) {
return false
}
gap := int(Sizeof(rcvNxt, seq))
// Early free-space bail before the ordered-insert scan; strictly cautious,
// as PeekWrite re-checks this below.
if gap+len(payload) > rx.Free() {
return false
}
// Find the insertion point that keeps held ordered by ascending seq.
end := Add(seq, Size(len(payload)))
i := 0
for i < len(r.held) && r.held[i].seq.LessThan(seq) {
i++
}
if i > 0 { // Overlaps the predecessor?
if prev := r.held[i-1]; seq.LessThan(Add(prev.seq, Size(prev.n))) {
return false
}
}
if i < len(r.held) { // Duplicate, or overlaps the successor?
if next := r.held[i]; next.seq == seq {
return true // already buffered; idempotent.
} else if next.seq.LessThan(end) {
return false
}
}
if !rx.PeekWrite(payload, gap) {
return false
}
r.held = append(r.held, reasmSeg{})
copy(r.held[i+1:], r.held[i:])
r.held[i] = reasmSeg{seq: seq, n: len(payload)}
return true
}
// reassemble delivers held segments contiguous with nxt by committing their
// staged bytes to rx, and drops any beginning before nxt (stale, or overwritten
// by the in-order write that advanced nxt). Because held is ordered, it walks a
// leading prefix and truncates once. It returns the bytes delivered; the caller
// advances rcv.NXT and ACKs. Delivery stops at the first gap, or if rx is full
// (the remainder is delivered on a later call).
func (r *reassembly) reassemble(rx *internal.Ring, nxt Value) Size {
var delivered Size
i := 0
for i < len(r.held) {
seg := r.held[i]
switch {
case seg.seq == nxt:
if rx.Commit(seg.n) != nil {
r.held = append(r.held[:0], r.held[i:]...)
return delivered
}
nxt = Add(nxt, Size(seg.n))
delivered += Size(seg.n)
i++
case seg.seq.LessThan(nxt):
i++ // stale/overwritten: drop.
default:
r.held = append(r.held[:0], r.held[i:]...)
return delivered // gap before the next segment.
}
}
r.held = r.held[:0]
return delivered
}
+168
View File
@@ -0,0 +1,168 @@
package tcp
import (
"bytes"
"testing"
"github.com/soypat/lneto/internal"
)
func TestReassemblyDisabledByDefault(t *testing.T) {
var r reassembly
if r.enabled() {
t.Fatal("zero-value reassembly must be disabled")
}
var rx internal.Ring
if r.store(&rx, 100, 100, []byte("x")) {
t.Error("store must fail when disabled")
}
}
func TestReassemblyStoreAndReassemble(t *testing.T) {
var r reassembly
r.reset(4)
rx := internal.Ring{Buf: make([]byte, 32)}
if !r.enabled() {
t.Fatal("reassembly should be enabled after reset")
}
// Buffer two out-of-order segments (gap at seq 100), stored newest-first to
// prove store keeps held ordered by seq.
if !r.store(&rx, 100, 108, []byte("CCC")) { // covers 108..111
t.Fatal("store 108 failed")
}
if !r.store(&rx, 100, 104, []byte("BBBB")) { // covers 104..108
t.Fatal("store 104 failed")
}
if r.buffered() != 2 {
t.Fatalf("buffered=%d, want 2", r.buffered())
}
if r.held[0].seq != 104 || r.held[1].seq != 108 {
t.Fatalf("held not ordered by seq: %+v", r.held)
}
// A gap at 100 blocks delivery entirely.
if got := r.reassemble(&rx, 100); got != 0 {
t.Fatalf("reassemble(100)=%d, want 0 with a gap at 100", got)
}
// Once 100..104 is delivered in order, both held segments become contiguous.
if _, err := rx.Write([]byte("AAAA")); err != nil {
t.Fatal("gap write:", err)
}
if got := r.reassemble(&rx, 104); got != 7 {
t.Fatalf("reassemble(104)=%d, want 7", got)
}
if r.buffered() != 0 {
t.Errorf("buffered=%d, want 0 after full delivery", r.buffered())
}
out := make([]byte, 16)
n, _ := rx.Read(out)
if !bytes.Equal(out[:n], []byte("AAAABBBBCCC")) {
t.Fatalf("reassembled %q, want AAAABBBBCCC", out[:n])
}
}
func TestReassemblyDedup(t *testing.T) {
var r reassembly
r.reset(4)
rx := internal.Ring{Buf: make([]byte, 32)}
if !r.store(&rx, 100, 104, []byte("AAA")) {
t.Fatal("first store failed")
}
if !r.store(&rx, 100, 104, []byte("AAA")) {
t.Error("duplicate store of same seq should be idempotent true")
}
if r.buffered() != 1 {
t.Errorf("buffered=%d, want 1 (duplicate must not add a segment)", r.buffered())
}
}
func TestReassemblyFull(t *testing.T) {
var r reassembly
r.reset(2) // only 2 slots.
rx := internal.Ring{Buf: make([]byte, 32)}
if !r.store(&rx, 100, 104, []byte("a")) || !r.store(&rx, 100, 108, []byte("b")) {
t.Fatal("filling slots failed")
}
if r.store(&rx, 100, 116, []byte("c")) {
t.Error("store must fail when all slots are occupied")
}
}
func TestReassemblyOversizedRejected(t *testing.T) {
var r reassembly
r.reset(2)
rx := internal.Ring{Buf: make([]byte, 4)}
if r.store(&rx, 100, 104, []byte("toolong")) {
t.Error("payload larger than free receive space must be rejected")
}
}
func TestReassemblyOverlapRejected(t *testing.T) {
var r reassembly
r.reset(4)
rx := internal.Ring{Buf: make([]byte, 32)}
if !r.store(&rx, 100, 104, []byte("BBBB")) { // covers 104..108
t.Fatal("store 104 failed")
}
// Segments overlapping the held 104..108 region must be rejected.
if r.store(&rx, 100, 106, []byte("XX")) { // 106..108 overlaps its successor
t.Error("overlapping store must be rejected")
}
if r.store(&rx, 100, 102, []byte("YYYY")) { // 102..106 overlaps its predecessor
t.Error("overlapping store must be rejected")
}
if r.buffered() != 1 {
t.Errorf("buffered=%d, want 1 (overlaps must not be stored)", r.buffered())
}
}
// TestReassembleDropsStale checks segments beginning before nxt are dropped, not
// delivered (their staged bytes may have been overwritten by the in-order write).
func TestReassembleDropsStale(t *testing.T) {
var r reassembly
r.reset(4)
rx := internal.Ring{Buf: make([]byte, 32)}
r.store(&rx, 100, 104, []byte("BBBB")) // covers 104..108
r.store(&rx, 100, 112, []byte("DDDD")) // covers 112..116
// rcv.NXT advanced to 106, partway into the first held segment, with a gap
// before the second: the stale segment is dropped and nothing is delivered.
if got := r.reassemble(&rx, 106); got != 0 {
t.Errorf("reassemble(106)=%d, want 0", got)
}
if r.buffered() != 1 || r.held[0].seq != 112 {
t.Errorf("held=%+v, want only seq 112", r.held)
}
}
func TestReassemblyResetDisables(t *testing.T) {
var r reassembly
rx := internal.Ring{Buf: make([]byte, 16)}
r.reset(4)
r.store(&rx, 100, 104, []byte("a"))
r.reset(0)
if r.enabled() {
t.Error("reset(0) must disable reassembly")
}
if r.buffered() != 0 {
t.Error("reset must clear held segments")
}
}
// TestReassembly_noAllocs verifies the data-path operations allocate nothing
// once configured (metadata is bounded at reset, payloads reuse the ring).
func TestReassembly_noAllocs(t *testing.T) {
var r reassembly
r.reset(4)
rx := internal.Ring{Buf: make([]byte, 32)}
seg := []byte("DATA")
allocs := testing.AllocsPerRun(100, func() {
r.clear()
rx.Reset()
r.store(&rx, 100, 108, seg) // buffer out of order.
r.store(&rx, 100, 104, seg) // buffer out of order.
_ = r.bufferedBytes()
_ = r.reassemble(&rx, 112)
})
if allocs != 0 {
t.Errorf("reassembly data path must not allocate, got %v allocs/op", allocs)
}
}
+215
View File
@@ -0,0 +1,215 @@
package tcp
import "time"
// RFC 6298 retransmission-timeout (RTO) parameters. The algorithm keeps a
// single retransmission timer per connection (RFC 6298 §5): the timer is
// (re)started whenever new data is acknowledged while data remains in flight,
// stopped when all data is acknowledged, and on expiry the oldest unacknowledged
// segment is retransmitted and the RTO is doubled (exponential backoff, §5.5).
const (
// rtoInitial is the RTO used before the first RTT measurement (RFC 6298 §2.1).
rtoInitial = time.Second
// rtoMin clamps the lower bound of the RTO. RFC 6298 §2.4 recommends a
// minimum of 1s, but that is punishing on the low-latency links lneto
// targets; like Linux we use a smaller floor so recovery on LAN/embedded
// links is timely.
rtoMin = 200 * time.Millisecond
// rtoMax clamps the upper bound across exponential backoff (RFC 6298 §5.5
// permits a maximum of at least 60s).
rtoMax = 60 * time.Second
// rttGainShift (alpha = 1/8) and rttvarGainShift (beta = 1/4) are the
// smoothing gains of RFC 6298 §2.3, applied as integer shifts.
rttGainShift = 3 // alpha = 1/8
rttvarGainShift = 2 // beta = 1/4
// rttvarK is the RTTVAR multiplier in RTO = SRTT + K*RTTVAR (RFC 6298 §2.3).
rttvarK = 4
// backoffMax caps the exponential-backoff doublings so RTO arithmetic cannot
// overflow and a wedged connection keeps probing at rtoMax.
backoffMax = 12
)
// RTO implements the RFC 6298 round-trip-time estimator and the single
// retransmission timer as a [LossRecovery]. Construct it with new(RTO) and hand
// it to [ConnConfig.LossRecovery]; the connection calls [RTO.Reset] on open, so
// the zero value is ready to use.
//
// RTO is a pure, reactive state machine: it observes the segments a connection
// sends and receives (via the LossRecovery hooks) and the monotonic time handed
// in at each hook, and from those alone derives RTT estimates and retransmission
// decisions. It holds no clock and allocates nothing, which keeps it
// deterministic for unit testing (see issue #140).
//
// RTO tracks its own shadow of the send sequence space purely from the segments
// it observes: [RTO.PostTx] advances the highest sequence sent and [RTO.PreRx]
// advances the highest sequence acknowledged. This is what lets it manage the
// timer (RFC 6298 §5.2/§5.3) without reaching into the tcp state machine, and it
// is also how retransmissions are distinguished for Karn's algorithm — a segment
// whose sequence space is not beyond the shadow snd.NXT is a retransmission and
// is never RTT-sampled.
type RTO struct {
srtt time.Duration // smoothed round-trip time (SRTT).
rttvar time.Duration // round-trip-time variation (RTTVAR).
rto time.Duration // current retransmission timeout.
haveRTT bool // false until the first RTT sample is taken.
// Shadow of the send sequence space, derived from observed segments.
haveSeq bool // false until the first data segment is observed.
sndUNA Value // highest acknowledged sequence number seen on the wire.
sndNXT Value // one past the highest sequence number sent.
// RTT sampling state (Karn's algorithm, RFC 6298 §3): at most one segment is
// timed at a time and retransmitted segments are never sampled.
timing bool
timedSeq Value // ACK at or beyond this value completes the sample.
timedAt int64 // send time (monotonic ns) of the timed segment.
// Retransmission timer state.
running bool
deadline int64 // time (monotonic ns) at which the timer expires.
backoff uint8 // consecutive timeouts, for exponential backoff.
}
var _ LossRecovery = (*RTO)(nil)
// Reset returns the estimator to its pre-connection state with the initial RTO.
// It implements [LossRecovery] and is called when the connection opens or aborts
// so the estimator can be reused across connection reuse.
func (r *RTO) Reset() { *r = RTO{rto: rtoInitial} }
// SmoothedRTT returns the current smoothed round-trip time (SRTT), or zero
// before the first RTT measurement. It is concrete-type introspection and is
// intentionally not part of [LossRecovery].
func (r *RTO) SmoothedRTT() time.Duration { return r.srtt }
// CurrentRTO returns the timeout currently in effect, clamped to [rtoMin, rtoMax].
func (r *RTO) CurrentRTO() time.Duration {
rto := r.rto
if rto < rtoMin {
rto = rtoMin
} else if rto > rtoMax {
rto = rtoMax
}
return rto
}
// Running reports whether the retransmission timer is currently armed.
func (r *RTO) Running() bool { return r.running }
// NextDeadline returns the monotonic-nanosecond instant at which the timer
// expires, or 0 when it is not armed. It implements [LossRecovery].
func (r *RTO) NextDeadline() int64 {
if !r.running {
return 0
}
return r.deadline
}
// PreRx samples the RTT and manages the retransmission timer from a received
// segment (RFC 6298 §5.2/§5.3). It implements [LossRecovery] and always keeps
// the segment (the estimator never drops traffic).
func (r *RTO) PreRx(incoming Segment, now int64) RxDirective {
if !r.haveSeq || !incoming.Flags.HasAny(FlagACK) {
return RxDirective{Keep: true}
}
ack := incoming.ACK
if r.timing && !ack.LessThan(r.timedSeq) {
// ACK covers the timed segment: take the RTT sample (§4). A valid
// measurement collapses the backoff (§5.7).
r.updateRTT(time.Duration(now - r.timedAt))
r.timing = false
r.backoff = 0
}
if r.sndUNA.LessThan(ack) && !r.sndNXT.LessThan(ack) {
// ACK advances snd.UNA and does not exceed what we have sent.
r.sndUNA = ack
}
if r.sndUNA == r.sndNXT {
r.running = false // §5.3: all outstanding data acknowledged.
} else {
// §5.3: new (but not all) data acknowledged — restart the timer.
r.running = true
r.deadline = now + int64(r.CurrentRTO())
}
return RxDirective{Keep: true}
}
// PreTx reports whether the retransmission timer has expired and, if so, applies
// the RFC 6298 §5.4–§5.6 timeout response — discard the outstanding RTT sample
// (Karn), back the RTO off exponentially and restart the timer — returning a
// directive that asks the connection to retransmit from snd.UNA (go-back-N). It
// implements [LossRecovery].
func (r *RTO) PreTx(now int64) TxDirective {
if !r.running || now < r.deadline || r.sndUNA == r.sndNXT {
return TxDirective{}
}
r.timing = false // §5.4: do not sample a retransmitted segment.
if r.backoff < backoffMax {
r.backoff++
r.rto = min(r.CurrentRTO()*2, rtoMax) // §5.5: RTO = RTO * 2.
}
r.running = true
r.deadline = now + int64(r.CurrentRTO())
return TxDirective{RetransmitAll: true}
}
// PostTx records an emitted segment: it advances the shadow send sequence,
// begins timing newly transmitted data (RFC 6298 §3) and arms the timer (§5.1).
// Segments that do not extend the send sequence are retransmissions and are
// never RTT-sampled (Karn's algorithm). Control-only segments (no data) are
// ignored. It implements [LossRecovery].
func (r *RTO) PostTx(outgoing Segment, now int64) {
if outgoing.DATALEN == 0 {
return // only data segments are timed / arm the RTO.
}
segStart := outgoing.SEQ
segEnd := segStart + Value(outgoing.LEN())
if !r.haveSeq {
r.haveSeq = true
r.sndUNA = segStart
r.sndNXT = segStart
}
if !r.sndNXT.LessThan(segEnd) {
// Segment does not extend the send sequence: it is a retransmission.
// Discard any outstanding RTT sample per Karn's algorithm. The timer was
// already (re)armed by PreTx on the timeout that triggered this resend.
r.timing = false
return
}
r.sndNXT = segEnd
if !r.timing {
r.timing = true
r.timedSeq = segEnd
r.timedAt = now
}
if !r.running {
r.running = true
r.deadline = now + int64(r.CurrentRTO())
}
}
// updateRTT folds a round-trip measurement into SRTT/RTTVAR/RTO using the
// integer-shift form of RFC 6298 §2.2/§2.3.
func (r *RTO) updateRTT(sample time.Duration) {
if sample <= 0 {
return
}
if !r.haveRTT {
// First measurement (RFC 6298 §2.2).
r.srtt = sample
r.rttvar = sample / 2
r.haveRTT = true
} else {
// Subsequent measurements (RFC 6298 §2.3):
// RTTVAR = (1-beta)*RTTVAR + beta*|SRTT-R|
// SRTT = (1-alpha)*SRTT + alpha*R
diff := r.srtt - sample
if diff < 0 {
diff = -diff
}
r.rttvar += (diff - r.rttvar) >> rttvarGainShift
r.srtt += (sample - r.srtt) >> rttGainShift
}
r.rto = r.srtt + rttvarK*r.rttvar
}
+206
View File
@@ -0,0 +1,206 @@
package tcp
import (
"testing"
"time"
)
const rtoMs = int64(time.Millisecond)
// rtoDataSeg builds a data segment of datalen octets starting at seq.
func rtoDataSeg(seq uint32, datalen int) Segment {
return Segment{SEQ: Value(seq), DATALEN: Size(datalen), Flags: FlagPSH | FlagACK}
}
// rtoAckSeg builds a bare ACK acknowledging up to ack.
func rtoAckSeg(ack uint32) Segment {
return Segment{ACK: Value(ack), Flags: FlagACK}
}
func newRTO() *RTO {
var r RTO
r.Reset()
return &r
}
func TestRTO_Reset(t *testing.T) {
var r RTO
r.Reset()
if r.rto != rtoInitial {
t.Errorf("initial rto=%v, want %v", r.rto, rtoInitial)
}
if r.CurrentRTO() != rtoInitial {
t.Errorf("CurrentRTO=%v, want %v", r.CurrentRTO(), rtoInitial)
}
if r.haveRTT {
t.Error("haveRTT should be false before first sample")
}
if r.Running() || r.NextDeadline() != 0 {
t.Error("timer must be disarmed after Reset")
}
}
// TestRTO_ArmOnSendSampleOnAck sends data, verifies the timer arms, then acks it
// and verifies an RTT sample is taken and the timer stops once all data is acked.
func TestRTO_ArmOnSendSampleOnAck(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.PostTx(rtoDataSeg(iss, 100), 0)
if !r.Running() {
t.Fatal("timer must arm after sending data")
}
if r.NextDeadline() != int64(rtoInitial) {
t.Errorf("deadline=%d, want %d", r.NextDeadline(), int64(rtoInitial))
}
// ACK arrives one RTT (40ms) later covering all sent data.
dir := r.PreRx(rtoAckSeg(iss+100), 40*rtoMs)
if !dir.Keep {
t.Error("PreRx must keep the segment")
}
if r.Running() {
t.Error("timer must stop once all data is acknowledged")
}
if r.SmoothedRTT() != 40*time.Millisecond {
t.Errorf("srtt=%v, want 40ms", r.SmoothedRTT())
}
}
// TestRTO_RetransmitOnTimeout verifies PreTx directs a go-back-N retransmit once
// the deadline passes with data outstanding, and backs the RTO off.
func TestRTO_RetransmitOnTimeout(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.PostTx(rtoDataSeg(iss, 100), 0)
if r.PreTx(int64(rtoInitial) - 1).RetransmitAll {
t.Fatal("must not retransmit before the deadline")
}
dir := r.PreTx(int64(rtoInitial))
if !dir.RetransmitAll {
t.Fatal("RTO must fire at the deadline with data outstanding")
}
if r.CurrentRTO() != 2*rtoInitial {
t.Errorf("rto=%v after one backoff, want %v", r.CurrentRTO(), 2*rtoInitial)
}
// The connection resends from snd.UNA; PostTx sees a retransmission.
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial))
if r.timing {
t.Error("retransmitted segment must not be RTT-sampled (Karn)")
}
}
// TestRTO_KarnNoSampleOnRetransmittedAck verifies that after a retransmission the
// ACK does not produce an RTT sample (Karn's algorithm).
func TestRTO_KarnNoSampleOnRetransmittedAck(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.PostTx(rtoDataSeg(iss, 100), 0)
// Timeout and retransmit.
r.PreTx(int64(rtoInitial))
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial))
// ACK now arrives; no sample should be taken since timing was discarded.
r.PreRx(rtoAckSeg(iss+100), int64(rtoInitial)+10*rtoMs)
if r.haveRTT {
t.Error("no RTT sample should exist after a retransmission (Karn)")
}
}
// TestRTO_TimerRestartsWhilePartiallyAcked verifies the timer restarts (not
// stops) when an ACK advances UNA but data remains in flight (RFC 6298 §5.3).
func TestRTO_TimerRestartsWhilePartiallyAcked(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.PostTx(rtoDataSeg(iss, 100), 0)
r.PostTx(rtoDataSeg(iss+100, 100), 0) // 200 octets outstanding, iss..iss+200.
dir := r.PreRx(rtoAckSeg(iss+100), 40*rtoMs) // acks first 100 only.
if !r.Running() {
t.Fatal("timer must remain armed while data is still in flight")
}
if r.NextDeadline() != 40*rtoMs+int64(r.CurrentRTO()) {
t.Errorf("deadline=%d, want %d", r.NextDeadline(), 40*rtoMs+int64(r.CurrentRTO()))
}
if !dir.Keep {
t.Error("PreRx must keep the segment")
}
}
// TestRTO_NoArmWithoutData verifies control-only segments neither arm the timer
// nor start an RTT sample.
func TestRTO_NoArmWithoutData(t *testing.T) {
r := newRTO()
r.PostTx(Segment{SEQ: 1000, Flags: FlagACK}, 0) // pure ACK, DATALEN==0.
if r.Running() || r.timing {
t.Error("pure control segment must not arm the timer or start a sample")
}
}
// TestRTO_BackoffCollapsesOnValidSample verifies a valid RTT measurement
// collapses the exponential backoff counter (RFC 6298 §5.7).
func TestRTO_BackoffCollapsesOnValidSample(t *testing.T) {
r := newRTO()
const iss = uint32(1000)
r.PostTx(rtoDataSeg(iss, 100), 0)
r.PreTx(int64(rtoInitial)) // one timeout: backoff=1.
r.PostTx(rtoDataSeg(iss, 100), int64(rtoInitial)) // retransmit (no sample).
if r.backoff != 1 {
t.Fatalf("backoff=%d, want 1 after a timeout", r.backoff)
}
// New data sent and freshly sampled, then acked.
r.PostTx(rtoDataSeg(iss+100, 100), int64(rtoInitial)+rtoMs)
r.PreRx(rtoAckSeg(iss+200), int64(rtoInitial)+30*rtoMs)
if r.backoff != 0 {
t.Errorf("backoff=%d, want 0 after a valid RTT sample", r.backoff)
}
}
// TestRTO_Clamped verifies CurrentRTO is clamped to [rtoMin, rtoMax].
func TestRTO_Clamped(t *testing.T) {
var r RTO
r.Reset()
r.rto = time.Nanosecond
if got := r.CurrentRTO(); got != rtoMin {
t.Errorf("CurrentRTO=%v, want floor %v", got, rtoMin)
}
r.rto = time.Hour
if got := r.CurrentRTO(); got != rtoMax {
t.Errorf("CurrentRTO=%v, want ceiling %v", got, rtoMax)
}
}
// TestRTO_UpdateRTTFirstSample verifies the first-measurement initialization of
// SRTT/RTTVAR (RFC 6298 §2.2).
func TestRTO_UpdateRTTFirstSample(t *testing.T) {
var r RTO
r.Reset()
r.updateRTT(100 * time.Millisecond)
if r.srtt != 100*time.Millisecond {
t.Errorf("srtt=%v, want 100ms", r.srtt)
}
if r.rttvar != 50*time.Millisecond {
t.Errorf("rttvar=%v, want 50ms", r.rttvar)
}
// RTO = SRTT + K*RTTVAR = 100 + 4*50 = 300ms.
if r.rto != 300*time.Millisecond {
t.Errorf("rto=%v, want 300ms", r.rto)
}
}
// TestRTO_ImplementsLossRecovery exercises RTO through the [LossRecovery]
// interface: sending data arms a deadline and a full ACK disarms it.
func TestRTO_ImplementsLossRecovery(t *testing.T) {
var lr LossRecovery = newRTO()
lr.Reset()
lr.PostTx(rtoDataSeg(1000, 100), 0)
if lr.NextDeadline() == 0 {
t.Error("expected an armed deadline after sending data")
}
if !lr.PreRx(rtoAckSeg(1100), 10*rtoMs).Keep {
t.Error("PreRx must keep")
}
if lr.NextDeadline() != 0 {
t.Error("expected disarmed timer after full ack")
}
}
+2 -2
View File
@@ -227,7 +227,7 @@ func BenchmarkSYNCookie_Generate(b *testing.B) {
clientISN := Value(0x12345678)
b.ResetTimer()
for i := 0; i < b.N; i++ {
for b.Loop() {
sc.MakeSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN)
}
}
@@ -247,7 +247,7 @@ func BenchmarkSYNCookie_Validate(b *testing.B) {
ackNum := cookie + 1
b.ResetTimer()
for i := 0; i < b.N; i++ {
for b.Loop() {
sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN, ackNum)
}
}
+146
View File
@@ -0,0 +1,146 @@
package netdev
import (
"sync/atomic"
"github.com/soypat/lneto/internal"
)
// TODO(soypat): True Zero Copy (TZC)
// TODO(soypat): TZC acheived on redesigning [DevEthernet] to not own any buffers and ask the networking stack for buffers in the callback path. TZC already acheived for Polling path.
// TODO(soypat): TZC in callback path requires redesign of bufferSelect and [Runner] likely.
// lenClaimed marks a slot claimed by putRx before its frame copy completes.
// The slot is published by storing the frame length, which must be the
// claimant's last write so getRx never observes a partially copied frame.
const lenClaimed = -1
// bufferSelect is a fixed pool of frame buffers shared between the runner
// goroutine and the device's receive handler goroutine. Slot ownership is
// arbitrated exclusively through CAS on lenAcquire:
// - 0: slot free.
// - lenClaimed(<0): slot claimed by putRx, frame copy in progress.
// - n>0: slot owned; if isRx is set the slot holds a published Rx frame.
//
// Only goroPutRx may be called concurrently with the other methods; all other
// methods must be called from a single goroutine (the runner's).
type bufferSelect struct {
// nextSeq generates arrival-order sequence numbers for Rx frames so getRx
// yields frames in the order they were received, not in slot order.
nextSeq atomic.Uint32
missedAcquire atomic.Uint32
bufs []struct {
lenAcquire atomic.Int32
isRx atomic.Bool
seq uint32
buf []byte
}
}
func (bs *bufferSelect) reset(bufs [][]byte) {
internal.SliceReuse(&bs.bufs, len(bufs))
bs.bufs = bs.bufs[:len(bufs)]
for i := range bs.bufs {
bs.bufs[i].buf = bufs[i]
}
bs.releaseAll()
}
func (bs *bufferSelect) releaseAll() {
bs.nextSeq.Store(0)
bs.missedAcquire.Store(0)
for i := range bs.bufs {
bs.bufs[i].isRx.Store(false)
bs.bufs[i].lenAcquire.Store(0)
}
}
// acquire claims a free slot for exclusive use by the caller and returns it
// sized to len. Returns nil if no slot is free or len exceeds slot size.
func (bs *bufferSelect) acquire(len int) []byte {
if len == 0 {
bs.missedAcquire.Add(1)
return nil
}
for i := range bs.bufs {
if len > cap(bs.bufs[i].buf) {
break // Length too long, would need allocation.
}
if bs.bufs[i].lenAcquire.CompareAndSwap(0, int32(len)) {
return bs.bufs[i].buf[:len]
}
}
bs.missedAcquire.Add(1)
return nil
}
// goroPutRx copies an incoming frame into a free slot and publishes it for getRx.
// It is the only method safe to call concurrently with the runner goroutine.
// Returns false if the frame is empty, oversize or no slot is free.
func (bs *bufferSelect) goroPutRx(frame []byte) bool {
n := len(frame)
if n == 0 {
return false
}
for i := range bs.bufs {
if n > cap(bs.bufs[i].buf) {
return false
}
if bs.bufs[i].lenAcquire.CompareAndSwap(0, lenClaimed) {
bs.bufs[i].isRx.Store(true)
bs.bufs[i].seq = bs.nextSeq.Add(1)
copy(bs.bufs[i].buf, frame)
bs.bufs[i].lenAcquire.Store(int32(n)) // publish: must be last write.
return true
}
}
return false
}
func (bs *bufferSelect) numFree() (numFree int) {
for i := range bs.bufs {
if bs.bufs[i].lenAcquire.Load() == 0 {
numFree++
}
}
return numFree
}
// getRx returns the oldest published Rx frame, or nil if none is pending.
func (bs *bufferSelect) getRx() []byte {
oldest := -1
var oldestSeq uint32
for i := range bs.bufs {
n := bs.bufs[i].lenAcquire.Load()
if n > 0 && bs.bufs[i].isRx.Load() &&
(oldest < 0 || lessThan(bs.bufs[i].seq, oldestSeq)) {
oldest = i
oldestSeq = bs.bufs[i].seq
}
}
if oldest < 0 {
return nil
}
return bs.bufs[oldest].buf[:bs.bufs[oldest].lenAcquire.Load()]
}
func (bs *bufferSelect) release(buf []byte) {
ptr := &buf[0]
for i := range bs.bufs {
if &bs.bufs[i].buf[0] == ptr {
// Clear isRx while still owning the slot so the next claimant
// never inherits a stale Rx mark.
bs.bufs[i].isRx.Store(false)
len := bs.bufs[i].lenAcquire.Load()
if len > 0 && bs.bufs[i].lenAcquire.CompareAndSwap(len, 0) {
return
}
panic("bs:race to release")
}
}
panic("bs:buffer not exist or bad offset")
}
func lessThan(aIsLessThan, b uint32) bool {
return int32(aIsLessThan-b) < 0
}
+38 -19
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/netip"
"unsafe"
"github.com/soypat/lneto"
"github.com/soypat/lneto/ethernet"
@@ -30,42 +31,48 @@ type DevEthernet interface {
// HardwareAddr6 returns the device's 6-byte MAC address.
// For PHY-only devices, returns the MAC provided at configuration.
HardwareAddr6() ([6]byte, error)
// SendEthFrameOffset transmits a complete Ethernet frame at offset given by [DevEthernet.MaxFrameSizeAndOffset].
// SendOffsetEthFrame transmits a complete Ethernet frame at offset given by [DevEthernet.MaxFrameSizeAndOffset].
// The frame includes the Ethernet header but NOT the FCS/CRC
// trailer (device or stack handles CRC as appropriate).
// SendEthFrameOffset blocks until the transmission is queued succesfully
// SendOffsetEthFrame blocks until the transmission is queued succesfully
// or finished sending. Should not be called concurrently
// unless user is sure the driver supports it.
SendOffsetEthFrame(offsetTxEthFrame []byte) error
// SetRecvHandler registers the function called when an Ethernet
// SetEthRecvHandler registers the function called when an Ethernet
// frame is received. Buffers needed by the device to operate efficiently
// should be allocated on its side. This function is mutually exclusive with EthPoll:
// use on or the other to receive data.
// should be allocated on its side.
//
// Frames may be delivered via this handler, via EthPoll's buffer, or both:
// - Handler unset: EthPoll writes received frames into its argument buffer.
// - Handler set: received frames are delivered to the handler. EthPoll must
// not write to its argument buffer; it is called with a nil buffer purely
// to pump devices that need explicit servicing to drive the handler.
//
// Quiescence guarantee: SetEthRecvHandler(nil) must not return while a
// previously installed handler is executing on another goroutine, and after
// it returns the old handler must not be invoked again (analogous to Linux
// synchronize_irq semantics). Callers rely on this to safely reuse the
// buffers a handler writes into.
SetEthRecvHandler(handler func(rxEthframe []byte))
// EthPoll services the device. For poll-based devices (e.g. CYW43439
// over SPI), reads from the bus and invokes the handler for each
// received frame. This method is mutually exclusive with SetEthRecvHandler:
// use one or the other to receive data but not return data via both channels.
// received frame.
//
// Behavior depends on whether a handler is set via SetEthRecvHandler:
// - No handler: writes a received frame into buf, returning its offset/length.
// - Handler set: buf is nil and must not be written to; EthPoll only pumps
// the device so frames are delivered through the handler. Return values are ignored.
EthPoll(buf []byte) (ethFrameOff, ethernetBytes int, err error)
// MaxFrameSizeAndOffset returns the max complete device frame size
// (including headers and any overhead) for buffer allocation.
// (including headers and any overhead) for Ethernet Rx buffer allocation.
// The second value returned is the offset at which the ethernet frame
// should be stored when being passed to [DevEthernet.SendOffsetEthFrame].
// Buffers allocated should be maxEthernetFrameSize+frameOff where maxEthernetFrameSize
// is usually 1500 but less or equal to maxFrameSize-frameOff.
// MTU can be calculated doing:
// // mfu-(14+4+4) for:
// // ethernet header+ethernet CRC if present+ethernet VLAN overhead for VLAN support.
// mtu := dev.MaxFrameSizeAndOffset() - ethernet.MaxOverheadSize
MaxFrameSizeAndOffset() (maxFrameSize int, frameOff int)
// Buffers allocated for Rx should be maxFrameSize.
MaxFrameSizeAndOffset() (maxFrameSize int, sendEthFrameOff int)
}
// Stack is an abstraction for a networking stack.
type Stack interface {
// Configure configures this Stack with the argument mac, ip and gateway addresses.
// The Stack must resolve the gateway hardware address if set.
// Configure(mac net.HardwareAddr, ip netip.Prefix, gw netip.Addr) error
// EnableICMP enables responding/sending ICMP echo frames.
EnableICMP(enabled bool) error
// EnableDHCP enables DHCP on the device if enabled=true and performs a DHCP request.
@@ -116,6 +123,18 @@ type InterfaceConfig struct {
MTU uint16
}
// RunnerBuffers returns 32-bit aligned contiguous buffers for using with [RunnerConfig].
func (iface *Interface[C]) RunnerBuffers(n int) [][]byte {
frmlen32 := (iface.frameSize + 3) / 4
rawBuf32 := make([]uint32, n*frmlen32) // ensure memory aligned
bufs := make([][]byte, n)
for i := range n {
buf32 := rawBuf32[i*frmlen32 : (i+1)*frmlen32]
bufs[i] = unsafe.Slice((*byte)(unsafe.Pointer(&buf32[0])), iface.frameSize)
}
return bufs
}
// Init initializes the interface from scratch with a netlink and device. If Init fails all methods on Interface are unsafe to call (panic).
func (iface *Interface[C]) Init(netlink Netlink[C], dev DevEthernet, cfg InterfaceConfig) (err error) {
if netlink == nil || dev == nil {
+415 -80
View File
@@ -3,91 +3,328 @@ package netdev
import (
"context"
"errors"
"runtime"
"sync/atomic"
"time"
"github.com/soypat/lneto"
)
// Runner orchestrates an Interface and a Stack asynchronously.
type Runner[C any] struct {
running atomic.Uint32
// buflen stores the length of data inside buf. It is used as a buffer acquisition synchronizing primitive.
buflen atomic.Uint32
// pktlost is incremented each time an incoming packet is lost due to insufficient buffer size.
pktlost atomic.Uint64
tx, rx atomic.Uint64
// buf stores actual data.
buf []byte
// bufsaux is used as ana argument to stack processing so that no allocations are performed
bufsaux [1][]byte
sizesaux [1]int
handlerTriggered bool
deviceIsPollOnly bool
var (
errRunnerAcquired = errors.New("runner currently running")
errNotDriven = errors.New("runner needs to be poll/async driven")
errWakeNeedsAsync = errors.New("runner wake needs to be async driven")
errNoBackoffNeedsWake = errors.New("wake mechanic not set for omitting backoff")
errAsyncHandlingWithRun = errors.New("incompatible use of EnableAsyncHandling with Run- use with RunOnce")
errEgressInvalidWrite = errors.New("EgressPackets returned invalid written data given frameOffset and argument buffer size")
)
// RunnerFlags selects how a [Runner] drives its [Interface]. See [RunnerConfig.Flags].
type RunnerFlags uint32
const (
// Signals Interface needs to be driven via [DevEthernet.EthPoll].
// [RunnerAsync] can be set too to signal packets received via callback instead of written to EthPoll buffer.
RunnerInterfacePoll RunnerFlags = 1 << iota
// Interface data channel driven exclusively by callback passed to [DevEthernet.SetEthRecvHandler].
// If set [DevEthernet.EthPoll] must not write data to argument buffer.
RunnerInterfaceAsync
// Runner backs off but also wakes up on receiving data asynchronously.
// Needs [RunnerInterfaceAsync] to be set to be effective.
RunnerAsyncWakeOnRx
// Runner will not use the backoff to queue timer wakeups. When setting this option
// the user is responsible for waking up the stack so that it can transmit even when not receiving data.
// Needs [RunnerAsyncWakeOnRx] to be set to be effective.
RunnerNoBackoff
)
// HasAll reports whether every bit in query is set.
func (rf RunnerFlags) HasAll(query RunnerFlags) bool { return rf&query == query }
// HasAny reports whether at least one bit in query is set.
func (rf RunnerFlags) HasAny(query RunnerFlags) bool { return rf&query != 0 }
// Validate reports whether the flag combination is a usable [Runner] configuration.
func (rf RunnerFlags) Validate() error {
driven := rf.HasAny(RunnerInterfaceAsync | RunnerInterfacePoll)
wake := rf.HasAny(RunnerAsyncWakeOnRx)
if !driven {
return errNotDriven
} else if wake && !rf.HasAll(RunnerInterfaceAsync) {
return errWakeNeedsAsync
} else if rf.HasAny(RunnerNoBackoff) && !wake {
return errNoBackoffNeedsWake
}
return nil
}
func (r *Runner[C]) Run(ctx context.Context, iface Interface[C], stack Stack, backoff lneto.BackoffStrategy) error {
if stack == nil || backoff == nil {
return errors.New("nil arguments to Run")
// Runner orchestrates an Interface and a Stack asynchronously.
// The complexity of Runner stems from it needing to handle three types of devices, see [RunnerConfig.Flags] documentation.
type Runner[C any] struct {
running atomic.Uint32
bufs bufferSelect
backoff lneto.BackoffStrategy
reconnect *C
// pktlost is incremented each time an incoming packet is lost due to insufficient buffer size.
// Does not include packets dropped by the stack ([lneto.ErrPacketDrop]); the stack counts those itself.
pktlost atomic.Uint64
// rx includes ALL data received, even dropped data. xnet.StackAsync keeps track of actual processed data.
rx atomic.Uint64
// rxStackErrs/rxPollErrs/txStackErrs/txSendErrs count receive/transmit path
// errors which are intentionally not propagated out of the run loop nor
// printed in the datapath. See [RunnerStatistics] for per-counter semantics.
rxStackErrs, rxPollErrs atomic.Uint64
txStackErrs, txSendErrs atomic.Uint64
// bufsaux is used as an argument to stack processing so that no allocations are performed
bufsaux [1][]byte
sizesaux [1]int
// flags is atomic since [Runner.Wake] reads it from arbitrary goroutines
// concurrently with Configure. Configure stores it last so a visible
// RunnerAsyncWakeOnRx bit guarantees a non-nil wake channel.
flags atomic.Uint32
wake chan struct{}
waketimer *time.Timer
asyncH *Interface[C]
}
// RunnerConfig configures a [Runner]. Used in [Runner.Configure].
type RunnerConfig[C any] struct {
// Buffers are the Rx/Tx packet buffers. At least one is required. Use
// [Interface.RunnerBuffers] to get correctly sized, aligned buffers.
Buffers [][]byte
// ReconnectParams is stored for use during link reconnection. Optional.
ReconnectParams *C
// Backoff is the idle wait strategy between loop iterations. Required.
Backoff lneto.BackoffStrategy
// Flags must be set to be Async, Poll driven, or both.
// This selects the kind of device operation:
// - [RunnerInterfacePoll]: Entirely poll driven Rx [DevEthernet] i.e: ESP32 family.
// - [RunnerInterfaceAsync]: Entirely async driven Rx (IRQ) [DevEthernet] i.e: LAN8720.
// - [RunnerInterfacePoll]|[RunnerInterfaceAsync]: Hybrid Rx [DevEthernet] that are poll driven but can receive data outside EthPoll method. i.e: CYW43439.
Flags RunnerFlags
}
// RunnerStatistics keeps track of send/receive statistics.
// It is an incomplete view that should be combined with the likes of xnet.StackAsync.ReadStatistics.
// It also does not have a view into packets dropped by the [DevEthernet] because of insufficient/slow polling.
type RunnerStatistics struct {
// Rx total bytes received from interface including packets dropped.
Rx uint64
// RxPacketsDropped incremented by 1 each time there is not enough resources to process an incoming packet.
// This does NOT include packets dropped by the Stack with [lneto.ErrPacketDrop].
RxPacketsDropped uint64
// RxPollErrs increments by 1 each time polling the [Interface] returns an error.
// Is irrelevant for callback driven [DevEthernet].
RxPollErrs uint64
// RxStackErrs increments by 1 each time stack returns a non [lneto.ErrPacketDrop] error.
RxStackErrs uint64
// TxStackErrs increments by 1 each time stack returns an error generating egress packets.
TxStackErrs uint64
// TxSendErrs increments by 1 each time sending a frame over [Interface] returns error.
TxSendErrs uint64
// BufAcquireFail increments by 1 each time a buffer is unable to be acquired for Rx/Tx.
BufAcquireFail uint64
}
// ReadStatistics reads statistics of Runner into [RunnerStatistics].
func (r *Runner[C]) ReadStatistics(stats *RunnerStatistics) {
*stats = RunnerStatistics{
Rx: r.rx.Load(),
RxPacketsDropped: r.pktlost.Load(),
RxPollErrs: r.rxPollErrs.Load(),
RxStackErrs: r.rxStackErrs.Load(),
TxStackErrs: r.txStackErrs.Load(),
TxSendErrs: r.txSendErrs.Load(),
BufAcquireFail: uint64(r.bufs.missedAcquire.Load()),
}
}
func (r *Runner[C]) getFlags() RunnerFlags { return RunnerFlags(r.flags.Load()) }
// Wake unblocks a [Runner] sleeping in [RunnerAsyncWakeOnRx] mode so it services the
// stack immediately instead of waiting out the backoff. Signals coalesce and never block.
// Safe to call from any goroutine.
// Returns an error if the Runner is not configured for wake mode.
func (r *Runner[C]) Wake() error {
if !r.getFlags().HasAny(RunnerAsyncWakeOnRx) {
return lneto.ErrInvalidConfig
}
select {
case r.wake <- struct{}{}: // signal waiting runner
default: // already pending — coalesce, never block
}
return nil
}
// Configure validates cfg and applies it to the Runner. Call before [Runner.Run].
// Returns an error on invalid flags, missing buffers/backoff, or while the Runner is running.
func (r *Runner[C]) Configure(cfg RunnerConfig[C]) error {
if err := cfg.Flags.Validate(); err != nil {
return err
}
if len(cfg.Buffers) < 1 {
return lneto.ErrInvalidConfig
} else if cfg.Backoff == nil {
return lneto.ErrMissingHALConfig
}
if !r.acquire() {
return errors.New("runner currently running.")
return errRunnerAcquired
}
defer func() {
iface.dev.SetEthRecvHandler(nil)
r.release()
}()
r.rx.Store(0)
r.tx.Store(0)
r.buflen.Store(0)
r.pktlost.Store(0)
r.handlerTriggered = false
r.deviceIsPollOnly = false
defer r.release()
r.flags.Store(0) // Disable Wake during reconfiguration.
r.teardownAsync()
r.bufs.reset(cfg.Buffers)
r.backoff = cfg.Backoff
r.reconnect = cfg.ReconnectParams
if cfg.Flags.HasAny(RunnerAsyncWakeOnRx) && r.wake == nil {
r.wake = make(chan struct{}, 1)
r.waketimer = time.NewTimer(24 * time.Hour)
}
r.flags.Store(uint32(cfg.Flags)) // Publish flags last; see field comment.
return nil
}
// RunOnce performs a single Rx-then-Tx service cycle and returns the bytes received
// and transmitted. It does no backoff, wake wait, or state reset: the caller controls
// pacing and must [Runner.Configure] (with a non-nil stack) before the first call.
// Returns an error if a [Runner.Run] or another RunOnce is already in progress.
//
// Unlike [Runner.Run], RunOnce does not install the async receive handler. For a
// poll-driven interface ([RunnerInterfacePoll]) it works as-is; for an async interface
// ([RunnerInterfaceAsync]) call [Runner.EnableAsyncHandling] once beforehand so delivered
// frames are captured.
func (r *Runner[C]) RunOnce(iface *Interface[C], stack Stack) (nrx, ntx int, err error) {
if stack == nil {
return 0, 0, lneto.ErrInvalidConfig
}
if !r.acquire() {
return 0, 0, errRunnerAcquired
}
defer r.release()
flags := r.getFlags()
async := flags.HasAny(RunnerInterfaceAsync)
poll := flags.HasAny(RunnerInterfacePoll)
bufsize := iface.bufsize()
if cap(r.buf) < bufsize {
r.buf = make([]byte, bufsize)
nrx, ntx, err = r.service(iface, stack, bufsize, poll, async)
return nrx, ntx, err
}
// EnableAsyncHandling installs the Runner's async receive handler on iface so that
// frames delivered via [DevEthernet.SetEthRecvHandler] are captured into the buffer
// pool. Use it to drive an async interface with [Runner.RunOnce], which (unlike
// [Runner.Run]) does not install the handler itself. [Runner.Run] manages the handler
// on its own and does not need this.
//
// Returns [lneto.ErrUnsupported] if the Runner is not configured async
// ([RunnerInterfaceAsync]), or an error if a Run/RunOnce is in progress.
func (r *Runner[C]) EnableAsyncHandling(iface *Interface[C]) error {
if !r.acquire() {
return errRunnerAcquired
}
r.buf = r.buf[:bufsize]
defer r.release()
if !r.getFlags().HasAny(RunnerInterfaceAsync) {
return lneto.ErrUnsupported
}
r.asyncH = iface
iface.dev.SetEthRecvHandler(r.recvEthHandler)
return nil
}
// DisableAsyncHandling removes the receive handler installed by [Runner.EnableAsyncHandling],
// stopping async frame delivery into the buffer pool. Call before reconfiguring or tearing
// down the Runner. No-op if async handling was not enabled.
func (r *Runner[C]) DisableAsyncHandling() error {
if !r.acquire() {
return errRunnerAcquired
}
defer r.release()
r.teardownAsync()
return nil
}
func (r *Runner[C]) teardownAsync() {
if r.asyncH != nil {
r.asyncH.dev.SetEthRecvHandler(nil)
r.asyncH = nil
}
}
// Run drives iface and stack until ctx is cancelled, doing one Rx then Tx per iteration
// and backing off when idle. Only one Run (and not concurrent with Configure) may execute
// at a time. Returns ctx.Err().
func (r *Runner[C]) Run(ctx context.Context, iface *Interface[C], stack Stack) error {
if stack == nil {
return lneto.ErrInvalidConfig
}
if !r.acquire() {
return errRunnerAcquired
}
defer r.release()
if r.asyncH != nil {
return errAsyncHandlingWithRun
}
r.bufs.releaseAll()
r.rx.Store(0)
r.pktlost.Store(0)
r.rxStackErrs.Store(0)
r.rxPollErrs.Store(0)
r.txStackErrs.Store(0)
r.txSendErrs.Store(0)
bufsize := iface.bufsize()
flags := r.getFlags()
async := flags.HasAny(RunnerInterfaceAsync)
poll := flags.HasAny(RunnerInterfacePoll)
wake := flags.HasAny(RunnerAsyncWakeOnRx)
backoffEnabled := !flags.HasAny(RunnerNoBackoff)
if wake {
r.waketimer.Stop()
}
backoff := r.backoff
if async {
iface.dev.SetEthRecvHandler(r.recvEthHandler)
// Only tear down the handler Run itself installed. In poll-only mode
// any handler on the device is not Run's to clear.
defer iface.dev.SetEthRecvHandler(nil)
}
// backoffs stores number of consecutive times no data was sent/received.
var backoffs uint
for ctx.Err() == nil {
n1, _ := r.processRx(stack, 0)
eoff, efrm, err := iface.dev.EthPoll(r.buf)
n2, _ := r.processRx(stack, 0)
if efrm > 0 && n2 == 0 {
r.deviceIsPollOnly = true
r.buflen.Store(uint32(eoff + efrm))
r.processRx(stack, eoff)
} else if efrm > 0 && n2 > 0 {
return errors.New("device both returns nonzero poll read and calls, choose one")
} else if err != nil {
println("err EthPoll:", err.Error())
}
// Now do Tx, but first acquire buffer.
if !r.buflen.CompareAndSwap(0, 1) {
continue // Oh no, async data received, go back to Rx processing.
}
r.bufsaux = [1][]byte{r.buf}
err = stack.EgressPackets(r.bufsaux[:], r.sizesaux[:], iface.frameOff)
n := r.sizesaux[0]
nrx, ntx, err := r.service(iface, stack, bufsize, poll, async)
if err != nil {
println("err EgressPackets:", err.Error())
} else if n > 0 {
if n+iface.frameOff > len(r.buf) {
return errors.New("EgressPackets returned invalid written data given frameOffset and argument buffer size")
}
err = iface.dev.SendOffsetEthFrame(r.bufsaux[0][:n+iface.frameOff])
r.tx.Add(uint64(n + iface.frameOff))
if err != nil {
println("err SendOffsetEthFrame:", err.Error())
}
return err
}
r.buflen.Store(0) // Release buffer.
if n1 > 0 || n2 > 0 || efrm > 0 || n > 0 {
if nrx > 0 || ntx > 0 {
backoffs = 0
} else if wake {
if backoffEnabled {
d := backoff(backoffs)
backoffs++
switch d {
case lneto.BackoffFlagGosched:
runtime.Gosched()
fallthrough
case lneto.BackoffFlagNop:
continue
default:
d = max(d, 100*time.Microsecond)
}
// Claude say:
// Reset without draining waketimer.C assumes Go 1.23+ timer
// semantics (stale expiries do not linger in the channel). On
// runtimes with older semantics (e.g. TinyGo) worst case is a
// single spurious early wakeup, which is benign here.
r.waketimer.Reset(d)
}
select {
case <-r.wake:
backoffs = 0 // woke early on data.
case <-ctx.Done():
case <-r.waketimer.C:
}
if backoffEnabled {
r.waketimer.Stop()
}
} else {
backoff.Do(backoffs)
backoffs++
@@ -96,20 +333,68 @@ func (r *Runner[C]) Run(ctx context.Context, iface Interface[C], stack Stack, ba
return ctx.Err()
}
func (r *Runner[C]) service(iface *Interface[C], stack Stack, bufsize int, poll, async bool) (nrx, ntx int, err error) {
nrx, err = r.doRx(iface, stack, poll, async)
// Now do Tx, but first acquire buffer.
txbuf := r.bufs.acquire(bufsize)
if txbuf == nil {
// We got blocked by Rx. Try draining rx.
for range len(r.bufs.bufs) {
n, err := r.doRx(iface, stack, poll, async)
if err != nil {
break
}
nrx += n
}
txbuf = r.bufs.acquire(bufsize)
if txbuf == nil {
// Buffers still held by in-flight Rx. Skip Tx this cycle rather
// than steal a slot the receive handler may be writing into.
return nrx, 0, nil
}
}
r.bufsaux = [1][]byte{txbuf}
err = stack.EgressPackets(r.bufsaux[:], r.sizesaux[:], iface.frameOff)
ntx = r.sizesaux[0]
if err != nil {
r.txStackErrs.Add(1)
} else if ntx > 0 {
if ntx+iface.frameOff > len(txbuf) {
r.bufs.release(txbuf)
return nrx, 0, errEgressInvalidWrite
}
err = iface.dev.SendOffsetEthFrame(r.bufsaux[0][:ntx+iface.frameOff])
if err != nil {
r.txSendErrs.Add(1)
}
}
r.bufs.release(txbuf) // Release buffer.
return nrx, ntx, nil
}
// PrintDebug
//
// Deprecated: Might be given other shape in future, but this is not how we do debugging. use freely meanwhile.
func (r *Runner[C]) PrintDebug() {
print("RUNNER: tx|rx:", r.tx.Load(), "|", r.rx.Load(),
" devpollonly:", r.deviceIsPollOnly, " pktlost:", r.pktlost.Load(),
" handles:", r.handlerTriggered, " bufsize:", len(r.buf),
flags := r.getFlags()
print("RUNNER: rx:", r.rx.Load(),
" pktlost:", r.pktlost.Load(),
" rxErrs:", r.rxStackErrs.Load(),
" txErrs:", r.txSendErrs.Load(),
" devPoll:", flags.HasAny(RunnerInterfacePoll),
" devAsync:", flags.HasAny(RunnerInterfaceAsync),
" devWakeRx:", flags.HasAny(RunnerAsyncWakeOnRx),
"\n")
}
// acquire takes the single-use lock, returning false if already held.
func (r *Runner[C]) acquire() bool {
return r.running.CompareAndSwap(0, 1)
}
// release frees the lock taken by acquire. Panics if not held.
func (r *Runner[C]) release() {
if r.running.Load()&1 == 0 {
panic("release of unacquired resource")
@@ -119,23 +404,73 @@ func (r *Runner[C]) release() {
// recvEthHandler is called asynchronously. Should be as fast as possible. Do not block inside.
func (r *Runner[C]) recvEthHandler(incomingEthernet []byte) {
if !r.buflen.CompareAndSwap(0, uint32(len(incomingEthernet))) {
// Failed to acquire buffer, packet dropped.
r.rx.Add(uint64(len(incomingEthernet))) // rx includes dropped data. xnet.StackAsync keeps track of actual received statistics.
if !r.bufs.goroPutRx(incomingEthernet) {
// No free buffer or frame oversize, packet dropped.
r.pktlost.Add(1)
return
}
copy(r.buf, incomingEthernet)
r.Wake()
}
// processRx is called after a packet is received asynchronously and compied to buffer via recvEthHandler
func (r *Runner[C]) processRx(stack Stack, ethFrameOff int) (int, error) {
r.handlerTriggered = true
n := r.buflen.Load()
if n == 0 {
// doRx services one Rx cycle. In poll mode it reads a frame from the device into a buffer
// and ingresses it. In async mode it drains frames delivered by recvEthHandler, pumping a
// poll-driven device with EthPoll(nil) when buffers are free. Device errors are counted
// in rxPollErrs; the returned error is the first stack ingress error encountered.
func (r *Runner[C]) doRx(iface *Interface[C], stack Stack, poll, async bool) (n int, gerr error) {
if !async { // poll guaranteed to be set as per RunnerFlags.Validate.
// Poll-only doRx.
buf := r.bufs.acquire(iface.frameSize)
if buf == nil {
return 0, nil
}
eoff, efrm, err := iface.dev.EthPoll(buf)
if err != nil {
r.rxPollErrs.Add(1)
}
if efrm > 0 {
r.bufsaux = [1][]byte{buf[:eoff+efrm]}
gerr = stack.IngressPackets(r.bufsaux[:], eoff)
if gerr != nil && gerr != lneto.ErrPacketDrop {
r.rxStackErrs.Add(1)
}
}
r.bufs.release(buf)
r.rx.Add(uint64(efrm))
return efrm, gerr
}
// Async branch.
n, gerr = r.processAsyncRx(stack)
if poll && r.bufs.numFree() > 0 {
// Manual polling required by device.
// Data not transmitted via this channel as per RunnerFlags documentation.
_, _, err := iface.dev.EthPoll(nil)
if err != nil {
r.rxPollErrs.Add(1)
}
} else {
return n, gerr
}
n2, err := r.processAsyncRx(stack)
if gerr == nil {
gerr = err
}
return n + n2, gerr
}
// processAsyncRx ingresses one frame previously copied to a buffer by recvEthHandler.
// Returns the frame length, or 0 if none is pending.
func (r *Runner[C]) processAsyncRx(stack Stack) (int, error) {
buf := r.bufs.getRx()
if buf == nil {
return 0, nil
}
r.rx.Add(uint64(n))
defer r.buflen.Store(0)
r.bufsaux = [1][]byte{r.buf[:n]}
return int(n), stack.IngressPackets(r.bufsaux[:], ethFrameOff)
r.bufsaux = [1][]byte{buf}
err := stack.IngressPackets(r.bufsaux[:], 0)
if err != nil && err != lneto.ErrPacketDrop {
r.rxStackErrs.Add(1)
}
r.bufs.release(buf)
return len(buf), err
}
+644
View File
@@ -0,0 +1,644 @@
package netdev_test
import (
"context"
"encoding/binary"
"errors"
"net/netip"
"runtime"
"sync"
"testing"
"time"
"github.com/soypat/lneto"
"github.com/soypat/lneto/x/netdev"
)
// mockDev is a DevEthernet test double. The mutex makes SetEthRecvHandler
// honor the quiescence guarantee with respect to deliver and EthPoll.
type mockDev struct {
mu sync.Mutex
handler func([]byte)
sent [][]byte
rxq [][]byte // frames pending delivery via EthPoll (poll mode or pump).
pumped int
frameSize int
frameOff int
pollErr error
sendErr error
}
func (d *mockDev) HardwareAddr6() ([6]byte, error) {
return [6]byte{0xde, 0xad, 0xbe, 0xef, 0, 1}, nil
}
func (d *mockDev) SendOffsetEthFrame(f []byte) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.sendErr != nil {
return d.sendErr
}
d.sent = append(d.sent, append([]byte(nil), f...))
return nil
}
func (d *mockDev) SetEthRecvHandler(h func(rxEthFrame []byte)) {
d.mu.Lock()
d.handler = h
d.mu.Unlock()
}
// deliver invokes the installed receive handler as a driver goroutine would.
// Returns false if no handler is installed.
func (d *mockDev) deliver(frame []byte) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.handler == nil {
return false
}
d.handler(frame)
return true
}
func (d *mockDev) handlerInstalled() bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.handler != nil
}
func (d *mockDev) numSent() int {
d.mu.Lock()
defer d.mu.Unlock()
return len(d.sent)
}
func (d *mockDev) queueRx(frame []byte) {
d.mu.Lock()
defer d.mu.Unlock()
d.rxq = append(d.rxq, append([]byte(nil), frame...))
}
func (d *mockDev) EthPoll(buf []byte) (int, int, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.handler != nil {
// Pump mode: frames go through the handler, buf must not be written.
if buf != nil {
return 0, 0, errors.New("EthPoll got non-nil buf with handler set")
}
d.pumped++
for _, f := range d.rxq {
d.handler(f)
}
d.rxq = nil
return 0, 0, d.pollErr
}
if len(d.rxq) == 0 {
return 0, 0, d.pollErr
}
f := d.rxq[0]
d.rxq = d.rxq[1:]
n := copy(buf[d.frameOff:], f)
return d.frameOff, n, nil
}
func (d *mockDev) MaxFrameSizeAndOffset() (int, int) { return d.frameSize, d.frameOff }
type mockNetlink struct{}
func (mockNetlink) LinkConnect(_ struct{}) error { return nil }
func (mockNetlink) LinkDisconnect() {}
func (mockNetlink) LinkNotify(_ netdev.NotifyCallback[struct{}]) {}
// mockStack records ingressed frames and emits queued egress frames.
type mockStack struct {
mu sync.Mutex
ingress [][]byte
egressq [][]byte
ingressErr error
egressErr error
}
func (s *mockStack) EnableICMP(bool) error { return nil }
func (s *mockStack) EnableDHCP(context.Context, bool, netip.Addr) (netip.Addr, netip.Addr, int, error) {
return netip.Addr{}, netip.Addr{}, 0, nil
}
func (s *mockStack) Socket(context.Context, string, int, int, netip.AddrPort, netip.AddrPort) (any, error) {
return nil, nil
}
func (s *mockStack) EgressPackets(bufs [][]byte, sizes []int, offset int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.egressErr != nil {
return s.egressErr
}
for i := range bufs {
sizes[i] = 0
if len(s.egressq) == 0 {
continue
}
sizes[i] = copy(bufs[i][offset:], s.egressq[0])
s.egressq = s.egressq[1:]
}
return nil
}
func (s *mockStack) IngressPackets(bufs [][]byte, offset int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.ingressErr != nil {
return s.ingressErr
}
for _, b := range bufs {
s.ingress = append(s.ingress, append([]byte(nil), b[offset:]...))
}
return nil
}
func (s *mockStack) queueEgress(frame []byte) {
s.mu.Lock()
defer s.mu.Unlock()
s.egressq = append(s.egressq, append([]byte(nil), frame...))
}
func (s *mockStack) numIngress() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.ingress)
}
func newIface(t *testing.T, dev *mockDev) *netdev.Interface[struct{}] {
t.Helper()
if dev.frameSize == 0 {
dev.frameSize = 1514 + dev.frameOff
}
var iface netdev.Interface[struct{}]
err := iface.Init(mockNetlink{}, dev, netdev.InterfaceConfig{})
if err != nil {
t.Fatal(err)
}
return &iface
}
func newRunner(t *testing.T, iface *netdev.Interface[struct{}], nbufs int, flags netdev.RunnerFlags, backoff lneto.BackoffStrategy) *netdev.Runner[struct{}] {
t.Helper()
if backoff == nil {
backoff = func(uint) time.Duration { return time.Millisecond }
}
var r netdev.Runner[struct{}]
err := r.Configure(netdev.RunnerConfig[struct{}]{
Buffers: iface.RunnerBuffers(nbufs),
Backoff: backoff,
Flags: flags,
})
if err != nil {
t.Fatal(err)
}
return &r
}
// testFrame returns a frame whose payload encodes and repeats seq for
// integrity checking with checkFrame.
func testFrame(seq uint32, size int) []byte {
f := make([]byte, size)
binary.LittleEndian.PutUint32(f, seq)
for i := 4; i < size; i++ {
f[i] = byte(seq)
}
return f
}
func checkFrame(t *testing.T, f []byte, size int) uint32 {
t.Helper()
if len(f) != size {
t.Fatalf("frame length %d, want %d", len(f), size)
}
seq := binary.LittleEndian.Uint32(f)
for i := 4; i < len(f); i++ {
if f[i] != byte(seq) {
t.Fatalf("frame seq %d corrupt at byte %d: got %#x want %#x", seq, i, f[i], byte(seq))
}
}
return seq
}
func TestRunnerFlagsValidate(t *testing.T) {
for _, tc := range []struct {
flags netdev.RunnerFlags
ok bool
}{
{flags: 0, ok: false},
{flags: netdev.RunnerInterfacePoll, ok: true},
{flags: netdev.RunnerInterfaceAsync, ok: true},
{flags: netdev.RunnerInterfacePoll | netdev.RunnerInterfaceAsync, ok: true},
{flags: netdev.RunnerAsyncWakeOnRx, ok: false},
{flags: netdev.RunnerInterfacePoll | netdev.RunnerAsyncWakeOnRx, ok: false},
{flags: netdev.RunnerInterfaceAsync | netdev.RunnerAsyncWakeOnRx, ok: true},
{flags: netdev.RunnerInterfaceAsync | netdev.RunnerNoBackoff, ok: false},
{flags: netdev.RunnerInterfaceAsync | netdev.RunnerAsyncWakeOnRx | netdev.RunnerNoBackoff, ok: true},
} {
err := tc.flags.Validate()
if (err == nil) != tc.ok {
t.Errorf("flags %#b: got err=%v, want ok=%v", tc.flags, err, tc.ok)
}
}
}
func TestRunOncePollOnly(t *testing.T) {
dev := &mockDev{frameOff: 4}
iface := newIface(t, dev)
stack := &mockStack{}
r := newRunner(t, iface, 2, netdev.RunnerInterfacePoll, nil)
const fsize = 64
dev.queueRx(testFrame(1, fsize))
stack.queueEgress(testFrame(2, fsize))
nrx, ntx, err := r.RunOnce(iface, stack)
if err != nil {
t.Fatal(err)
}
if nrx != fsize {
t.Errorf("nrx=%d, want %d", nrx, fsize)
}
if ntx != fsize {
t.Errorf("ntx=%d, want %d", ntx, fsize)
}
if stack.numIngress() != 1 {
t.Fatalf("ingress=%d, want 1", stack.numIngress())
}
if got := checkFrame(t, stack.ingress[0], fsize); got != 1 {
t.Errorf("ingress seq=%d, want 1", got)
}
if dev.numSent() != 1 {
t.Fatalf("sent=%d, want 1", dev.numSent())
}
// Sent frame includes the device frame offset prefix.
if got := checkFrame(t, dev.sent[0][dev.frameOff:], fsize); got != 2 {
t.Errorf("sent seq=%d, want 2", got)
}
}
// TestRunOnceAsyncOrdering checks frames ingress in arrival order, not in
// buffer slot order (review: getRx scans slots in index order; reordering
// TCP segments triggers dup-ACK/retransmit churn).
func TestRunOnceAsyncOrdering(t *testing.T) {
dev := &mockDev{}
iface := newIface(t, dev)
stack := &mockStack{}
r := newRunner(t, iface, 4, netdev.RunnerInterfaceAsync, nil)
err := r.EnableAsyncHandling(iface)
if err != nil {
t.Fatal(err)
}
const fsize = 64
for seq := uint32(1); seq <= 3; seq++ {
if !dev.deliver(testFrame(seq, fsize)) {
t.Fatal("handler not installed")
}
}
for range 3 {
_, _, err := r.RunOnce(iface, stack)
if err != nil {
t.Fatal(err)
}
}
if stack.numIngress() != 3 {
t.Fatalf("ingress=%d, want 3", stack.numIngress())
}
for i, f := range stack.ingress {
if got := checkFrame(t, f, fsize); got != uint32(i+1) {
t.Errorf("ingress[%d] seq=%d, want %d: frames reordered", i, got, i+1)
}
}
}
// TestRunOnceAsyncPollPumpSingleBuffer exercises the async+poll pump path with
// a single buffer over several cycles. Review (MDr164, buffer.go inline): reset
// and release clear lenAcquire but not isRx, so numFree undercounts and the
// EthPoll pump is wrongly skipped.
func TestRunOnceAsyncPollPumpSingleBuffer(t *testing.T) {
dev := &mockDev{}
iface := newIface(t, dev)
stack := &mockStack{}
r := newRunner(t, iface, 1, netdev.RunnerInterfaceAsync|netdev.RunnerInterfacePoll, nil)
err := r.EnableAsyncHandling(iface)
if err != nil {
t.Fatal(err)
}
const fsize = 64
const cycles = 3
for seq := uint32(1); seq <= cycles; seq++ {
dev.queueRx(testFrame(seq, fsize))
_, _, err := r.RunOnce(iface, stack)
if err != nil {
t.Fatal(err)
}
}
if stack.numIngress() != cycles {
t.Fatalf("ingress=%d, want %d: EthPoll pump skipped", stack.numIngress(), cycles)
}
for i, f := range stack.ingress {
if got := checkFrame(t, f, fsize); got != uint32(i+1) {
t.Errorf("ingress[%d] seq=%d, want %d", i, got, i+1)
}
}
// Deliver a frame that stays pending in the pool, then reconfigure: reset
// must fully clear slot state. A stale isRx mark from the abandoned frame
// makes numFree undercount and skip the pump.
if !dev.deliver(testFrame(cycles+1, fsize)) {
t.Fatal("handler not installed")
}
err = r.Configure(netdev.RunnerConfig[struct{}]{
Buffers: iface.RunnerBuffers(1),
Backoff: func(uint) time.Duration { return time.Millisecond },
Flags: netdev.RunnerInterfaceAsync | netdev.RunnerInterfacePoll,
})
if err != nil {
t.Fatal(err)
}
err = r.EnableAsyncHandling(iface)
if err != nil {
t.Fatal(err)
}
dev.queueRx(testFrame(cycles+2, fsize))
_, _, err = r.RunOnce(iface, stack)
if err != nil {
t.Fatal(err)
}
// The frame in flight at reconfigure time is legitimately dropped; the
// queued frame must still arrive through the pump.
if stack.numIngress() != cycles+1 {
t.Fatalf("ingress=%d, want %d: pump skipped after reconfigure (stale isRx)", stack.numIngress(), cycles+1)
}
}
// TestRunAfterEnableAsyncHandlingFails checks Run rejects a Runner set up via
// EnableAsyncHandling WITHOUT tearing down the installed handler. Review issue
// 4: the teardown defer is installed before the asyncH check, silently
// uninstalling the handler so subsequent RunOnce drops all async frames.
func TestRunAfterEnableAsyncHandlingFails(t *testing.T) {
dev := &mockDev{}
iface := newIface(t, dev)
stack := &mockStack{}
r := newRunner(t, iface, 2, netdev.RunnerInterfaceAsync, nil)
err := r.EnableAsyncHandling(iface)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err = r.Run(ctx, iface, stack)
if err == nil || errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Run after EnableAsyncHandling: got %v, want immediate config error", err)
}
// The rejected Run must not tear down the handler EnableAsyncHandling installed.
if !dev.handlerInstalled() {
t.Fatal("Run teardown removed handler it did not install")
}
if !dev.deliver(testFrame(1, 64)) {
t.Fatal("handler not installed")
}
nrx, _, err := r.RunOnce(iface, stack)
if err != nil || nrx != 64 {
t.Fatalf("RunOnce after rejected Run: nrx=%d err=%v", nrx, err)
}
}
// TestOversizeFrameDropped checks a frame larger than the pool buffers is
// dropped instead of panicking. Review: acquireNext slices buf[:len] without a
// bounds check — a slice-bounds panic inside the receive path.
func TestOversizeFrameDropped(t *testing.T) {
dev := &mockDev{}
iface := newIface(t, dev)
stack := &mockStack{}
r := newRunner(t, iface, 2, netdev.RunnerInterfaceAsync, nil)
err := r.EnableAsyncHandling(iface)
if err != nil {
t.Fatal(err)
}
func() {
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("receive handler panicked on oversize frame: %v", recovered)
}
}()
dev.deliver(make([]byte, dev.frameSize+1))
}()
nrx, _, err := r.RunOnce(iface, stack)
if err != nil || nrx != 0 || stack.numIngress() != 0 {
t.Fatalf("oversize frame ingressed: nrx=%d ingress=%d err=%v", nrx, stack.numIngress(), err)
}
}
// TestRunBackoffEscalates checks the backoff counter escalates past a
// Gosched/Nop opening move. Review issue 5 (MDr164, runner.go inline): continue
// skips backoffs++, so a strategy returning Gosched at 0 busy-spins forever.
func TestRunBackoffEscalates(t *testing.T) {
dev := &mockDev{}
iface := newIface(t, dev)
stack := &mockStack{}
var mu sync.Mutex
var recorded []uint
backoff := func(consecutive uint) time.Duration {
mu.Lock()
recorded = append(recorded, consecutive)
mu.Unlock()
if consecutive == 0 {
return lneto.BackoffFlagGosched
}
return time.Millisecond
}
r := newRunner(t, iface, 2, netdev.RunnerInterfaceAsync|netdev.RunnerAsyncWakeOnRx, backoff)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := r.Run(ctx, iface, stack)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(recorded) == 0 {
t.Fatal("backoff never called")
}
escalated := false
for _, c := range recorded {
if c > 0 {
escalated = true
break
}
}
if !escalated {
t.Fatalf("backoff counter pinned at 0 across %d idle iterations", len(recorded))
}
}
// TestRunAsyncStress hammers the receive handler from a producer goroutine
// while Run services the stack under Tx pressure, forcing buffer pool
// contention. Run with -race. Review issue 1: forceAcquireTx steals a slot the
// receive handler may be concurrently copying into, so partially constructed
// frames land on the wire and double release panics the runner. Checks frames
// are never corrupted (ingress AND egress) and never reordered.
func TestRunAsyncStress(t *testing.T) {
dev := &mockDev{}
iface := newIface(t, dev)
stack := &mockStack{}
r := newRunner(t, iface, 3, netdev.RunnerInterfaceAsync|netdev.RunnerAsyncWakeOnRx, nil)
const fsize = 64
const nframes = 2000
const egressSeq = 0xffff
ctx, cancel := context.WithCancel(context.Background())
runDone := make(chan error, 1)
go func() { runDone <- r.Run(ctx, iface, stack) }()
// Egress traffic keeps the Tx path contending with Rx for buffers.
for range 200 {
stack.queueEgress(testFrame(egressSeq, fsize))
}
for seq := uint32(1); seq <= nframes; seq++ {
for !dev.deliver(testFrame(seq, fsize)) {
runtime.Gosched()
}
}
time.Sleep(10 * time.Millisecond) // let the runner drain pending frames.
cancel()
err := <-runDone
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
stack.mu.Lock()
if len(stack.ingress) == 0 {
t.Fatal("no frames ingressed")
}
prev := uint32(0)
for _, f := range stack.ingress {
seq := checkFrame(t, f, fsize)
if seq <= prev {
t.Fatalf("reordered: seq %d after %d", seq, prev)
}
prev = seq
}
numIngress := len(stack.ingress)
stack.mu.Unlock()
dev.mu.Lock()
for _, f := range dev.sent {
if seq := checkFrame(t, f, fsize); seq != egressSeq {
t.Fatalf("egress frame corrupted: seq=%#x", seq)
}
}
numSent := len(dev.sent)
dev.mu.Unlock()
t.Logf("ingressed %d/%d frames, sent %d", numIngress, nframes, numSent)
}
// TestRunnerStatisticsCounting checks each RunnerStatistics counter increments
// per its documented trigger: device poll errors -> RxPollErrs, stack ingress
// errors -> RxStackErrs except ErrPacketDrop which -> RxPacketsDropped, stack
// egress errors -> TxStackErrs, and device send errors -> TxSendErrs.
func TestRunnerStatisticsCounting(t *testing.T) {
const fsize = 64
errBoom := errors.New("boom")
for _, tc := range []struct {
name string
setup func(dev *mockDev, stack *mockStack)
want netdev.RunnerStatistics
}{
{
// Successful Rx+Tx must not bump any error counter (regression:
// missing nil check counted every successful ingress as RxStackErrs).
name: "no errors",
setup: func(dev *mockDev, stack *mockStack) {
dev.queueRx(testFrame(1, fsize))
stack.queueEgress(testFrame(2, fsize))
},
want: netdev.RunnerStatistics{Rx: fsize},
},
{
name: "poll error",
setup: func(dev *mockDev, stack *mockStack) { dev.pollErr = errBoom },
want: netdev.RunnerStatistics{RxPollErrs: 1},
},
{
// ErrPacketDrop is counted by the stack itself, not the Runner:
// neither RxPacketsDropped nor RxStackErrs may increment.
name: "ingress packet drop",
setup: func(dev *mockDev, stack *mockStack) {
stack.ingressErr = lneto.ErrPacketDrop
dev.queueRx(testFrame(1, fsize))
},
want: netdev.RunnerStatistics{Rx: fsize},
},
{
name: "ingress stack error",
setup: func(dev *mockDev, stack *mockStack) {
stack.ingressErr = errBoom
dev.queueRx(testFrame(1, fsize))
},
want: netdev.RunnerStatistics{Rx: fsize, RxStackErrs: 1},
},
{
name: "egress stack error",
setup: func(dev *mockDev, stack *mockStack) { stack.egressErr = errBoom },
want: netdev.RunnerStatistics{TxStackErrs: 1},
},
{
name: "send error",
setup: func(dev *mockDev, stack *mockStack) {
dev.sendErr = errBoom
stack.queueEgress(testFrame(1, fsize))
},
want: netdev.RunnerStatistics{TxSendErrs: 1},
},
} {
t.Run(tc.name, func(t *testing.T) {
dev := &mockDev{}
iface := newIface(t, dev)
stack := &mockStack{}
r := newRunner(t, iface, 2, netdev.RunnerInterfacePoll, nil)
tc.setup(dev, stack)
_, _, err := r.RunOnce(iface, stack)
if err != nil {
t.Fatal(err)
}
var stats netdev.RunnerStatistics
r.ReadStatistics(&stats)
if stats != tc.want {
t.Errorf("stats=%+v, want %+v", stats, tc.want)
}
})
}
}
// TestRunResetsStatistics checks Run zeroes all counters accumulated by a
// previous session, including RxPollErrs.
func TestRunResetsStatistics(t *testing.T) {
dev := &mockDev{}
iface := newIface(t, dev)
stack := &mockStack{}
r := newRunner(t, iface, 2, netdev.RunnerInterfacePoll, nil)
dev.pollErr = errors.New("boom")
if _, _, err := r.RunOnce(iface, stack); err != nil {
t.Fatal(err)
}
var stats netdev.RunnerStatistics
r.ReadStatistics(&stats)
if stats == (netdev.RunnerStatistics{}) {
t.Fatal("expected nonzero statistics before Run")
}
dev.mu.Lock()
dev.pollErr = nil
dev.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if err := r.Run(ctx, iface, stack); !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
r.ReadStatistics(&stats)
if stats != (netdev.RunnerStatistics{}) {
t.Errorf("Run did not reset statistics: %+v", stats)
}
}
+18 -13
View File
@@ -103,6 +103,8 @@ type StackConfig struct {
MTU uint16
// Accept multicast ethernet and IP packets. Needed for MDNS.
AcceptMulticast bool
// Accept broadcast IPv4 packets. Needed for managing access points and DHCPv4 servers.
AcceptIPv4Broadcast bool
}
func (cfg *StackConfig) id() uint16 {
@@ -163,9 +165,14 @@ func (s *StackAsync) IngressIP(ipFrame []byte) error {
func (s *StackAsync) EgressIP(dstIPFrame []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(dstIPFrame) < s.link.MTU() {
mtu := s.link.MTU()
if len(dstIPFrame) < mtu {
return 0, lneto.ErrShortBuffer
}
// Clip to MTU so downstream layers cannot emit an IP datagram larger than the
// link MTU (mirrors StackEthernet.Encapsulate). This also bounds the TCP frame
// budget, so the advertised MSS becomes MTU-ipHdr-20 instead of the buffer size.
dstIPFrame = dstIPFrame[:mtu]
n, err := s.ip4.Encapsulate(dstIPFrame, 0, 0)
if s.ipv6enabled && n == 0 {
n, err = s.stack6.EgressIPv6(dstIPFrame)
@@ -234,7 +241,7 @@ func (s *StackAsync) Reset(cfg StackConfig) (err error) {
}
s.ip4.SetAddr4(cfg.StaticAddress4)
s.setAcceptMulticast4(cfg.AcceptMulticast)
s.ip4.SetAcceptBroadcast4(cfg.AcceptIPv4Broadcast)
s.arpt.passivePeers = uint8(cfg.PassivePeers)
err = s.resetARP()
if err != nil {
@@ -543,8 +550,12 @@ func (s *StackAsync) RegisterUDP4(node lneto.StackNode, remoteAddr [4]byte, remo
if idx >= cap(s.userUDPs) {
return lneto.ErrExhausted
}
raddr := remoteAddr[:]
if remoteAddr == [4]byte{} {
raddr = nil
}
s.userUDPs = s.userUDPs[:idx+1]
s.userUDPs[idx].SetStackNode(node, remoteAddr[:], remotePort)
s.userUDPs[idx].SetStackNode(node, raddr, remotePort)
return s.udps.RegisterMACFiltered(&s.userUDPs[idx], nil)
}
@@ -831,24 +842,18 @@ func addr4(addr [4]byte, ok bool) netip.Addr {
return netip.AddrFrom4(addr)
}
// Debug prints debugging information. Very useful for users when coupled with
// the debugheaplog build tag. See [internal.LogAttrs] debugheaplog version.
//
// go build -tags=debugheaplog ./yourprogram
// Debug prints debugging and heap information.
func (s *StackAsync) Debug(msg string) {
internal.LogAttrs(slog.Default(), slog.LevelDebug, "stackasync",
internal.LogAttrsAndAllocs(msg, slog.Default(), slog.LevelDebug, "stackasync",
slog.String("umsg", msg),
slog.Uint64("sent", s.stats.TotalSent),
slog.Uint64("recv", s.stats.TotalReceived),
)
}
// DebugErr prints debugging and error info. Very useful for users when coupled with
// the debugheaplog build tag. See [internal.LogAttrs] debugheaplog version.
//
// go build -tags=debugheaplog ./yourprogram
// DebugErr prints debugging and heap information.
func (s *StackAsync) DebugErr(msg, err string) {
internal.LogAttrs(slog.Default(), slog.LevelError, "stackasync",
internal.LogAttrsAndAllocs(msg, slog.Default(), slog.LevelError, "stackasync",
slog.String("umsg", msg),
slog.String("err", err),
slog.Uint64("sent", s.stats.TotalSent),
+17 -20
View File
@@ -9,22 +9,19 @@ import (
"slices"
"sync"
"time"
"github.com/soypat/lneto"
)
const (
_AF_INET = 0x2
_SOCK_STREAM = 0x1
_SOCK_DGRAM = 0x2
_SOL_SOCKET = 0x1
_SO_KEEPALIVE = 0x9
_SOL_TCP = 0x6
_TCP_KEEPINTVL = 0x5
_IPPROTO_TCP = 0x6
_IPPROTO_UDP = 0x11
AF_INET = 0x2
AF_INET6 = 0xa
SOCK_STREAM = 0x1
SOCK_DGRAM = 0x2
// Made up, not a real IP protocol number. This is used to create a
// TLS socket on the device, assuming the device supports mbed TLS.
_IPPROTO_TLS = 0xFE
_F_SETFL = 0x4
_IPPROTO_TLS = 0xFE // TODO: is this a good idea?
)
// gosocket is the stack abstraction for the baremetal proposal.
@@ -92,7 +89,7 @@ func (s *StackBerkeley) SetSockOpt(sockfd int, level int, opt int, value any) er
// domain must be AF_INET. stype must be SOCK_STREAM or SOCK_DGRAM.
// protocol must be IPPROTO_TCP, IPPROTO_UDP, or IPPROTO_TLS.
func (s *StackBerkeley) Socket(domain int, stype int, protocol int) (sockfd int, _ error) {
if domain != _AF_INET {
if domain != AF_INET {
return -1, fmt.Errorf("unsupported domain %d", domain)
}
s.mu.Lock()
@@ -117,23 +114,23 @@ func (s *StackBerkeley) Connect(sockfd int, host string, ip netip.AddrPort) erro
var raddr net.Addr
var network string
var family, sotype int
switch pending.sock.protocol {
case _IPPROTO_TCP, _IPPROTO_TLS:
switch lneto.IPProto(pending.sock.protocol) {
case lneto.IPProtoTCP, _IPPROTO_TLS:
if pending.sock.boundAddr.IsValid() && pending.sock.boundAddr.Port() > 0 {
laddr = &net.TCPAddr{IP: pending.sock.boundAddr.Addr().AsSlice(), Port: int(pending.sock.boundAddr.Port())}
}
raddr = &net.TCPAddr{IP: ip.Addr().AsSlice(), Port: int(ip.Port())}
network = "tcp4"
family = _AF_INET
sotype = _SOCK_STREAM
case _IPPROTO_UDP:
family = AF_INET
sotype = SOCK_STREAM
case lneto.IPProtoUDP:
if pending.sock.boundAddr.IsValid() && pending.sock.boundAddr.Port() > 0 {
laddr = &net.UDPAddr{IP: pending.sock.boundAddr.Addr().AsSlice(), Port: int(pending.sock.boundAddr.Port())}
}
raddr = &net.UDPAddr{IP: ip.Addr().AsSlice(), Port: int(ip.Port())}
network = "udp4"
family = _AF_INET
sotype = _SOCK_DGRAM
family = AF_INET
sotype = SOCK_DGRAM
default:
return fmt.Errorf("Connect: unsupported protocol %d", pending.sock.protocol)
}
@@ -166,7 +163,7 @@ func (s *StackBerkeley) Listen(sockfd int, backlog int) error {
if pending.sock.boundAddr.IsValid() && pending.sock.boundAddr.Port() > 0 {
laddr = &net.TCPAddr{IP: pending.sock.boundAddr.Addr().AsSlice(), Port: int(pending.sock.boundAddr.Port())}
}
c, err := s.gosocket(context.Background(), "tcp4", _AF_INET, _SOCK_STREAM, laddr, nil)
c, err := s.gosocket(context.Background(), "tcp4", AF_INET, SOCK_STREAM, laddr, nil)
if err != nil {
return err
}
+29 -11
View File
@@ -31,8 +31,20 @@ func (s *StackAsync) StackBlocking(stackProtoBackoff lneto.BackoffStrategy) Stac
}
type StackBlocking struct {
async *StackAsync
_backoff lneto.BackoffStrategy
async *StackAsync
_backoff lneto.BackoffStrategy
_nanotime func() int64
}
func (s StackBlocking) nanotime() int64 {
if s._nanotime != nil {
return s._nanotime()
}
return time.Now().UnixNano()
}
func (s StackBlocking) deadlineTO(timeout time.Duration) int64 {
return int64(timeout) + s.nanotime()
}
func (s StackBlocking) DoDHCPv4(reqAddr [4]byte, timeout time.Duration) (*DHCPResults, error) {
@@ -41,7 +53,7 @@ func (s StackBlocking) DoDHCPv4(reqAddr [4]byte, timeout time.Duration) (*DHCPRe
return nil, err
}
var backoffs uint
deadline := time.Now().Add(timeout)
deadline := s.deadlineTO(timeout)
requested := false
var lastState dhcpv4.ClientState
for range maxIter {
@@ -108,7 +120,7 @@ func (s StackBlocking) DoNTP(hostAddr netip.Addr, timeout time.Duration) (offset
return -1, err
}
deadline := time.Now().Add(timeout)
deadline := s.deadlineTO(timeout)
var done bool
var backoffs uint
for range maxIter {
@@ -130,7 +142,7 @@ func (s StackBlocking) DoResolveHardwareAddress6(addr netip.Addr, timeout time.D
return hw, err
}
var backoffs uint
deadline := time.Now().Add(timeout)
deadline := s.deadlineTO(timeout)
for range maxIter {
hw, err = s.async.ResultResolveHardwareAddress6(addr)
if err == nil {
@@ -159,7 +171,7 @@ func (s StackBlocking) DoLookupIPType(host string, timeout time.Duration, qtype
return nil, err
}
deadline := time.Now().Add(timeout)
deadline := s.deadlineTO(timeout)
var backoffs uint
for range maxIter {
addrs, completed, err := s.async.ResultLookupIP(host)
@@ -181,7 +193,15 @@ func (s StackBlocking) DoDialTCP(conn *tcp.Conn, localPort uint16, addrp netip.A
if err != nil {
return err
}
deadline := time.Now().Add(timeout)
err = s.waitDialTCP(conn, timeout)
if err != nil {
conn.Abort()
}
return err
}
func (s StackBlocking) waitDialTCP(conn *tcp.Conn, timeout time.Duration) (err error) {
deadline := s.deadlineTO(timeout)
var backoffs uint
for range maxIter {
state := conn.State()
@@ -189,12 +209,10 @@ func (s StackBlocking) DoDialTCP(conn *tcp.Conn, localPort uint16, addrp netip.A
return nil
} else if state == tcp.StateSynSent || state == tcp.StateSynRcvd || conn.AwaitingSynSend() {
if err = s.checkDeadline(deadline); err != nil {
conn.Abort()
return err
}
} else {
// Unexpected state, abort and terminate connection.
conn.Abort()
return errTCPFailedToConnect
}
s.backoff(backoffs)
@@ -203,8 +221,8 @@ func (s StackBlocking) DoDialTCP(conn *tcp.Conn, localPort uint16, addrp netip.A
return errDeadlineExceed
}
func (s StackBlocking) checkDeadline(deadline time.Time) error {
if time.Since(deadline) > 0 {
func (s StackBlocking) checkDeadline(deadline int64) error {
if s.nanotime() > deadline {
return errDeadlineExceed
}
return nil
+21 -5
View File
@@ -18,10 +18,15 @@ import (
const (
sockSTREAM = 0x1
sockDGRAM = 0x2
defaultTCPDialTimeout = 2 * time.Second
defaultTCPDialRetries = 1
)
type StackGoConfig struct {
ListenerPoolConfig TCPPoolConfig
TCPDialTimeout time.Duration
TCPDialRetries int
}
func (s *StackAsync) StackGo(stackProtoBackoff lneto.BackoffStrategy, cfg StackGoConfig) StackGo {
@@ -32,16 +37,27 @@ func (s *StackAsync) StackGo(stackProtoBackoff lneto.BackoffStrategy, cfg StackG
}
func (s StackBlocking) StackGo(cfg StackGoConfig) StackGo {
if cfg.TCPDialRetries <= 0 {
cfg.TCPDialRetries = defaultTCPDialRetries
}
if cfg.TCPDialTimeout <= 0 {
cfg.TCPDialTimeout = defaultTCPDialTimeout
}
sg := StackGo{
blk: s,
plcfg: cfg.ListenerPoolConfig,
blk: s,
plcfg: cfg.ListenerPoolConfig,
tcpDialTimeout: cfg.TCPDialTimeout,
tcpDialRetries: cfg.TCPDialRetries,
}
return sg
}
type StackGo struct {
blk StackBlocking
plcfg TCPPoolConfig
blk StackBlocking
plcfg TCPPoolConfig
tcpDialTimeout time.Duration
tcpDialRetries int
}
func (s StackGo) Socket(ctx context.Context, network string, family, sotype int, laddr, raddr net.Addr) (c any, err error) {
@@ -171,7 +187,7 @@ func (s StackGo) SocketNetip(ctx context.Context, network string, family, sotype
if err != nil {
return nil, err
}
err = s.blk.async.DialTCP(&conn, laddr.Port(), raddr)
err = s.blk.StackRetrying().DoDialTCP(&conn, laddr.Port(), raddr, s.tcpDialTimeout, s.tcpDialRetries)
if err != nil {
return nil, err
}
+22 -5
View File
@@ -13,9 +13,11 @@ func (s *StackAsync) StackRetrying(stackProtoBackoff lneto.BackoffStrategy) Stac
if stackProtoBackoff == nil {
panic("nil backoff to StackRetrying")
}
return StackRetrying{
block: s.StackBlocking(stackProtoBackoff),
}
return s.StackBlocking(stackProtoBackoff).StackRetrying()
}
func (s StackBlocking) StackRetrying() StackRetrying {
return StackRetrying{block: s}
}
var (
@@ -93,13 +95,28 @@ func (s StackRetrying) DoResolveHardwareAddress6(addr netip.Addr, timeout time.D
func (s StackRetrying) DoDialTCP(conn *tcp.Conn, localPort uint16, addrp netip.AddrPort, timeout time.Duration, retries int) (err error) {
expectEnd := time.Now().Add(timeout * time.Duration(retries))
var firstErr error
for range retries {
err = s.block.DoDialTCP(conn, localPort, addrp, timeout)
for i := range retries {
if i == 0 || conn.State().IsClosed() {
err = s.block.async.DialTCP(conn, localPort, addrp)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
} else if conn.IsAwaitingControl() {
conn.RequeueControl()
}
err = s.block.waitDialTCP(conn, timeout)
if err == nil {
return nil
} else if firstErr == nil {
firstErr = err
}
if !conn.IsAwaitingControl() {
conn.Abort()
}
}
if time.Now().Before(expectEnd) {
if err != firstErr {
+2 -2
View File
@@ -41,7 +41,7 @@ func BenchmarkARPExchange(b *testing.B) {
var buf [frameSize]byte
b.ResetTimer()
for i := 0; i < b.N; i++ {
for b.Loop() {
err = c1.StartResolveHardwareAddress6(queryAddr)
if err != nil {
b.Fatal(err)
@@ -130,7 +130,7 @@ func BenchmarkTCPHandshake(b *testing.B) {
var pktbuf [frameSize]byte
b.ResetTimer()
for i := 0; i < b.N; i++ {
for b.Loop() {
// Setup connections.
err = sv.ListenTCP4(svconn, svPort)
if err != nil {
+48 -38
View File
@@ -16,6 +16,7 @@ import (
"github.com/soypat/lneto"
"github.com/soypat/lneto/ethernet"
"github.com/soypat/lneto/internal"
"github.com/soypat/lneto/internal/ltesto"
"github.com/soypat/lneto/ipv4"
"github.com/soypat/lneto/tcp"
)
@@ -120,7 +121,7 @@ func TestTCPListener_ConcurrentEcho(t *testing.T) {
wg.Add(1)
go func(clientID int) {
defer wg.Done()
if runClient(t, clientID, &clientStacks[clientID], &clientConns[clientID],
if runClient(t, ctx, clientID, &clientStacks[clientID], &clientConns[clientID],
serverIP, serverPort) {
clientSuccess[clientID] = true
}
@@ -206,7 +207,7 @@ func echoServer(ctx context.Context, listener *tcp.Listener) {
}
if listener.NumberOfReadyToAccept() == 0 {
time.Sleep(time.Millisecond)
runtime.Gosched()
continue
}
@@ -240,7 +241,7 @@ func echoServer(ctx context.Context, listener *tcp.Listener) {
}
}
func runClient(t *testing.T, id int, stack *StackAsync, conn *tcp.Conn,
func runClient(t *testing.T, ctx context.Context, id int, stack *StackAsync, conn *tcp.Conn,
serverAddr netip.Addr, serverPort uint16) bool {
// Dial server.
clientPort := uint16(10000 + id)
@@ -250,14 +251,8 @@ func runClient(t *testing.T, id int, stack *StackAsync, conn *tcp.Conn,
return false
}
// Wait for connection established (handshake via kernel loop).
deadline := time.Now().Add(5 * time.Second)
for conn.State() != tcp.StateEstablished {
if time.Now().After(deadline) {
t.Errorf("client %d: timeout waiting for established state, got %s", id, conn.State())
return false
}
time.Sleep(time.Millisecond)
for conn.State() != tcp.StateEstablished && ctx.Err() == nil {
runtime.Gosched()
}
// Send test data.
@@ -268,15 +263,11 @@ func runClient(t *testing.T, id int, stack *StackAsync, conn *tcp.Conn,
return false
}
// Read echo response.
// Read echo response. conn.Read yields (backoffYield) until data arrives, and the
// overall test timeout (ctx) backstops a hang.
var buf [64]byte
deadline = time.Now().Add(5 * time.Second)
var totalRead int
for totalRead < len(testData) {
if time.Now().After(deadline) {
t.Errorf("client %d: timeout waiting for echo response, got %d/%d bytes", id, totalRead, len(testData))
return false
}
for totalRead < len(testData) && ctx.Err() == nil {
n, err := conn.Read(buf[totalRead:])
if err != nil {
t.Errorf("client %d read failed: %v", id, err)
@@ -325,11 +316,24 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug - 99,
}))
// When the payload exceeds the Tx buffer, c1.Write must run in a background
// goroutine that blocks until the driver drains the buffer. The scheduler turns
// that blocking into a deterministic, sleep-free handoff: c1's backoff parks the
// writer and the driver releases it after freeing buffer space.
async := datalen > tx1Buf
var tsched *ltesto.Sched
var tgoro ltesto.SchedGoro
c1Backoff := backoffYield
if async {
tsched = ltesto.NewSched(t)
tgoro = tsched.Goro()
c1Backoff = tgoro.Yield
}
err := c1.Configure(tcp.ConnConfig{
RxBuf: nil,
TxBuf: make([]byte, tx1Buf),
TxPacketQueueSize: queueSize,
RWBackoff: backoffYield,
RWBackoff: c1Backoff,
Logger: logger,
})
if err != nil {
@@ -350,29 +354,19 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
for i := range datalen {
data[i] = byte(i)
}
deadline := time.Now().Add(3600 * time.Second)
err = c1.SetDeadline(deadline)
err2 := c2.SetDeadline(deadline)
if err != nil || err2 != nil {
t.Fatal(err)
}
async := datalen > tx1Buf
if async {
// Since data does not fit in TCP Tx buffer the test must be run asynchronously.
c1.InternalHandler().SetLoggers(logger, logger)
// c1.InternalHandler().SetLoggers(nil, nil)
go func() {
n, err := c1.Write(data)
if err != nil {
t.Error("async write", err)
} else if n != len(data) {
t.Error("io.Writer faulty implementation")
n, werr := c1.Write(data)
if werr == nil && n != len(data) {
werr = fmt.Errorf("async write %d of %d bytes", n, len(data))
}
err = c1.Close()
if err != nil {
t.Fatal("async close", err)
if werr == nil {
werr = c1.Close()
}
tgoro.FinishWithErr(werr)
}()
} else {
n, err := c1.Write(data)
@@ -389,7 +383,20 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
exchanging := 1
tcpData := 0
totalRead := 0
writerDone := false
for exchanging > 0 || c1.State().TxDataOpen() {
if async && !writerDone {
// Block until the writer parks on a full Tx buffer (or finishes). Servicing
// each park with exactly one pump round below keeps progress deterministic
// without sleeping or guessing whether the writer will park again.
done, werr := tsched.AwaitGoroYieldOrDone()
if werr != nil {
t.Error("async write/close:", werr)
}
if done {
writerDone = true
}
}
exchanges++
exchanging = exchangeEthernetOnce(t, s1, s2, buf)
frm, ok := getTCPFrame(buf[:exchanging])
@@ -401,18 +408,21 @@ func testCloseTransmitsPending(tst *tester, s1, s2 *StackAsync, c1, c2 *tcp.Conn
if err != nil {
t.Error(err)
} else if ngot != n {
t.Errorf("want %d data read c1->c1, got %d", n, ngot)
t.Errorf("want %d data read c1->c2, got %d", n, ngot)
} else if !internal.BytesEqual(buf[:n], data[totalRead:totalRead+n]) {
t.Errorf("exch%d data rx mismatch, want:\n%q\ngot:\n%q\n", exchanges, data[totalRead:totalRead+n], buf[:n])
}
totalRead += ngot
runtime.Gosched() // Yield to let c1 write via goroutine.
acks := exchangeEthernetOnce(t, s2, s1, buf) // Send ACK s1's way.
acks := exchangeEthernetOnce(t, s2, s1, buf) // Send ACK s1's way, freeing its Tx buffer.
if acks == 0 {
t.Error("no data sent back to s1")
}
}
}
if async && !writerDone {
// The ACK above freed Tx buffer space; release the writer to fill it and re-park.
tsched.YieldToGoro()
}
}
if c1.BufferedUnsent() != 0 {
t.Errorf("done %s: want no data left unsent got %d/%d", c1.State(), c1.BufferedUnsent(), len(data))
+8 -1
View File
@@ -2,7 +2,11 @@
package xnet
import "os"
import (
"os"
"github.com/soypat/lneto/internal"
)
var _pcap CapturePrinter
@@ -14,4 +18,7 @@ func init() {
func debugPacket(msg string, b []byte) {
_pcap.PrintEthernet(msg, b)
if internal.HeapAllocDebugging {
internal.LogAllocs(msg)
}
}
+2 -6
View File
@@ -3,7 +3,6 @@ package xnet
import (
"net/netip"
"testing"
"time"
"github.com/soypat/lneto"
"github.com/soypat/lneto/ethernet"
@@ -172,7 +171,7 @@ func TestStackAsyncListener_MultiSequentialConn(t *testing.T) {
chw := [6]byte{0xbe, 0xef, 0, 0, 0, 1}
sv.SetGatewayHardwareAddr(chw)
tst := testerFrom(t, MTU)
doRequest := func(caddrp netip.AddrPort, sleep time.Duration, data []byte) {
doRequest := func(caddrp netip.AddrPort, data []byte) {
var client StackAsync
err := client.Reset(StackConfig{
Hostname: "Client",
@@ -222,15 +221,12 @@ func TestStackAsyncListener_MultiSequentialConn(t *testing.T) {
if len(data) > 0 {
tst.TestTCPEstablishedSingleData(&client, sv, &clConn, svconn, data)
}
if sleep > 0 {
time.Sleep(sleep)
}
tst.TestTCPClose(&client, sv, &clConn, svconn)
}
for range 1000 {
caddr := caddr.Next()
doRequest(netip.AddrPortFrom(caddr, uint16(sv.Prand32())), 0, []byte("HTTP 1.0\r\n"))
doRequest(netip.AddrPortFrom(caddr, uint16(sv.Prand32())), []byte("HTTP 1.0\r\n"))
}
}
+164 -19
View File
@@ -2,10 +2,12 @@ package xnet
import (
"bytes"
"context"
"errors"
"math/rand"
"net/netip"
"sync"
"syscall"
"testing"
"time"
@@ -35,6 +37,21 @@ func TestTCPConn_ReadBlocksUntilDataAvailable(t *testing.T) {
client, sv, clconn, svconn := newTCPStacks(t, seed, MTU)
tst := testerFrom(t, MTU)
// Drive svconn.Read from a single background goroutine whose backoff is
// controlled by the scheduler. This lets the test thread know deterministically
// when Read has parked waiting for data, instead of sleeping and hoping it blocked.
tsched := ltesto.NewSched(t)
tgoro := tsched.Goro()
err := svconn.Configure(tcp.ConnConfig{
RxBuf: make([]byte, MTU),
TxBuf: make([]byte, MTU),
TxPacketQueueSize: 4,
RWBackoff: tgoro.Yield,
})
if err != nil {
t.Fatal(err)
}
tst.TestTCPSetupAndEstablish(sv, client, svconn, clconn, svPort, 1337)
// Verify no data buffered initially.
@@ -43,27 +60,25 @@ func TestTCPConn_ReadBlocksUntilDataAvailable(t *testing.T) {
}
sendData := []byte("blocking test data")
readDone := make(chan struct{})
var readN int
var readErr error
var readBuf [64]byte
// Start a goroutine to read from svconn - this should block since no data available.
go func() {
readN, readErr = svconn.Read(readBuf[:])
close(readDone)
n, err := svconn.Read(readBuf[:])
readN = n
tgoro.FinishWithErr(err)
}()
// Give Read time to enter blocking state.
select {
case <-readDone:
t.Fatal("Read returned immediately without data - expected blocking")
case <-time.After(50 * time.Millisecond):
// Good - Read is blocking as expected.
// AwaitGoroYield blocks until Read parks in backoff: deterministic proof that
// Read found no data available and is waiting.
tsched.AwaitGoroYield()
if readN != 0 {
t.Fatal("Read returned before data was available")
}
// Write data on client side.
_, err := clconn.Write(sendData)
_, err = clconn.Write(sendData)
if err != nil {
t.Fatal(err)
}
@@ -86,14 +101,9 @@ func TestTCPConn_ReadBlocksUntilDataAvailable(t *testing.T) {
t.Fatal(err)
}
// Now Read should unblock and return data.
select {
case <-readDone:
// Good - Read unblocked.
case <-time.After(500 * time.Millisecond):
t.Fatal("Read did not unblock after data became available")
}
// Release Read; the data is now available so it returns instead of backing off again.
tsched.YieldToGoro()
readErr := <-tsched.Done()
if readErr != nil {
t.Fatalf("Read returned error: %v", readErr)
}
@@ -105,6 +115,80 @@ func TestTCPConn_ReadBlocksUntilDataAvailable(t *testing.T) {
}
}
func TestStackGoTCPDialRetriesPendingControl(t *testing.T) {
const seed = 5678
const MTU = ethernet.MaxMTU
const tcptimeout = time.Second
const yield = 1 * time.Millisecond
client, sv, _, _ := newTCPStacks(t, seed, MTU)
tsched := ltesto.NewSched(t)
tgoro := tsched.Goro()
sg := client.StackBlocking(tgoro.Yield).StackGo(StackGoConfig{
ListenerPoolConfig: TCPPoolConfig{
QueueSize: 4,
TxBufSize: MTU,
RxBufSize: MTU,
NewBackoff: func() lneto.BackoffStrategy {
return backoffYield
},
},
TCPDialTimeout: tcptimeout,
TCPDialRetries: 2,
})
t.Log("start")
// closure to simulate time.
var now time.Duration
sg.blk._nanotime = func() int64 { return int64(now) }
laddr := netip.AddrPortFrom(netip.AddrFrom4(client.Addr4()), 1234)
raddr := netip.AddrPortFrom(netip.AddrFrom4(sv.Addr4()), 22)
go func() {
_, err := sg.SocketNetip(context.Background(), "tcp", syscall.AF_INET, sockSTREAM, laddr, raddr)
tgoro.FinishWithErr(err)
}()
npacket := 0
ntcppacket := 0
var buf [ethernet.MaxMTU + ethernet.MaxOverheadSize]byte
for !t.Failed() { // Tinygo does not implement failnow.
tsched.AwaitGoroYield()
n, err := client.EgressEthernet(buf[:])
now += tcptimeout / 100
if err != nil {
t.Fatal(err)
} else if n == 0 {
t.Fatal("expected packet from socketnetip", npacket, ntcppacket)
}
npacket++
frm, ok := getTCPFrame(buf[:])
if !ok {
tsched.YieldToGoro()
continue
}
ntcppacket++
_, flags := frm.OffsetAndFlags()
if flags != tcp.FlagSYN {
t.Fatal("expected SYN packet")
}
switch ntcppacket {
case 1:
now += 2 * tcptimeout
tsched.YieldToGoro()
case 2:
now += 2 * tcptimeout
tsched.YieldToGoro()
select {
case <-time.After(time.Second):
t.Fatal("SocketNetip hanging")
case err := <-tsched.Done():
if err != errDeadlineExceed {
t.Fatal("expected deadline exceeded", err)
}
return // Test success.
}
}
}
}
func TestStackAsyncTCP_multipacket(t *testing.T) {
const seed = 1234
const MTU = 512
@@ -1051,3 +1135,64 @@ func getTCPFrame(etherFrame []byte) (tcp.Frame, bool) {
func backoffYield(consecutiveBackoffs uint) time.Duration {
return lneto.BackoffFlagGosched
}
// TestEgressIP_TCPMSSAdvertisesMTU guards against advertising a Maximum Segment Size
// derived from the (oversized) egress buffer instead of the link MTU. See the
// EgressIP clip in StackAsync: without it the SYN advertises ~65479 rather than
// MTU-ipHdr-20.
func TestEgressIP_TCPMSSAdvertisesMTU(t *testing.T) {
const mtu = 1280
const wantMSS = uint16(mtu - 20 - 20) // -IPv4 header -TCP header = 1240.
s1, s2, c1, _ := newTCPStacks(t, 4, mtu)
raddr := s2.Addr4()
err := s1.DialTCP4(c1, 12345, raddr, 80)
if err != nil {
t.Fatal(err)
}
// Emit the client SYN through the IP (TUN) egress path using a buffer far
// larger than the MTU. The advertised MSS must reflect the MTU, not len(buf).
buf := make([]byte, 65535)
n, err := s1.EgressIP(buf)
if err != nil {
t.Fatal(err)
} else if n == 0 {
t.Fatal("no SYN emitted")
} else if n > mtu {
t.Fatalf("emitted IP datagram %d exceeds MTU %d", n, mtu)
}
ifrm, err := ipv4.NewFrame(buf[:n])
if err != nil {
t.Fatal(err)
}
tfrm, err := tcp.NewFrame(ifrm.Payload())
if err != nil {
t.Fatal(err)
}
seg := tfrm.Segment(len(tfrm.Payload()))
if !seg.Flags.HasAny(tcp.FlagSYN) {
t.Fatalf("expected SYN, got flags %s", seg.Flags.String())
}
var op tcp.OptionCodec
gotMSS := uint16(0)
found := false
err = op.ForEachOption(tfrm.Options(), func(kind tcp.OptionKind, data []byte) error {
if kind == tcp.OptMaxSegmentSize && len(data) == 2 {
gotMSS = uint16(data[0])<<8 | uint16(data[1])
found = true
}
return nil
})
if err != nil {
t.Fatal(err)
}
if !found {
t.Fatal("no MSS option in SYN")
}
if gotMSS != wantMSS {
t.Errorf("advertised MSS = %d, want %d (MTU %d - 40)", gotMSS, wantMSS, mtu)
}
}
-22
View File
@@ -1,22 +0,0 @@
//go:build !tinygo
// Exclude tinygo compiler from build since exec package and t.Skip() are unimplemented.
// Also: wouldn't including tinygo cause a recursive call to tinygo test?
package xnet
import (
"os/exec"
"testing"
)
func TestTinyGoTest(t *testing.T) {
if exec.Command("tinygo", "version").Run() != nil {
t.Skip("tinygo not installed")
}
// This takes a long time. Consider running only important
// tests with -run=TestXxx flag: `go test ./... -run=TestXxx`
out, err := exec.Command("tinygo", "test", ".").CombinedOutput()
if err != nil {
t.Fatal("tinygo failed to test:", err, "\n", string(out))
}
}
+82
View File
@@ -0,0 +1,82 @@
//go:build !tinygo
// Exclude tinygo compiler from build since exec package and t.Skip() are unimplemented.
// Also: wouldn't including tinygo cause a recursive call to tinygo test?
package xnet
import (
"debug/elf"
"os"
"os/exec"
"strings"
"testing"
)
func TestTinyGoTest(t *testing.T) {
if testing.Short() {
t.Skip("`tinygo test` skipped on short test")
}
if exec.Command("tinygo", "version").Run() != nil {
t.Skip("tinygo not installed")
}
// This takes a long time. Consider running only important
// tests with -run=TestXxx flag: `go test ./... -run=TestXxx`
out, err := exec.Command("tinygo", "test", ".").CombinedOutput()
if err != nil {
t.Fatal("tinygo failed to test:", err, "\n", string(out))
}
}
func TestStackAsyncNoIPv6(t *testing.T) {
const name = "mwe.elf"
cmd := exec.Command("go", "build", "-o="+name, "../../examples/min-working-example")
cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, "\n", string(out))
}
defer os.Remove(name)
f, err := os.Open(name)
if err != nil {
t.Fatal(err)
}
defer f.Close()
file, err := elf.NewFile(f)
if err != nil {
t.Fatal(err)
}
syms, err := file.Symbols()
if err != nil {
t.Fatal(err)
}
// Sanity check that symbol/debug information was compiled in normally.
// If this exported method is missing the binary was likely stripped
// (e.g. -ldflags="-s -w") and the IPv6 absence check below would be
// meaningless since every symbol would be gone.
const wantSym = "(*StackAsync).AssimilateDHCPResults"
if !elfContainsSymbol(t, syms, wantSym) {
t.Fatalf("expected symbol %q in %s; symbol table may be stripped", wantSym, name)
}
// Guarantee the compiler did not aggressively include the IPv6 stack in
// this IPv4-only example. Methods on the unexported stack6 type are the
// IPv6 implementation; their presence means IPv6 code leaked into the
// binary and was not dead-code eliminated.
const ipv6Sym = "(*stack6)"
if elfContainsSymbol(t, syms, ipv6Sym) {
t.Errorf("unexpected IPv6 symbol %q found in %s; IPv6 functionality was not eliminated from IPv4-only build", ipv6Sym, name)
}
}
// elfContainsSymbol reports whether the ELF binary at path defines a symbol
// whose name contains identifier. It reads the symbol table (.symtab), which a
// normal go build retains.
func elfContainsSymbol(t *testing.T, syms []elf.Symbol, identifier string) bool {
t.Helper()
for i := range syms {
if strings.Contains(syms[i].Name, identifier) {
return true
}
}
return false
}