mirror of
https://github.com/soypat/lneto.git
synced 2026-07-26 10:38:47 +00:00
Compare commits
25 Commits
dhcp-allocator
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8873b55e5d | |||
| 41c7e3c445 | |||
| 1bbfd5eac3 | |||
| 1a474154cb | |||
| 347cef9ba5 | |||
| 157a8537a2 | |||
| 766e03e90e | |||
| ab1a0c735a | |||
| 3c1f0e0281 | |||
| 99e9d90a60 | |||
| 4fc53a84cd | |||
| 104a2b0b26 | |||
| e5f8ac0687 | |||
| 3b5b010673 | |||
| 4d6363da00 | |||
| cdc99fb9ff | |||
| 96a02e7d2c | |||
| b795d6a437 | |||
| fa609ea54f | |||
| a42f0b404c | |||
| ef279863a9 | |||
| d5ea2efdf4 | |||
| a0a5336108 | |||
| 28addf329a | |||
| 860d6d00bb |
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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].
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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].
|
||||
//
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user