mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-22 11:45:52 +00:00
a0f75b1bb2
Follow-up to the 2026-07-17 live docker-update exercise where a DiskUsage roundtrip against a colima daemon parked 6+ minutes even though dockerCallWithRetry wraps every call in a 20s context.WithTimeout. Investigation result: context deadline propagation through moby client v0.5.0 (request.go, API-version negotiation) and the otelhttp transport wrapper is intact. Reproducing with a deliberately hung unix-socket daemon aborts DiskUsage at the deadline in both hang shapes (pre-header and mid-body), so there is no client-library bug to fix or file upstream; the production stall's root cause remains environmental (deadline timer never fired process-side). Containment and diagnosis: - buildReport now runs under dockerCollectCycleTimeout (5m) so a wedged cycle can never stall the module indefinitely, plus an independent watchdog timer that logs an error with a full goroutine dump if the cycle outlives even that deadline - capturing exactly the evidence that was missing from the original incident. - hung_daemon_deadline_test.go pins that a context deadline aborts the real moby client against a hung unix-socket daemon (pre-header and mid-body stalls), guarding future moby/otelhttp upgrades. The incident note referenced a dockerCollectCycleTimeout watchdog as already added; it did not exist on any branch - this commit is that containment, landed for real. Contract-Neutral: dockeragent collect-cycle watchdog containment: timeout plumbing only, no collection-semantics or contract-surface delta
182 lines
5.0 KiB
Go
182 lines
5.0 KiB
Go
package dockeragent
|
|
|
|
// Regression pins for the 2026-07-17 live-exercise stall: collectStorageUsage
|
|
// sat 6+ minutes inside moby DiskUsage even though dockerCallWithRetry wraps
|
|
// every call in context.WithTimeout. Investigation could not reproduce a
|
|
// deadline being swallowed anywhere in the moby client stack; these tests pin
|
|
// that a context deadline aborts the real moby client (request.go + otelhttp
|
|
// transport + API-version negotiation) against a hung unix-socket daemon, in
|
|
// both hang shapes, so a future moby/otelhttp upgrade cannot silently regress
|
|
// it:
|
|
//
|
|
// - hang before response headers (daemon computing /system/df forever)
|
|
// - hang mid-body (daemon streams partial JSON then stalls)
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/moby/moby/client"
|
|
)
|
|
|
|
// hungSocketServer listens on a unix socket, accepts connections, discards
|
|
// whatever the client writes, and never responds.
|
|
func startHungSocketServer(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir, err := os.MkdirTemp("", "pulsedu")
|
|
if err != nil {
|
|
t.Fatalf("mkdtemp: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
|
|
sockPath := filepath.Join(dir, "docker.sock")
|
|
ln, err := net.Listen("unix", sockPath)
|
|
if err != nil {
|
|
t.Fatalf("listen unix: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func(c net.Conn) {
|
|
defer c.Close()
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
if _, err := c.Read(buf); err != nil {
|
|
return
|
|
}
|
|
// Read requests forever, never write a byte back.
|
|
}
|
|
}(conn)
|
|
}
|
|
}()
|
|
|
|
return sockPath
|
|
}
|
|
|
|
// startStallingHTTPServer answers /_ping so API-version negotiation succeeds,
|
|
// then for /system/df writes response headers plus a partial JSON body and
|
|
// stalls forever.
|
|
func startStallingHTTPServer(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir, err := os.MkdirTemp("", "pulsedu")
|
|
if err != nil {
|
|
t.Fatalf("mkdtemp: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
|
|
sockPath := filepath.Join(dir, "docker.sock")
|
|
ln, err := net.Listen("unix", sockPath)
|
|
if err != nil {
|
|
t.Fatalf("listen unix: %v", err)
|
|
}
|
|
|
|
hang := make(chan struct{})
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/_ping", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Api-Version", "1.51")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
// Any versioned endpoint (e.g. /v1.51/system/df): stream a partial
|
|
// body, then stall.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprint(w, `{"LayersSize": 1, "Images": [`)
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
<-hang
|
|
})
|
|
|
|
srv := &http.Server{Handler: mux}
|
|
go func() { _ = srv.Serve(ln) }()
|
|
t.Cleanup(func() {
|
|
close(hang)
|
|
_ = srv.Close()
|
|
})
|
|
|
|
return sockPath
|
|
}
|
|
|
|
func newTestMobyClient(t *testing.T, sockPath string) dockerClient {
|
|
t.Helper()
|
|
|
|
cli, err := newMobyDockerClient(
|
|
client.WithHost("unix://"+sockPath),
|
|
client.WithAPIVersionNegotiation(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("new moby client: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = cli.Close() })
|
|
return cli
|
|
}
|
|
|
|
// diskUsageViaProductionPath mirrors collectStorageUsage's exact wrapper and
|
|
// options.
|
|
func diskUsageViaProductionPath(ctx context.Context, cli dockerClient, timeout time.Duration) error {
|
|
_, err := dockerCallWithRetryAttempts(ctx, timeout, 1, func(callCtx context.Context) (client.DiskUsageResult, error) {
|
|
return cli.DiskUsage(callCtx, dockerDiskUsageOptions{
|
|
Containers: true,
|
|
Images: true,
|
|
BuildCache: true,
|
|
Volumes: true,
|
|
Verbose: true,
|
|
})
|
|
})
|
|
return err
|
|
}
|
|
|
|
func TestDiskUsageDeadlineAbortsHungSocketBeforeHeaders(t *testing.T) {
|
|
sockPath := startHungSocketServer(t)
|
|
cli := newTestMobyClient(t, sockPath)
|
|
|
|
const timeout = 2 * time.Second
|
|
start := time.Now()
|
|
err := diskUsageViaProductionPath(context.Background(), cli, timeout)
|
|
elapsed := time.Since(start)
|
|
|
|
if err == nil {
|
|
t.Fatalf("expected error from hung daemon socket, got nil after %v", elapsed)
|
|
}
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("expected context.DeadlineExceeded, got %v (after %v)", err, elapsed)
|
|
}
|
|
if elapsed > timeout+3*time.Second {
|
|
t.Fatalf("deadline did not abort the call promptly: elapsed %v for %v timeout", elapsed, timeout)
|
|
}
|
|
t.Logf("pre-header hang aborted after %v with: %v", elapsed, err)
|
|
}
|
|
|
|
func TestDiskUsageDeadlineAbortsHungSocketMidBody(t *testing.T) {
|
|
sockPath := startStallingHTTPServer(t)
|
|
cli := newTestMobyClient(t, sockPath)
|
|
|
|
const timeout = 2 * time.Second
|
|
start := time.Now()
|
|
err := diskUsageViaProductionPath(context.Background(), cli, timeout)
|
|
elapsed := time.Since(start)
|
|
|
|
if err == nil {
|
|
t.Fatalf("expected error from mid-body stall, got nil after %v", elapsed)
|
|
}
|
|
if elapsed > timeout+3*time.Second {
|
|
t.Fatalf("deadline did not abort the mid-body stall promptly: elapsed %v for %v timeout (err=%v)", elapsed, timeout, err)
|
|
}
|
|
t.Logf("mid-body stall aborted after %v with: %v", elapsed, err)
|
|
}
|