diff --git a/pkg/shim/shim_windows.go b/pkg/shim/shim_windows.go index c319666c1ca54..32a860ef7b651 100644 --- a/pkg/shim/shim_windows.go +++ b/pkg/shim/shim_windows.go @@ -23,43 +23,134 @@ import ( "io" "net" "os" + "os/signal" + "strings" + "sync" + "syscall" "time" winio "github.com/Microsoft/go-winio" - "github.com/containerd/errdefs" + "github.com/containerd/containerd/v2/pkg/namespaces" "github.com/containerd/log" "github.com/containerd/ttrpc" "golang.org/x/sys/windows" ) -func setupSignals(config Config) (chan os.Signal, error) { - return nil, errdefs.ErrNotImplemented +// setupSignals creates the shim's signal channel for Windows and registers +// interrupt/terminate on it. Short-lived actions (e.g. "delete") run only +// reap(), which drains these so a stray Ctrl+C / termination cannot kill the +// process mid-action via the default OS behavior; serve() additionally handles +// graceful shutdown through handleExitSignals. Windows has no SIGCHLD (the OS +// reaps children), so no reaping signal is registered. +func setupSignals(_ Config) (chan os.Signal, error) { + signals := make(chan os.Signal, 32) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + return signals, nil } +// newServer creates a new ttrpc server for Windows. +// Unlike Unix, Windows doesn't have user-based socket authentication, +// so we create a basic ttrpc server without the handshaker. func newServer(opts ...ttrpc.ServerOpt) (*ttrpc.Server, error) { - return nil, errdefs.ErrNotImplemented + return ttrpc.NewServer(opts...) } +// subreaper is not applicable on Windows as the OS automatically +// handles orphaned processes differently than Unix systems. func subreaper() error { - return errdefs.ErrNotImplemented + // This is a no-op on Windows - the OS handles orphaned processes + return nil } -func setupDumpStacks(dump chan<- os.Signal) { +// setupDumpStacks is currently not implemented for Windows. +// Windows doesn't have SIGUSR1, so stack dumping would need to use +// a different mechanism (e.g., a named event or debug console). +func setupDumpStacks(_ chan<- os.Signal) { + // No-op on Windows - SIGUSR1 doesn't exist + // Future: could implement using Windows events or console signals } -func serveListener(path string, fd uintptr) (net.Listener, error) { - return nil, errdefs.ErrNotImplemented +// serveListener creates a named pipe listener for Windows at the given path. +// Windows requires an explicit named-pipe path; unlike Unix there is no +// inherited-descriptor fallback, so an empty path is an error. +func serveListener(path string, _ uintptr) (net.Listener, error) { + if path == "" { + return nil, fmt.Errorf("named pipe path is required on Windows") + } + + // Require the canonical Windows named pipe prefix: \\.\pipe\. + if !strings.HasPrefix(path, `\\.\pipe\`) { + return nil, fmt.Errorf("address %q is not a named pipe path (must start with %q)", path, `\\.\pipe\`) + } + + l, err := winio.ListenPipe(path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create named pipe listener at %s: %w", path, err) + } + + log.L.WithField("pipe", path).Debug("serving api on named pipe") + return l, nil } +// reap handles signals on Windows. Unlike Unix, Windows doesn't send SIGCHLD +// when child processes exit, so we only need to handle shutdown signals. func reap(ctx context.Context, logger *log.Entry, signals chan os.Signal) error { - return errdefs.ErrNotImplemented + logger.Debug("starting signal loop") + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case s := <-signals: + logger.WithField("signal", s).Debug("received signal in reap loop") + // On Windows, we just log the signal + // Exit signals are handled in handleExitSignals + } + } } +// handleExitSignals listens for shutdown signals (SIGINT, SIGTERM) and +// triggers the provided cancel function for graceful shutdown. func handleExitSignals(ctx context.Context, logger *log.Entry, cancel context.CancelFunc) { + ch := make(chan os.Signal, 32) + // On Windows, os.Kill cannot be caught. We handle os.Interrupt (Ctrl+C) and SIGTERM. + signal.Notify(ch, os.Interrupt, syscall.SIGTERM) + + for { + select { + case s := <-ch: + logger.WithField("signal", s).Debug("caught exit signal") + cancel() + return + case <-ctx.Done(): + return + } + } } -func openLog(ctx context.Context, _ string) (io.Writer, error) { - return nil, errdefs.ErrNotImplemented +// openLog creates a named pipe for shim logging on Windows. +// The containerd daemon connects to this pipe as a client to read log output. +// The pipe format is: \\.\pipe\containerd-shim-{namespace}-{id}-log +func openLog(ctx context.Context, id string) (io.Writer, error) { + ns, err := namespaces.NamespaceRequired(ctx) + if err != nil { + return nil, err + } + pipePath := fmt.Sprintf("\\\\.\\pipe\\containerd-shim-%s-%s-log", ns, id) + l, err := winio.ListenPipe(pipePath, nil) + if err != nil { + return nil, fmt.Errorf("failed to create shim log pipe: %w", err) + } + + rlw := &reconnectingLogWriter{ + l: l, + } + + // Accept connections from containerd in the background. + // Supports reconnection if containerd restarts. + go rlw.acceptConnections() + + return rlw, nil } // awaitPipeReady polls a named pipe address until it is connectable, @@ -106,3 +197,93 @@ func awaitPipeReady(address string) error { } } } + +// reconnectingLogWriter adapts containerd's log channel to the Windows +// named-pipe model. +// +// On Unix the shim log is a FIFO: a passive kernel object the shim writes to +// while containerd reads the far end, and containerd can detach and reattach +// (for example across a restart) with no involvement from the shim. Windows +// named pipes have no such passive form — they are connection-oriented, so the +// shim is a server that must Accept a reader, serves one reader at a time, and +// must Accept a fresh connection each time that reader reconnects. +// +// This type provides exactly what the pipe model forces the shim to handle +// itself, and which the FIFO gave for free: +// - a background Accept loop so a reader can connect at any time, +// - swapping to the newest connection (closing the old) on reconnect, +// - dropping writes while no reader is attached so logging never blocks the shim. +// +// Logs written while no reader is connected — including the brief window during +// a reconnect — are intentionally discarded rather than buffered. +type reconnectingLogWriter struct { + l net.Listener // named pipe listener that accepts reader connections + mu sync.Mutex // guards conn + conn net.Conn // current reader connection, or nil when none is attached +} + +// acceptConnections listens for log connections in the background. +func (rlw *reconnectingLogWriter) acceptConnections() { + for { + newConn, err := rlw.l.Accept() + if err != nil { + // Listener was closed, stop accepting + return + } + + rlw.mu.Lock() + // Close the old connection if one exists + if rlw.conn != nil { + rlw.conn.Close() + } + rlw.conn = newConn + rlw.mu.Unlock() + } +} + +// Write implements io.Writer. It writes to the current connection if one exists. +// If no connection is established yet, writes are silently dropped to avoid +// blocking the shim. +func (rlw *reconnectingLogWriter) Write(p []byte) (n int, err error) { + rlw.mu.Lock() + conn := rlw.conn + rlw.mu.Unlock() + + if conn == nil { + // No connection yet, drop the log. + return len(p), nil + } + + n, err = conn.Write(p) + if err != nil || n < len(p) { + // A write error or short write means the reader is gone or wedged. + // Drop the connection so the next write starts fresh, and report full + // success so logging never backpressures the shim. + rlw.mu.Lock() + if rlw.conn == conn { + rlw.conn.Close() + rlw.conn = nil + } + rlw.mu.Unlock() + return len(p), nil + } + return len(p), nil +} + +// Close implements io.Closer. It closes both the listener and any active connection. +func (rlw *reconnectingLogWriter) Close() error { + rlw.mu.Lock() + defer rlw.mu.Unlock() + + var err error + if rlw.l != nil { + err = rlw.l.Close() + } + if rlw.conn != nil { + if cerr := rlw.conn.Close(); cerr != nil && err == nil { + err = cerr + } + rlw.conn = nil + } + return err +} diff --git a/pkg/shim/shim_windows_test.go b/pkg/shim/shim_windows_test.go index 588dcc8ba03fa..4572fedd41321 100644 --- a/pkg/shim/shim_windows_test.go +++ b/pkg/shim/shim_windows_test.go @@ -1,3 +1,5 @@ +//go:build windows + /* Copyright The containerd Authors. @@ -17,13 +19,468 @@ package shim import ( + "context" + "errors" + "fmt" + "net" + "os" "strings" + "sync" + "sync/atomic" + "syscall" "testing" "time" winio "github.com/Microsoft/go-winio" + "github.com/containerd/containerd/v2/pkg/namespaces" + "github.com/containerd/log" +) + +const ( + // connectionWaitTime is the time to wait for pipe connections to be established + connectionWaitTime = 50 * time.Millisecond + // readTimeout is the timeout for reading from pipe connections + readTimeout = time.Second ) +// testPipeCounter ensures unique pipe names across parallel tests +var testPipeCounter atomic.Uint64 + +// uniquePipePath generates a unique pipe path for testing +func uniquePipePath(prefix string) string { + return fmt.Sprintf(`\\.\pipe\%s-%d-%d`, prefix, os.Getpid(), testPipeCounter.Add(1)) +} + +// createTestPipe creates a named pipe listener for testing. The caller is +// responsible for closing the returned listener. +func createTestPipe(t *testing.T, pipePath string) net.Listener { + t.Helper() + l, err := winio.ListenPipe(pipePath, nil) + if err != nil { + t.Fatalf("failed to create test pipe: %v", err) + } + return l +} + +// connectToPipe dials the named pipe for testing. The caller is responsible +// for closing the returned connection. +func connectToPipe(t *testing.T, pipePath string) net.Conn { + t.Helper() + conn, err := winio.DialPipe(pipePath, nil) + if err != nil { + t.Fatalf("failed to connect to pipe: %v", err) + } + return conn +} + +// readResult holds the result of an async read operation +type readResult struct { + buf []byte + err error +} + +// asyncRead reads data from connection with timeout in a goroutine. +// Returns a channel that will receive the result when the read completes. +func asyncRead(conn net.Conn, expectedLen int) <-chan readResult { + resultChan := make(chan readResult, 1) + go func() { + buf := make([]byte, expectedLen) + _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) + nRead, err := conn.Read(buf) + resultChan <- readResult{buf: buf[:nRead], err: err} + }() + return resultChan +} + +func TestSetupSignals(t *testing.T) { + tests := []struct { + name string + config Config + expectError bool + expectNilChan bool + expectedCapacity int + }{ + { + name: "default config creates signal channel with capacity 32", + config: Config{}, + expectError: false, + expectNilChan: false, + expectedCapacity: 32, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + signals, err := setupSignals(tt.config) + if (err != nil) != tt.expectError { + t.Fatalf("setupSignals() error = %v, expectError %v", err, tt.expectError) + } + if (signals == nil) != tt.expectNilChan { + t.Fatal("setupSignals returned unexpected nil channel state") + } + if signals != nil && cap(signals) != tt.expectedCapacity { + t.Fatalf("expected signal channel capacity %d, got %d", tt.expectedCapacity, cap(signals)) + } + }) + } +} + +func TestServeListener(t *testing.T) { + tests := []struct { + name string + path string + expectError bool + shouldClose bool + }{ + { + name: "empty path should fail", + path: "", + expectError: true, + shouldClose: false, + }, + { + name: "non-pipe path should fail", + path: "/tmp/invalid/path", + expectError: true, + shouldClose: false, + }, + { + name: "valid pipe path should succeed", + path: fmt.Sprintf(`\\.\pipe\containerd-shim-test-%d`, os.Getpid()), + expectError: false, + shouldClose: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l, err := serveListener(tt.path, 0) + if (err != nil) != tt.expectError { + t.Fatalf("serveListener() error = %v, expectError %v", err, tt.expectError) + } + if tt.shouldClose && l != nil { + defer l.Close() + } + if !tt.expectError && l == nil { + t.Fatal("serveListener returned nil listener") + } + }) + } +} + +func TestReconnectingLogWriterDropsLogsBeforeConnection(t *testing.T) { + t.Parallel() + pipePath := uniquePipePath("shim-test-log") + l := createTestPipe(t, pipePath) + + rlw := &reconnectingLogWriter{l: l} + go rlw.acceptConnections() + defer rlw.Close() + + // Write before any client connects - should not block and return success + testData := []byte("test log message before connection") + n, err := rlw.Write(testData) + if err != nil { + t.Fatalf("Write should not return error before connection: %v", err) + } + if n != len(testData) { + t.Fatalf("Write should return len(data) even when dropping: got %d, want %d", n, len(testData)) + } +} + +func TestReconnectingLogWriterWritesAfterConnection(t *testing.T) { + t.Parallel() + pipePath := uniquePipePath("shim-test-log-write") + l := createTestPipe(t, pipePath) + + rlw := &reconnectingLogWriter{l: l} + go rlw.acceptConnections() + defer rlw.Close() + + // Connect a client + clientConn := connectToPipe(t, pipePath) + defer clientConn.Close() + + // Give time for connection to be accepted + time.Sleep(connectionWaitTime) + + // Write after client connects + testData := []byte("test log message after connection") + + // Start reading from client side before writing to prevent blocking + readChan := asyncRead(clientConn, len(testData)) + + n, err := rlw.Write(testData) + if err != nil { + t.Fatalf("Write failed after connection: %v", err) + } + if n != len(testData) { + t.Fatalf("Write returned wrong length: got %d, want %d", n, len(testData)) + } + + // Wait for read to complete and verify + result := <-readChan + if result.err != nil { + t.Fatalf("client failed to read: %v", result.err) + } + if string(result.buf) != string(testData) { + t.Fatalf("client read wrong data: got %q, want %q", string(result.buf), string(testData)) + } +} + +func TestReconnectingLogWriterSupportsReconnection(t *testing.T) { + t.Parallel() + pipePath := uniquePipePath("shim-test-log-reconnect") + l := createTestPipe(t, pipePath) + + rlw := &reconnectingLogWriter{l: l} + go rlw.acceptConnections() + defer rlw.Close() + + // First client connects + client1 := connectToPipe(t, pipePath) + + // Give time for connection to be accepted + time.Sleep(connectionWaitTime) + + // Write with first client + testData1 := []byte("message to first client") + + // Start reading from first client before writing + readChan1 := asyncRead(client1, len(testData1)) + + _, err := rlw.Write(testData1) + if err != nil { + t.Fatalf("Write to first client failed: %v", err) + } + + // Wait for read to complete and verify + result1 := <-readChan1 + if result1.err != nil { + t.Fatalf("first client failed to read: %v", result1.err) + } + if string(result1.buf) != string(testData1) { + t.Fatalf("first client read wrong data: got %q, want %q", string(result1.buf), string(testData1)) + } + + // Second client connects (simulating containerd restart) + client2 := connectToPipe(t, pipePath) + defer client2.Close() + + // Give time for new connection to be accepted and old one closed + time.Sleep(connectionWaitTime) + + // Close first client (it should already be closed by the writer) + client1.Close() + + // Write with second client connected + testData2 := []byte("message to second client") + + // Start reading from second client before writing + readChan2 := asyncRead(client2, len(testData2)) + + _, err = rlw.Write(testData2) + if err != nil { + t.Fatalf("Write to second client failed: %v", err) + } + + // Wait for read to complete and verify + result2 := <-readChan2 + if result2.err != nil { + t.Fatalf("second client failed to read: %v", result2.err) + } + if string(result2.buf) != string(testData2) { + t.Fatalf("second client read wrong data: got %q, want %q", string(result2.buf), string(testData2)) + } +} + +func TestReconnectingLogWriterClose(t *testing.T) { + t.Parallel() + pipePath := uniquePipePath("shim-test-log-close") + l := createTestPipe(t, pipePath) + + rlw := &reconnectingLogWriter{l: l} + go rlw.acceptConnections() + + // Connect a client + client := connectToPipe(t, pipePath) + defer client.Close() + + // Give time for connection to be accepted + time.Sleep(connectionWaitTime) + + // Close the writer + err := rlw.Close() + if err != nil { + t.Fatalf("Close failed: %v", err) + } + + // Verify listener is closed by trying to connect again + _, err = winio.DialPipe(pipePath, nil) + if err == nil { + t.Fatal("should not be able to connect after Close") + } +} + +func TestReconnectingLogWriterConcurrentWrites(t *testing.T) { + t.Parallel() + pipePath := uniquePipePath("shim-test-log-concurrent") + l := createTestPipe(t, pipePath) + + rlw := &reconnectingLogWriter{l: l} + go rlw.acceptConnections() + defer rlw.Close() + + // Connect a client + client := connectToPipe(t, pipePath) + defer client.Close() + + // Give time for connection to be accepted + time.Sleep(connectionWaitTime) + + // Start reading in background + readDone := make(chan struct{}) + go func() { + defer close(readDone) + buf := make([]byte, 4096) + for { + _ = client.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + _, err := client.Read(buf) + if err != nil { + return + } + } + }() + + // Concurrent writes - collect errors instead of using t.Errorf in goroutine + const numWriters = 10 + errChan := make(chan error, numWriters) + var wg sync.WaitGroup + for i := 0; i < numWriters; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + msg := fmt.Sprintf("concurrent message %d\n", id) + _, err := rlw.Write([]byte(msg)) + if err != nil { + errChan <- fmt.Errorf("concurrent Write %d failed: %w", id, err) + } + }(i) + } + wg.Wait() + close(errChan) + + // Report any errors from concurrent writes + for err := range errChan { + t.Error(err) + } + + // Close and wait for reader to finish + rlw.Close() + <-readDone +} + +func TestOpenLog(t *testing.T) { + tests := []struct { + name string + setupCtx func() context.Context + containerID string + expectError bool + shouldConnect bool + pipePath string + }{ + { + name: "creates named pipe and accepts connections", + setupCtx: func() context.Context { + return namespaces.WithNamespace(context.Background(), "test-ns") + }, + containerID: "test-container-id", + expectError: false, + shouldConnect: true, + pipePath: `\\.\pipe\containerd-shim-test-ns-test-container-id-log`, + }, + { + name: "fails without namespace in context", + setupCtx: func() context.Context { + return context.Background() + }, + containerID: "test-container-id", + expectError: true, + shouldConnect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := tt.setupCtx() + + writer, err := openLog(ctx, tt.containerID) + if (err != nil) != tt.expectError { + t.Fatalf("openLog() error = %v, expectError %v", err, tt.expectError) + } + + if tt.expectError { + return + } + + defer writer.(interface{ Close() error }).Close() + + if tt.shouldConnect { + // Verify we can connect to the created pipe + client, err := winio.DialPipe(tt.pipePath, nil) + if err != nil { + t.Fatalf("failed to connect to log pipe: %v", err) + } + defer client.Close() + + // Give time for connection to be accepted + time.Sleep(connectionWaitTime) + + // Write should succeed + testData := []byte("test log from openLog") + + // Read from client in goroutine to prevent blocking + readDone := make(chan struct{}) + var readBuf []byte + var readErr error + go func() { + defer close(readDone) + buf := make([]byte, len(testData)) + _ = client.SetReadDeadline(time.Now().Add(readTimeout)) + nRead, err := client.Read(buf) + readBuf = buf[:nRead] + readErr = err + }() + + n, err := writer.Write(testData) + if err != nil { + t.Fatalf("Write failed: %v", err) + } + if n != len(testData) { + t.Fatalf("Write returned wrong length: got %d, want %d", n, len(testData)) + } + + // Wait for read to complete and verify client receives the data + <-readDone + if readErr != nil { + t.Fatalf("client failed to read: %v", readErr) + } + if string(readBuf) != string(testData) { + t.Fatalf("client read wrong data: got %q, want %q", string(readBuf), string(testData)) + } + } + }) + } +} + +func TestSubreaper(t *testing.T) { + // On Windows, subreaper is a no-op + err := subreaper() + if err != nil { + t.Fatalf("subreaper should return nil on Windows: %v", err) + } +} + // TestAwaitPipeReady_State1_RetriesOnErrTimeout proves that awaitPipeReady // retries when winio.DialPipe returns winio.ErrTimeout (pipe in state 1: // ListenPipe called, Accept not yet called). @@ -69,3 +526,155 @@ func TestAwaitPipeReady_State1_RetriesOnErrTimeout(t *testing.T) { t.Errorf("awaitPipeReady returned in %v; want ≥1s to confirm retry path was taken", elapsed) } } + +func TestAwaitPipeReadyEmptyAddress(t *testing.T) { + // An empty address means "no pipe to wait for" and must return immediately. + if err := awaitPipeReady(""); err != nil { + t.Fatalf("awaitPipeReady(\"\") = %v; want nil", err) + } +} + +func TestAwaitPipeReadyConnectsWhenServing(t *testing.T) { + t.Parallel() + pipePath := uniquePipePath("shim-test-await-serving") + l := createTestPipe(t, pipePath) + defer l.Close() + + // A reader is actively accepting, so the dial should connect on the first try. + go func() { + conn, err := l.Accept() + if err == nil { + conn.Close() + } + }() + // Ensure Accept is pending before we dial. + time.Sleep(connectionWaitTime) + + if err := awaitPipeReady(pipePath); err != nil { + t.Fatalf("awaitPipeReady(%q) = %v; want nil", pipePath, err) + } +} + +func TestNewServer(t *testing.T) { + srv, err := newServer() + if err != nil { + t.Fatalf("newServer() error = %v", err) + } + if srv == nil { + t.Fatal("newServer() returned a nil server") + } +} + +func TestSetupDumpStacks(t *testing.T) { + // setupDumpStacks is a no-op on Windows; it must return without panicking + // or blocking on the supplied channel. + setupDumpStacks(make(chan os.Signal, 1)) +} + +func TestReapReturnsOnContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + errCh <- reap(ctx, log.L, make(chan os.Signal, 1)) + }() + + cancel() + + select { + case err := <-errCh: + if !errors.Is(err, context.Canceled) { + t.Fatalf("reap() = %v; want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("reap did not return after context cancellation") + } +} + +func TestReapContinuesAfterSignal(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + signals := make(chan os.Signal, 1) + errCh := make(chan error, 1) + go func() { + errCh <- reap(ctx, log.L, signals) + }() + + // Delivering a signal must not stop the loop; on Windows reap just logs it. + signals <- syscall.SIGTERM + select { + case err := <-errCh: + t.Fatalf("reap returned unexpectedly after a signal: %v", err) + case <-time.After(100 * time.Millisecond): + } + + // Only context cancellation ends the loop. + cancel() + select { + case <-errCh: + case <-time.After(time.Second): + t.Fatal("reap did not return after context cancellation") + } +} + +func TestHandleExitSignalsReturnsOnContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + handleExitSignals(ctx, log.L, func() { + t.Error("shutdown callback should not run on context cancellation") + }) + }() + + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("handleExitSignals did not return after context cancellation") + } +} + +// shortWriteConn is a net.Conn whose Write reports fewer bytes than requested +// with a nil error, to exercise reconnectingLogWriter's short-write handling. +type shortWriteConn struct { + net.Conn + closed bool +} + +func (c *shortWriteConn) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + return len(p) - 1, nil // short write with nil error +} + +func (c *shortWriteConn) Close() error { + c.closed = true + return nil +} + +func TestReconnectingLogWriterDropsConnectionOnShortWrite(t *testing.T) { + conn := &shortWriteConn{} + rlw := &reconnectingLogWriter{conn: conn} + + data := []byte("hello world") + n, err := rlw.Write(data) + if err != nil { + t.Fatalf("Write returned error on short write: %v", err) + } + if n != len(data) { + t.Fatalf("Write returned n=%d; want %d (must report full success, never a short write)", n, len(data)) + } + + // A short write must drop the connection so the next write starts fresh. + rlw.mu.Lock() + dropped := rlw.conn == nil + rlw.mu.Unlock() + if !dropped { + t.Fatal("short write should have dropped the connection") + } + if !conn.closed { + t.Fatal("short write should have closed the dropped connection") + } +}