main pico / pkg / apps / pipe / conn_test.go
Antonio Mika  ·  2026-10-10
  1package pipe
  2
  3import (
  4	"context"
  5	"crypto/ed25519"
  6	"crypto/rand"
  7	"errors"
  8	"io"
  9	"net"
 10	"runtime"
 11	"strings"
 12	"sync"
 13	"sync/atomic"
 14	"testing"
 15	"time"
 16
 17	"github.com/picosh/utils/pipe"
 18	"golang.org/x/crypto/ssh"
 19)
 20
 21// closers closes what a test server opened once the test ends.
 22type closers struct {
 23	mu sync.Mutex
 24	cs []io.Closer
 25}
 26
 27func newClosers(t *testing.T) *closers {
 28	c := &closers{}
 29	t.Cleanup(func() {
 30		c.mu.Lock()
 31		defer c.mu.Unlock()
 32		for _, cl := range c.cs {
 33			_ = cl.Close()
 34		}
 35	})
 36	return c
 37}
 38
 39func (c *closers) add(cl io.Closer) {
 40	c.mu.Lock()
 41	defer c.mu.Unlock()
 42	c.cs = append(c.cs, cl)
 43}
 44
 45// stalledConn is a pipe connection to a server that accepts connections and
 46// never answers.
 47func stalledConn(t *testing.T, timeout time.Duration) *pipeConn {
 48	t.Helper()
 49	ln, err := net.Listen("tcp", "127.0.0.1:0")
 50	if err != nil {
 51		t.Fatal(err)
 52	}
 53	open := newClosers(t)
 54	open.add(ln)
 55	go func() {
 56		for {
 57			conn, err := ln.Accept()
 58			if err != nil {
 59				return
 60			}
 61			open.add(conn)
 62		}
 63	}()
 64	conn := newPipeConn(&pipe.SSHClientInfo{RemoteHost: ln.Addr().String()})
 65	conn.timeout = timeout
 66	return conn
 67}
 68
 69func TestStalledHandshakeEndsWithRequest(t *testing.T) {
 70	conn := stalledConn(t, time.Minute)
 71
 72	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
 73	defer cancel()
 74	start := time.Now()
 75	if _, err := conn.run(ctx, "sub topic"); !errors.Is(err, context.DeadlineExceeded) {
 76		t.Fatalf("run = %v, want the request's deadline", err)
 77	}
 78	if took := time.Since(start); took > time.Second {
 79		t.Errorf("canceled request waited %v for the handshake", took)
 80	}
 81
 82	// The next request is not held up behind the canceled one.
 83	ctx, cancel = context.WithTimeout(context.Background(), 100*time.Millisecond)
 84	defer cancel()
 85	start = time.Now()
 86	if _, err := conn.run(ctx, "sub topic"); !errors.Is(err, context.DeadlineExceeded) {
 87		t.Fatalf("run = %v, want the request's deadline", err)
 88	}
 89	if took := time.Since(start); took > time.Second {
 90		t.Errorf("second request waited %v", took)
 91	}
 92}
 93
 94func TestStalledHandshakeTimesOut(t *testing.T) {
 95	conn := stalledConn(t, 200*time.Millisecond)
 96
 97	start := time.Now()
 98	_, err := conn.run(context.Background(), "sub topic")
 99	var netErr net.Error
100	if !errors.As(err, &netErr) || !netErr.Timeout() {
101		t.Fatalf("run = %v, want a timeout", err)
102	}
103	if took := time.Since(start); took > 2*time.Second {
104		t.Errorf("handshake took %v, want about 200ms", took)
105	}
106}
107
108// stalledSSH is a pipe connection to an ssh server that completes the
109// handshake and then stops answering: at opening a session when openStalls
110// is set, or else at starting the command. It counts the connections made
111// and the ones closed.
112func stalledSSH(t *testing.T, timeout time.Duration, openStalls bool) (conn *pipeConn, conns, closed *atomic.Int32) {
113	t.Helper()
114	_, key, err := ed25519.GenerateKey(rand.Reader)
115	if err != nil {
116		t.Fatal(err)
117	}
118	hostKey, err := ssh.NewSignerFromKey(key)
119	if err != nil {
120		t.Fatal(err)
121	}
122	cfg := &ssh.ServerConfig{NoClientAuth: true}
123	cfg.AddHostKey(hostKey)
124
125	ln, err := net.Listen("tcp", "127.0.0.1:0")
126	if err != nil {
127		t.Fatal(err)
128	}
129	open := newClosers(t)
130	open.add(ln)
131	conns, closed = &atomic.Int32{}, &atomic.Int32{}
132	go func() {
133		for {
134			nc, err := ln.Accept()
135			if err != nil {
136				return
137			}
138			conns.Add(1)
139			open.add(nc)
140			go func() {
141				sc, chans, reqs, err := ssh.NewServerConn(nc, cfg)
142				if err != nil {
143					return
144				}
145				go func() {
146					_ = sc.Wait()
147					closed.Add(1)
148				}()
149				go ssh.DiscardRequests(reqs)
150				for nch := range chans {
151					if openStalls {
152						continue
153					}
154					ch, chReqs, err := nch.Accept()
155					if err != nil {
156						return
157					}
158					open.add(ch)
159					// Never reply to the exec request.
160					go func() {
161						for range chReqs {
162						}
163					}()
164				}
165			}()
166		}
167	}()
168	conn = newPipeConn(&pipe.SSHClientInfo{RemoteHost: ln.Addr().String()})
169	conn.timeout = timeout
170	return conn, conns, closed
171}
172
173func TestStalledSessionEndsWithRequest(t *testing.T) {
174	for name, openStalls := range map[string]bool{"open": true, "exec": false} {
175		t.Run(name, func(t *testing.T) {
176			conn, _, _ := stalledSSH(t, time.Minute, openStalls)
177			ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
178			defer cancel()
179			start := time.Now()
180			if _, err := conn.run(ctx, "sub topic"); !errors.Is(err, context.DeadlineExceeded) {
181				t.Fatalf("run = %v, want the request's deadline", err)
182			}
183			if took := time.Since(start); took > time.Second {
184				t.Errorf("canceled request waited %v for the session", took)
185			}
186		})
187	}
188}
189
190func TestStalledSessionRedials(t *testing.T) {
191	for name, openStalls := range map[string]bool{"open": true, "exec": false} {
192		t.Run(name, func(t *testing.T) {
193			conn, conns, _ := stalledSSH(t, 200*time.Millisecond, openStalls)
194			_, err := conn.run(context.Background(), "sub topic")
195			if err == nil || !strings.Contains(err.Error(), "timed out") {
196				t.Fatalf("run = %v, want a timeout", err)
197			}
198			if n := conns.Load(); n != 2 {
199				t.Errorf("made %d connections, want the stalled one and a redial", n)
200			}
201		})
202	}
203}
204
205func TestCanceledStartStillTimesOut(t *testing.T) {
206	for name, openStalls := range map[string]bool{"open": true, "exec": false} {
207		t.Run(name, func(t *testing.T) {
208			conn, _, closed := stalledSSH(t, 300*time.Millisecond, openStalls)
209			before := runtime.NumGoroutine()
210			for range 5 {
211				ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
212				_, err := conn.run(ctx, "sub topic")
213				cancel()
214				if !errors.Is(err, context.DeadlineExceeded) {
215					t.Fatalf("run = %v, want the request's deadline", err)
216				}
217			}
218			for range 100 {
219				if closed.Load() == 1 && runtime.NumGoroutine() <= before {
220					return
221				}
222				time.Sleep(20 * time.Millisecond)
223			}
224			t.Errorf("closed %d connections and have %d goroutines, want 1 and at most %d", closed.Load(), runtime.NumGoroutine(), before)
225		})
226	}
227}