mirror of
https://github.com/tailscale/tailscale.git
synced 2026-06-03 21:01:54 +08:00
net/porttrack: add net.Listen wrapper to help tests allocate ports race-free
Some checks failed
checklocks / checklocks (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
Dockerfile build / deploy (push) Has been cancelled
test installer.sh / test (curl, alpine:3.21) (push) Has been cancelled
test installer.sh / test (curl, alpine:edge) (push) Has been cancelled
test installer.sh / test (curl, alpine:latest) (push) Has been cancelled
test installer.sh / test (curl, amazonlinux:latest) (push) Has been cancelled
test installer.sh / test (curl, archlinux:latest) (push) Has been cancelled
test installer.sh / test (curl, debian:oldstable-slim) (push) Has been cancelled
test installer.sh / test (curl, debian:sid-slim) (push) Has been cancelled
test installer.sh / test (curl, debian:stable-slim, 1.80.0) (push) Has been cancelled
test installer.sh / test (curl, debian:testing-slim) (push) Has been cancelled
test installer.sh / test (curl, elementary/docker:stable) (push) Has been cancelled
test installer.sh / test (curl, elementary/docker:unstable) (push) Has been cancelled
test installer.sh / test (curl, fedora:latest, 1.80.0) (push) Has been cancelled
test installer.sh / test (curl, kalilinux/kali-dev) (push) Has been cancelled
test installer.sh / test (curl, kalilinux/kali-rolling) (push) Has been cancelled
test installer.sh / test (curl, opensuse/leap:latest) (push) Has been cancelled
test installer.sh / test (curl, opensuse/tumbleweed:latest) (push) Has been cancelled
test installer.sh / test (curl, oraclelinux:8) (push) Has been cancelled
test installer.sh / test (curl, oraclelinux:9) (push) Has been cancelled
test installer.sh / test (curl, parrotsec/core:latest) (push) Has been cancelled
test installer.sh / test (curl, rockylinux:8.7) (push) Has been cancelled
test installer.sh / test (curl, rockylinux:9) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:20.04) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:22.04) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:24.04, 1.80.0) (push) Has been cancelled
test installer.sh / test (wget, debian:oldstable-slim) (push) Has been cancelled
test installer.sh / test (wget, debian:sid-slim) (push) Has been cancelled
CI / gomod-cache (push) Has been cancelled
CI / fuzz (push) Has been cancelled
tailscale.com/cmd/vet / vet (push) Has been cancelled
test installer.sh / notify-slack (push) Has been cancelled
CI / race-root-integration (1/4) (push) Has been cancelled
CI / race-root-integration (2/4) (push) Has been cancelled
CI / race-root-integration (3/4) (push) Has been cancelled
CI / race-root-integration (4/4) (push) Has been cancelled
CI / test (-race, amd64, 1/3) (push) Has been cancelled
CI / test (-race, amd64, 2/3) (push) Has been cancelled
CI / test (-race, amd64, 3/3) (push) Has been cancelled
CI / test (386) (push) Has been cancelled
CI / test (amd64) (push) Has been cancelled
CI / Windows (${{ matrix.name || matrix.shard}}) (win-bench, benchmarks) (push) Has been cancelled
CI / Windows (${{ matrix.name || matrix.shard}}) (win-shard-1-2, 1/2) (push) Has been cancelled
CI / Windows (${{ matrix.name || matrix.shard}}) (win-shard-2-2, 2/2) (push) Has been cancelled
CI / Windows (win-tool-go) (push) Has been cancelled
CI / macos (push) Has been cancelled
CI / privileged (push) Has been cancelled
CI / vm (push) Has been cancelled
CI / cross (386, linux) (push) Has been cancelled
CI / cross (amd64, darwin) (push) Has been cancelled
CI / cross (amd64, freebsd) (push) Has been cancelled
CI / cross (amd64, openbsd) (push) Has been cancelled
CI / cross (amd64, windows) (push) Has been cancelled
CI / cross (arm, 5, linux) (push) Has been cancelled
CI / cross (arm, 7, linux) (push) Has been cancelled
CI / cross (arm64, darwin) (push) Has been cancelled
CI / cross (arm64, linux) (push) Has been cancelled
CI / cross (arm64, windows) (push) Has been cancelled
CI / cross (loong64, linux) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / crossmin (amd64, illumos) (push) Has been cancelled
CI / crossmin (amd64, plan9) (push) Has been cancelled
CI / crossmin (amd64, solaris) (push) Has been cancelled
CI / crossmin (ppc64, aix) (push) Has been cancelled
CI / android (push) Has been cancelled
CI / wasm (push) Has been cancelled
CI / tailscale_go (push) Has been cancelled
CI / depaware (push) Has been cancelled
CI / go_generate (push) Has been cancelled
CI / make_tidy (push) Has been cancelled
CI / licenses (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--with-tags-all=darwin, arm64, darwin, macOS) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--with-tags-all=linux, amd64, linux, Linux) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--with-tags-all=windows, amd64, windows, Windows) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=1/4, amd64, linux, Portable (1/4)) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=2/4, amd64, linux, Portable (2/4)) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=3/4, amd64, linux, Portable (3/4)) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=4/4, amd64, linux, Portable (4/4)) (push) Has been cancelled
CI / notify_slack (push) Has been cancelled
CI / merge_blocker (push) Has been cancelled
CI / check_mergeability_strict (push) Has been cancelled
CI / check_mergeability (push) Has been cancelled
Some checks failed
checklocks / checklocks (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
Dockerfile build / deploy (push) Has been cancelled
test installer.sh / test (curl, alpine:3.21) (push) Has been cancelled
test installer.sh / test (curl, alpine:edge) (push) Has been cancelled
test installer.sh / test (curl, alpine:latest) (push) Has been cancelled
test installer.sh / test (curl, amazonlinux:latest) (push) Has been cancelled
test installer.sh / test (curl, archlinux:latest) (push) Has been cancelled
test installer.sh / test (curl, debian:oldstable-slim) (push) Has been cancelled
test installer.sh / test (curl, debian:sid-slim) (push) Has been cancelled
test installer.sh / test (curl, debian:stable-slim, 1.80.0) (push) Has been cancelled
test installer.sh / test (curl, debian:testing-slim) (push) Has been cancelled
test installer.sh / test (curl, elementary/docker:stable) (push) Has been cancelled
test installer.sh / test (curl, elementary/docker:unstable) (push) Has been cancelled
test installer.sh / test (curl, fedora:latest, 1.80.0) (push) Has been cancelled
test installer.sh / test (curl, kalilinux/kali-dev) (push) Has been cancelled
test installer.sh / test (curl, kalilinux/kali-rolling) (push) Has been cancelled
test installer.sh / test (curl, opensuse/leap:latest) (push) Has been cancelled
test installer.sh / test (curl, opensuse/tumbleweed:latest) (push) Has been cancelled
test installer.sh / test (curl, oraclelinux:8) (push) Has been cancelled
test installer.sh / test (curl, oraclelinux:9) (push) Has been cancelled
test installer.sh / test (curl, parrotsec/core:latest) (push) Has been cancelled
test installer.sh / test (curl, rockylinux:8.7) (push) Has been cancelled
test installer.sh / test (curl, rockylinux:9) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:20.04) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:22.04) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:24.04, 1.80.0) (push) Has been cancelled
test installer.sh / test (wget, debian:oldstable-slim) (push) Has been cancelled
test installer.sh / test (wget, debian:sid-slim) (push) Has been cancelled
CI / gomod-cache (push) Has been cancelled
CI / fuzz (push) Has been cancelled
tailscale.com/cmd/vet / vet (push) Has been cancelled
test installer.sh / notify-slack (push) Has been cancelled
CI / race-root-integration (1/4) (push) Has been cancelled
CI / race-root-integration (2/4) (push) Has been cancelled
CI / race-root-integration (3/4) (push) Has been cancelled
CI / race-root-integration (4/4) (push) Has been cancelled
CI / test (-race, amd64, 1/3) (push) Has been cancelled
CI / test (-race, amd64, 2/3) (push) Has been cancelled
CI / test (-race, amd64, 3/3) (push) Has been cancelled
CI / test (386) (push) Has been cancelled
CI / test (amd64) (push) Has been cancelled
CI / Windows (${{ matrix.name || matrix.shard}}) (win-bench, benchmarks) (push) Has been cancelled
CI / Windows (${{ matrix.name || matrix.shard}}) (win-shard-1-2, 1/2) (push) Has been cancelled
CI / Windows (${{ matrix.name || matrix.shard}}) (win-shard-2-2, 2/2) (push) Has been cancelled
CI / Windows (win-tool-go) (push) Has been cancelled
CI / macos (push) Has been cancelled
CI / privileged (push) Has been cancelled
CI / vm (push) Has been cancelled
CI / cross (386, linux) (push) Has been cancelled
CI / cross (amd64, darwin) (push) Has been cancelled
CI / cross (amd64, freebsd) (push) Has been cancelled
CI / cross (amd64, openbsd) (push) Has been cancelled
CI / cross (amd64, windows) (push) Has been cancelled
CI / cross (arm, 5, linux) (push) Has been cancelled
CI / cross (arm, 7, linux) (push) Has been cancelled
CI / cross (arm64, darwin) (push) Has been cancelled
CI / cross (arm64, linux) (push) Has been cancelled
CI / cross (arm64, windows) (push) Has been cancelled
CI / cross (loong64, linux) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / crossmin (amd64, illumos) (push) Has been cancelled
CI / crossmin (amd64, plan9) (push) Has been cancelled
CI / crossmin (amd64, solaris) (push) Has been cancelled
CI / crossmin (ppc64, aix) (push) Has been cancelled
CI / android (push) Has been cancelled
CI / wasm (push) Has been cancelled
CI / tailscale_go (push) Has been cancelled
CI / depaware (push) Has been cancelled
CI / go_generate (push) Has been cancelled
CI / make_tidy (push) Has been cancelled
CI / licenses (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--with-tags-all=darwin, arm64, darwin, macOS) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--with-tags-all=linux, amd64, linux, Linux) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--with-tags-all=windows, amd64, windows, Windows) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=1/4, amd64, linux, Portable (1/4)) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=2/4, amd64, linux, Portable (2/4)) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=3/4, amd64, linux, Portable (3/4)) (push) Has been cancelled
CI / staticcheck (${{ matrix.name }}) (--without-tags-any=windows,darwin,linux --shard=4/4, amd64, linux, Portable (4/4)) (push) Has been cancelled
CI / notify_slack (push) Has been cancelled
CI / merge_blocker (push) Has been cancelled
CI / check_mergeability_strict (push) Has been cancelled
CI / check_mergeability (push) Has been cancelled
Updates tailscale/corp#27805 Updates tailscale/corp#27806 Updates tailscale/corp#37964 Change-Id: I7bb5ed7f258e840a8208e5d725c7b2f126d7ef96 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
parent
120f27f383
commit
d42b3743b7
176
net/porttrack/porttrack.go
Normal file
176
net/porttrack/porttrack.go
Normal file
@ -0,0 +1,176 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package porttrack provides race-free ephemeral port assignment for
|
||||
// subprocess tests. The parent test process creates a [Collector] that
|
||||
// listens on a TCP port; the child process uses [Listen] which, when
|
||||
// given a magic address, binds to localhost:0 and reports the actual
|
||||
// port back to the collector.
|
||||
//
|
||||
// The magic address format is:
|
||||
//
|
||||
// testport-report:HOST:PORT/LABEL
|
||||
//
|
||||
// where HOST:PORT is the collector's TCP address and LABEL identifies
|
||||
// which listener this is (e.g. "main", "plaintext").
|
||||
//
|
||||
// When [Listen] is called with a non-magic address, it falls through to
|
||||
// [net.Listen] with zero overhead beyond a single [strings.HasPrefix]
|
||||
// check.
|
||||
package porttrack
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"tailscale.com/util/testenv"
|
||||
)
|
||||
|
||||
const magicPrefix = "testport-report:"
|
||||
|
||||
// Collector is the parent/test side of the porttrack protocol. It
|
||||
// listens for port reports from child processes that used [Listen]
|
||||
// with a magic address obtained from [Collector.Addr].
|
||||
type Collector struct {
|
||||
ln net.Listener
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
ports map[string]int
|
||||
err error // non-nil if a context passed to Port was cancelled
|
||||
}
|
||||
|
||||
// NewCollector creates a new Collector. The collector's TCP listener is
|
||||
// closed when t finishes.
|
||||
func NewCollector(t testenv.TB) *Collector {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("porttrack.NewCollector: %v", err)
|
||||
}
|
||||
c := &Collector{
|
||||
ln: ln,
|
||||
ports: make(map[string]int),
|
||||
}
|
||||
c.cond = sync.NewCond(&c.mu)
|
||||
go c.accept(t)
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
return c
|
||||
}
|
||||
|
||||
// accept runs in a goroutine, accepting connections and parsing port
|
||||
// reports until the listener is closed.
|
||||
func (c *Collector) accept(t testenv.TB) {
|
||||
for {
|
||||
conn, err := c.ln.Accept()
|
||||
if err != nil {
|
||||
return // listener closed
|
||||
}
|
||||
go c.handleConn(t, conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) handleConn(t testenv.TB, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
label, portStr, ok := strings.Cut(line, "\t")
|
||||
if !ok {
|
||||
t.Errorf("porttrack: malformed report line: %q", line)
|
||||
return
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Errorf("porttrack: bad port in report %q: %v", line, err)
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.ports[label] = port
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Addr returns a magic address string that, when passed to [Listen],
|
||||
// causes the child to bind to localhost:0 and report its actual port
|
||||
// back to this collector under the given label.
|
||||
func (c *Collector) Addr(label string) string {
|
||||
return magicPrefix + c.ln.Addr().String() + "/" + label
|
||||
}
|
||||
|
||||
// Port blocks until the child process has reported the port for the
|
||||
// given label, then returns it. If ctx is cancelled before a port is
|
||||
// reported, Port returns the context's cause as an error.
|
||||
func (c *Collector) Port(ctx context.Context, label string) (int, error) {
|
||||
stop := context.AfterFunc(ctx, func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.err == nil {
|
||||
c.err = context.Cause(ctx)
|
||||
}
|
||||
c.cond.Broadcast()
|
||||
})
|
||||
defer stop()
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for {
|
||||
if p, ok := c.ports[label]; ok {
|
||||
return p, nil
|
||||
}
|
||||
if c.err != nil {
|
||||
return 0, c.err
|
||||
}
|
||||
c.cond.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// Listen is the child/production side of the porttrack protocol.
|
||||
//
|
||||
// If address has the magic prefix (as returned by [Collector.Addr]),
|
||||
// Listen binds to localhost:0 on the given network, then TCP-connects
|
||||
// to the collector and writes "LABEL\tPORT\n" to report the actual
|
||||
// port. The collector connection is closed before returning.
|
||||
//
|
||||
// If address does not have the magic prefix, Listen is simply
|
||||
// [net.Listen](network, address).
|
||||
func Listen(network, address string) (net.Listener, error) {
|
||||
rest, ok := strings.CutPrefix(address, magicPrefix)
|
||||
if !ok {
|
||||
return net.Listen(network, address)
|
||||
}
|
||||
|
||||
// rest is "HOST:PORT/LABEL"
|
||||
slashIdx := strings.LastIndex(rest, "/")
|
||||
if slashIdx < 0 {
|
||||
return nil, fmt.Errorf("porttrack: malformed magic address %q: missing /LABEL", address)
|
||||
}
|
||||
collectorAddr := rest[:slashIdx]
|
||||
label := rest[slashIdx+1:]
|
||||
|
||||
ln, err := net.Listen(network, "localhost:0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
conn, err := net.Dial("tcp", collectorAddr)
|
||||
if err != nil {
|
||||
ln.Close()
|
||||
return nil, fmt.Errorf("porttrack: failed to connect to collector at %s: %v", collectorAddr, err)
|
||||
}
|
||||
_, err = fmt.Fprintf(conn, "%s\t%d\n", label, port)
|
||||
conn.Close()
|
||||
if err != nil {
|
||||
ln.Close()
|
||||
return nil, fmt.Errorf("porttrack: failed to report port to collector: %v", err)
|
||||
}
|
||||
|
||||
return ln, nil
|
||||
}
|
||||
95
net/porttrack/porttrack_test.go
Normal file
95
net/porttrack/porttrack_test.go
Normal file
@ -0,0 +1,95 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package porttrack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCollectorAndListen(t *testing.T) {
|
||||
c := NewCollector(t)
|
||||
|
||||
labels := []string{"main", "plaintext", "debug"}
|
||||
ports := make([]int, len(labels))
|
||||
|
||||
for i, label := range labels {
|
||||
ln, err := Listen("tcp", c.Addr(label))
|
||||
if err != nil {
|
||||
t.Fatalf("Listen(%q): %v", label, err)
|
||||
}
|
||||
defer ln.Close()
|
||||
p, err := c.Port(t.Context(), label)
|
||||
if err != nil {
|
||||
t.Fatalf("Port(%q): %v", label, err)
|
||||
}
|
||||
ports[i] = p
|
||||
}
|
||||
|
||||
// All ports should be distinct non-zero values.
|
||||
seen := map[int]string{}
|
||||
for i, label := range labels {
|
||||
if ports[i] == 0 {
|
||||
t.Errorf("Port(%q) = 0", label)
|
||||
}
|
||||
if prev, ok := seen[ports[i]]; ok {
|
||||
t.Errorf("Port(%q) = Port(%q) = %d", label, prev, ports[i])
|
||||
}
|
||||
seen[ports[i]] = label
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenPassthrough(t *testing.T) {
|
||||
ln, err := Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen passthrough: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
if ln.Addr().(*net.TCPAddr).Port == 0 {
|
||||
t.Fatal("expected non-zero port")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
c := NewCollector(t)
|
||||
|
||||
ln, err := Listen("tcp", c.Addr("http"))
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
// Start a server on the listener.
|
||||
go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
port, err := c.Port(t.Context(), "http")
|
||||
if err != nil {
|
||||
t.Fatalf("Port: %v", err)
|
||||
}
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/", port))
|
||||
if err != nil {
|
||||
t.Fatalf("http.Get: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortContextCancelled(t *testing.T) {
|
||||
c := NewCollector(t)
|
||||
// Nobody will ever report "never", so Port should block until ctx is done.
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
_, err := c.Port(ctx, "never")
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Port with cancelled context: got %v, want %v", err, context.Canceled)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user