ICMPEngine is a small, embeddable library for sending non-privileged ICMP echo requests and receiving replies concurrently, without blocking on per-packet timeouts.
Key features:
- One IPv4 socket and one IPv6 socket; matches replies to requests across many destinations concurrently.
- Does not wait for a packet's timeout before sending the next — outstanding pings are tracked centrally.
- A single expiry timer tracks the soonest-expiring outstanding ping using container/heap (a typed priority queue). Timeouts are per-ping, so one engine can mix a 10ms LAN host and an hours-away link (see
PingTimeout). - Built to embed:
context.Contextcancellation, functional-options construction, errors returned instead oflog.Fatal, and standard-library log/slog logging (passnilfor none — no logging dependency). - Leverages golang.org/x/net/icmp and IPPROTO_ICMP NonPrivilegedPing sockets (lwn.net/Articles/422330).
- Uses the standard library net/netip IP type.
- Configurable ICMP payload size and DSCP marking, for both IPv4 and IPv6 (see Packet size and DSCP).
go get github.com/randomizedcoder/icmpengine
Non-privileged ICMP on Linux requires the ping group range to include your gid:
sudo sysctl -w net.ipv4.ping_group_range="0 2147483647"
Ping a single host (see ./example/simple):
package main
import (
"context"
"fmt"
"net/netip"
"time"
"github.com/randomizedcoder/icmpengine"
)
func main() {
eng, err := icmpengine.New(
icmpengine.WithTimeout(500 * time.Millisecond),
)
if err != nil {
panic(err)
}
ctx := context.Background()
if err := eng.Start(ctx); err != nil {
panic(err)
}
defer eng.Close()
res, err := eng.Ping(ctx, netip.MustParseAddr("8.8.8.8"), 10, 100*time.Millisecond, icmpengine.SortRTTs())
if err != nil {
panic(err)
}
fmt.Printf("%s: success=%d/%d min=%s mean=%s max=%s\n",
res.IP, res.Successes, res.Count, res.Min, res.Mean, res.Max)
}Ping many hosts concurrently with a bounded worker pool (see ./example/concurrent):
targets := []icmpengine.Target{
{Addr: netip.MustParseAddr("8.8.8.8"), Count: 20, Interval: 50 * time.Millisecond},
{Addr: netip.MustParseAddr("1.1.1.1"), Count: 20, Interval: 50 * time.Millisecond},
}
results, err := eng.PingAll(ctx, 4 /* workers */, targets) // results aligned to targetsRunnable examples and a fuller CLI:
go run ./example/simple -dest 8.8.8.8 -count 5
go run ./example/concurrent -dest 8.8.8.8,1.1.1.1 -workers 4
go run ./cmd/icmpengine -dest 127.0.0.1,::1 -count 10
The engine logs via log/slog. Pass icmpengine.WithLogger(l) to supply a
*slog.Logger, or omit it (or pass nil) to disable logging entirely — there is
no logging dependency to pull in.
WithTimeout sets the engine's default, but each Ping (and each PingAll
Target) can override it with PingTimeout, so different destinations can wait
different amounts of time — a 10ms LAN host and an hours-away interplanetary link
in the same engine:
res, _ := eng.Ping(ctx, lan, 5, time.Second, icmpengine.PingTimeout(10*time.Millisecond))
res, _ := eng.Ping(ctx, mars, 5, time.Minute, icmpengine.PingTimeout(3*time.Hour))The timeout machinery is covered by deterministic testing/synctest tests that
exercise the full range (LAN microseconds → Mars-rover hours) in fake time, so a
3-hour timeout test runs in microseconds.
Payload size is a per-ping option. PayloadSize(n) appends n data bytes
after the 8-byte ICMP header — the same semantics as ping -s n (so
PayloadSize(56) yields a 64-byte ICMP message; the kernel adds the IP header).
It defaults to 0 (a bare echo request) and accepts 0..65500. It works
identically for IPv4 and IPv6:
res, _ := eng.Ping(ctx, addr, 5, time.Second, icmpengine.PayloadSize(56))DSCP is an engine-level option. WithDSCP(v) marks every echo request with
the 6-bit DiffServ code point v (0..63), written into the IPv4 ToS / IPv6
Traffic Class byte as v<<2 (the low two ECN bits stay zero). It is applied
once, at Start, to both the IPv4 and IPv6 socket. It is engine-wide rather
than per-ping because the engine shares one socket per family across all
concurrent pings, and IPv4 only exposes ToS as a persistent socket option:
eng, _ := icmpengine.New(icmpengine.WithDSCP(46)) // 46 = EF (Expedited Forwarding)TTL / hop limit is also engine-level. WithTTL(n) sets the outgoing IPv4
TTL (and the equivalent IPv6 hop limit) for every echo request, applied once at
Start to both sockets via SetTTL / SetHopLimit. It defaults to 64 (the
Linux ping / kernel default); a value of 0 keeps the kernel default without
setting the option, and the accepted range is 0..255:
eng, _ := icmpengine.New(icmpengine.WithTTL(1)) // e.g. TTL 1 to only reach the first hopSource address is engine-level. WithSource(addr) binds the sockets so echo
requests leave from a specific local address (useful on multi-homed hosts). The
address applies to its own family only (an IPv4 source binds the IPv4 socket and
leaves IPv6 on the wildcard, and vice versa); binding a non-local address makes
Start fail:
eng, _ := icmpengine.New(icmpengine.WithSource(netip.MustParseAddr("10.0.0.5")))Don't-Fragment is engine-level and Linux-only. WithDontFragment(true) sets
the IPv4 DF bit (and the IPv6 equivalent) by enabling path-MTU discovery on the
socket (IP_MTU_DISCOVER = IP_PMTUDISC_DO) — the same trick ping -M do uses,
so no raw socket / CAP_NET_RAW is required. Combined with PayloadSize it
probes the path MTU. On non-Linux platforms Start returns
ErrDontFragmentUnsupported:
eng, _ := icmpengine.New(icmpengine.WithDontFragment(true))
res, _ := eng.Ping(ctx, addr, 1, 0, icmpengine.PayloadSize(1472)) // fails if the path MTU is smallerThe CLI exposes these as -size (bytes), -dscp (0–63), -ttl (1–255),
-source (bind address) and -df (Don't-Fragment):
icmpengine -dest 8.8.8.8,2001:4860:4860::8888 -count 5 -size 56 -dscp 46 -ttl 64 -df
Outstanding pings are tracked in a swappable "expiry tracker" (a priority queue with arbitrary removal). Six backends are built in and selectable at construction, all validated by the same correctness suite:
icmpengine.BackendDaryHeap— 8-ary array heap (default; fastest on the realistic workload, O(1) peek, no external dependency).icmpengine.BackendHeap—container/heapbinary min-heap (stdlib, O(1) peek, no external dependency).icmpengine.BackendPairing— pairing heap.icmpengine.BackendBTree—github.com/google/btree.icmpengine.BackendRadix— fixed-base radix trie.icmpengine.BackendTimingWheel— hierarchical timing wheel + btree overflow.
eng, _ := icmpengine.New(icmpengine.WithExpiryBackend(icmpengine.BackendHeap)) // opt into the stdlib heapThe CLI exposes this as -backend heap|dary|pairing|btree|radix|wheel. The
8-ary heap is the default — benchmarking across uniform and mixed
(heterogeneous per-ping timeouts µs…hours) workloads plus the engine-level fleet
shows it consistently fastest, at the same allocations as the binary heap (just a
shallower, more cache-friendly array). The flat array heaps beat the pointer/bucket
structures because at these sizes cache behavior and constant factors decide it,
not asymptotics.
See docs/backends.md for a per-backend guide (what each is, pros/cons, when to pick it) and docs/benchmarking.md for the full methodology, result tables, and the fan-out sweep.
This repo ships a Nix flake (thin flake.nix orchestrator + modular nix/).
nix develop # dev shell (Go, golangci-lint, gopls, delve, ...)
nix build .#icmpengine # main binary (default variant)
nix build .#icmpengine-debug # keeps symbols + DWARF
nix build .#icmpengine-stripped # smallest
nix run .#default -- --dest 127.0.0.1,::1 --count 5
nix flake check # gofmt + nix-fmt + go-vet + golangci (Tier 0+1) + gosec + version smoke + race
Static analysis is tiered (like the reference xtcp2 flake):
lint-quick # Tier 0 golangci (~30s) — dev shell helper
lint # Tier 1 golangci (~2min, CI-gating)
lint-comprehensive # Tier 2 golangci (~10min, non-gating)
nix build .#golangci-lint-comprehensive # Tier 2 as a package
nix build .#quality-report && cat result/quality-report.md # every tool, one report
nix run .#quality-report # print the aggregated report
nix flake check gates Tier 0, Tier 1, gosec, gofmt, nix-fmt, go-vet, the
version smoke and the race detector. Tier 2 (complexity/duplication/naming) is
surfaced for awareness via the quality-report and the comprehensive package,
but does not gate CI.
OCI image (scratch-based, single binary):
nix build .#oci-icmpengine && ./result | docker load
docker run --rm icmpengine:latest --version
# runtime ping needs NET_RAW or a permissive ping_group_range:
docker run --rm --sysctl net.ipv4.ping_group_range="0 2147483647" \
icmpengine:latest --dest 127.0.0.1 --count 3
Consumers can pull the binary via the overlay
(inputs.icmpengine.overlays.default → pkgs.icmpengine).
Note: the hermetic nix flake check runs only the socket-free unit tests; the
loopback ICMP integration tests need privileges the Nix sandbox lacks and run in
GitHub Actions instead.
| Dependancy | License | Link |
|---|---|---|
| Golang | BSD | https://golang.org/LICENSE |
| github.com/google/btree v1.1.3 | Apache 2.0 | https://github.com/google/btree/blob/master/LICENSE |
| github.com/go-cmd/cmd v1.4.3 | MIT | https://github.com/go-cmd/cmd/blob/master/LICENSE |
| github.com/pkg/profile v1.7.0 | BSD | https://github.com/pkg/profile/blob/master/LICENSE |
| github.com/prometheus/client_golang v1.23.2 | Apache 2.0 | https://github.com/prometheus/client_golang/blob/master/LICENSE |
| golang.org/x/net v0.57.0 | BSD | https://golang.org/LICENSE |
The IP type is the standard library net/netip,
and logging is the standard library log/slog, so
neither inet.af/netaddr nor hashicorp/go-hclog is a dependency any more.
The library needs only golang.org/x/net and github.com/google/btree
(the latter tiny, with zero transitive dependencies). The remaining modules are
used solely by the cmd/icmpengine CLI (profiling, Prometheus metrics, and the
root sysctl helper), so embedding the library does not pull them in.
$ cat go.mod
module github.com/randomizedcoder/icmpengine
go 1.26.4
require (
github.com/go-cmd/cmd v1.4.3
github.com/google/btree v1.1.3
github.com/pkg/profile v1.7.0
github.com/prometheus/client_golang v1.23.2
golang.org/x/net v0.57.0
)
How to tag
git tag
git tag -a v1.0.1 -m "v1.0.1"
git push origin --tags
