mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
524469b7a3
Closes BUG-3008. Closes BUG-2949, which filed this failure on 09-07 with the
mechanism explicitly undiagnosed and stopped in the right place: it refuted the
obvious candidate, noted a repo-wide grep found no other caller, and listed
three hypotheses. This is hypothesis (1), and the caller is the standard
library rather than this repo.
httptest.Server.Close() calls CloseIdleConnections() on the PROCESS-WIDE
http.DefaultTransport — deliberately, and the standard library says so in its
own comment, calling it "not part of httptest.Server's correctness". So in a
package where many tests each stand up a server, every `defer ts.Close()`
mutates state every other test's requests depend on. CI observed a request
failing in one test while the server that closed belonged to another:
client_stream_identity_test.go:135: request not sendable:
Get "http://127.0.0.1:45521/api/v1/events/stream?armed=true":
net/http: HTTP/1.x transport connection broken: http: CloseIdleConnections called
SCOPE is every test request in internal/server, not just the parallel ones.
Three revisions were needed to get the class right, and each earlier boundary
was wrong in a way worth recording:
- Direct call sites in test bodies missed SHARED HELPERS. connectSSE,
apiRequest and readRawSSEFramesAuthed sent through the default transport
and have parallel callers; connectSSE holds its request open for a whole
test. A helper cannot know which of its callers is exposed, so it no
longer tries to.
- "Serial tests are safe" is true but is the wrong boundary. Serial-ness is
one t.Parallel() away, and CONVE-2086 tells the next author to add exactly
that line.
- A nil Transport IS http.DefaultTransport. listen_serve_test.go's health
probe was exposed identically and appears in no grep for DefaultClient.
internal/cli is deliberately different: its tests drive the PRODUCTION client
from NewClientFromURL, which has a nil Transport by design, so those sends
cannot be isolated without changing the shipped CLI. There the boundary really
is parallel-vs-serial, and the two parallel tests that build their own requests
use srv.Client(). The asymmetry is written into isolatedTestClient's doc
comment rather than left to be inferred.
WHAT IS NOT CLAIMED: Transport.CloseIdleConnections documents that it "does not
interrupt any connections currently in use", and the idle pool is mutated under
idleMu. The interleaving by which errCloseIdleConns reached a caller's Do() is
NOT reconstructed, and the comment says so instead of inventing one. That
unknown is the argument for isolation over a narrower repair: a client with its
own transport is out of reach whichever window it was.
CONVE-2086 amended with the rule, so the convention that creates the exposure
also carries it.
14 files, all _test.go. Codex: four rounds, CLEAN on the fourth; rounds 1-3
each found a real defect in the class boundary or in the comment's claims about
net/http. Gates on 8dfd6ad3: go test ./... 0, go vet 0, gofmt clean, lint 0
issues; all 8 CI checks green.
208 lines
7.3 KiB
Go
208 lines
7.3 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestNewWatchEventsStreamRequest_AnnouncesIdentity covers PLAN-2558
|
|
// S2's client half: the session identity has to reach the server as
|
|
// request headers, because the presence registry fills from the stream
|
|
// connection itself.
|
|
func TestNewWatchEventsStreamRequest_AnnouncesIdentity(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := NewClientFromURL("http://127.0.0.1:0")
|
|
req, err := c.NewWatchEventsStreamRequest(context.Background(), "", StreamSessionIdentity{
|
|
Label: "docapp",
|
|
PID: 4242,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build request: %v", err)
|
|
}
|
|
|
|
if got := req.Header.Get("X-Pad-Session-Label"); got != "docapp" {
|
|
t.Fatalf("label header = %q, want %q", got, "docapp")
|
|
}
|
|
if got := req.Header.Get("X-Pad-Session-Pid"); got != "4242" {
|
|
t.Fatalf("pid header = %q, want %q", got, "4242")
|
|
}
|
|
// The stream's pre-existing contract must survive the new parameter.
|
|
if got := req.Header.Get("Accept"); got != "text/event-stream" {
|
|
t.Fatalf("Accept header = %q, want text/event-stream", got)
|
|
}
|
|
}
|
|
|
|
// TestNewWatchEventsStreamRequest_OmitsUnsetIdentity pins absence
|
|
// rather than emptiness. Sending `X-Pad-Session-Label: ""` would make
|
|
// every unannounced client look like a client that announced nothing
|
|
// useful — indistinguishable at the server, but noise on the wire and
|
|
// an invitation for a future reader to treat "present but empty" as a
|
|
// meaningful state.
|
|
func TestNewWatchEventsStreamRequest_OmitsUnsetIdentity(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := NewClientFromURL("http://127.0.0.1:0")
|
|
req, err := c.NewWatchEventsStreamRequest(context.Background(), "", StreamSessionIdentity{})
|
|
if err != nil {
|
|
t.Fatalf("build request: %v", err)
|
|
}
|
|
|
|
if _, ok := req.Header["X-Pad-Session-Label"]; ok {
|
|
t.Fatal("expected no label header at all for a zero identity")
|
|
}
|
|
if _, ok := req.Header["X-Pad-Session-Pid"]; ok {
|
|
t.Fatal("expected no pid header at all for a zero identity")
|
|
}
|
|
}
|
|
|
|
// TestNewWatchEventsStreamRequest_UnsendableLabelStillConnects is the
|
|
// regression for the failure Codex round 1 found on this PR, and the
|
|
// reason it is asserted by DOING THE ROUND TRIP rather than by
|
|
// inspecting the header: the bug was never about the header's contents.
|
|
// Unix directory names may contain newlines ("doc\napp" is a legal
|
|
// directory), Go's http.Client refuses to SEND a header value holding
|
|
// one, and Do returns an error before anything reaches the server. In
|
|
// the monitor that looks exactly like an unreachable padd, so its retry
|
|
// loop backs off and tries again — forever, printing nothing, with the
|
|
// user simply never receiving notifications. The server cannot defend
|
|
// against a request that was never transmitted.
|
|
//
|
|
// A test that only checked the header value would pass against the
|
|
// broken version too, since http.Header.Set stores anything; only
|
|
// attempting the request distinguishes them.
|
|
func TestNewWatchEventsStreamRequest_UnsendableLabelStillConnects(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var gotLabel string
|
|
var sawRequest bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sawRequest = true
|
|
gotLabel = r.Header.Get("X-Pad-Session-Label")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClientFromURL(srv.URL)
|
|
req, err := c.NewWatchEventsStreamRequest(context.Background(), "", StreamSessionIdentity{
|
|
Label: "doc\napp",
|
|
PID: 4242,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build request: %v", err)
|
|
}
|
|
|
|
// srv.Client(), not http.DefaultClient: httptest.Server.Close() closes idle
|
|
// connections on the PROCESS-WIDE default transport (the standard library
|
|
// does it deliberately, calling it "not part of httptest.Server's
|
|
// correctness"), so with several parallel tests in this package any one of
|
|
// them finishing could break this request mid-flight. BUG-3008.
|
|
resp, err := srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request was not sendable — the monitor would retry forever and deliver nothing: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if !sawRequest {
|
|
t.Fatal("server never saw the request")
|
|
}
|
|
if gotLabel != "docapp" {
|
|
t.Fatalf("label = %q, want the control byte dropped (%q)", gotLabel, "docapp")
|
|
}
|
|
}
|
|
|
|
// TestNewWatchEventsStreamRequest_ArmedSendsQueryParam is PLAN-2613 S2's
|
|
// client half: an armed identity must reach the server as ?armed=true (a
|
|
// query param, not a header — see StreamSessionIdentity.Armed). Asserted
|
|
// by the round trip, matching the label test's stance: the value only
|
|
// matters if it actually arrives, and it is what admits the connection to
|
|
// push delivery, so a broken wiring is a silent consent failure.
|
|
func TestNewWatchEventsStreamRequest_ArmedSendsQueryParam(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var gotArmed string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotArmed = r.URL.Query().Get("armed")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClientFromURL(srv.URL)
|
|
req, err := c.NewWatchEventsStreamRequest(context.Background(), "", StreamSessionIdentity{Armed: true})
|
|
if err != nil {
|
|
t.Fatalf("build request: %v", err)
|
|
}
|
|
// srv.Client() rather than http.DefaultClient, for the reason above (BUG-3008).
|
|
resp, err := srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request not sendable: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Exactly "true" — the server treats only that value as armed, so
|
|
// anything else would be a silent no-consent.
|
|
if gotArmed != "true" {
|
|
t.Fatalf("armed query param = %q, want %q", gotArmed, "true")
|
|
}
|
|
}
|
|
|
|
// TestNewWatchEventsStreamRequest_UnarmedOmitsQueryParam pins absence, not
|
|
// armed=false. Sending armed=false would be indistinguishable at the
|
|
// server (only "true" counts) but invites a reader to treat "present but
|
|
// false" as meaningful, and it must never look like a partially-armed
|
|
// state. A zero identity carries no armed param at all.
|
|
func TestNewWatchEventsStreamRequest_UnarmedOmitsQueryParam(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := NewClientFromURL("http://127.0.0.1:0")
|
|
req, err := c.NewWatchEventsStreamRequest(context.Background(), "", StreamSessionIdentity{Label: "docapp"})
|
|
if err != nil {
|
|
t.Fatalf("build request: %v", err)
|
|
}
|
|
if _, ok := req.URL.Query()["armed"]; ok {
|
|
t.Fatalf("expected no armed query param for an unarmed identity, got %q", req.URL.RawQuery)
|
|
}
|
|
}
|
|
|
|
// TestHeaderSafeLabel covers the pieces the round trip above can't show
|
|
// individually.
|
|
func TestHeaderSafeLabel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"ordinary", "docapp", "docapp"},
|
|
{"newline dropped", "doc\napp", "docapp"},
|
|
{"carriage return dropped", "doc\rapp", "docapp"},
|
|
{"tab dropped", "doc\tapp", "docapp"},
|
|
{"spaces survive — legal in a header value", "my project", "my project"},
|
|
{"surrounding space trimmed", " docapp ", "docapp"},
|
|
{"control-only becomes empty, so no header is sent at all", "\n\t\x00", ""},
|
|
{"non-ascii printable survives", "проект", "проект"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := headerSafeLabel(tc.in); got != tc.want {
|
|
t.Fatalf("headerSafeLabel(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHeaderSafeLabel_BoundsLength(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := headerSafeLabel(strings.Repeat("é", maxHeaderLabelLen+50))
|
|
if n := len([]rune(got)); n != maxHeaderLabelLen {
|
|
t.Fatalf("got %d runes, want %d", n, maxHeaderLabelLen)
|
|
}
|
|
}
|