Files
coder/cli/clilog/clilog_test.go
T
Ehab Younes eac7ee4975 fix(cli): discard log writes to closed pipes during shutdown (#26082)
Add clilog.DiscardOnPipeError, an io.Writer wrapper that drops writes
failing with io.ErrClosedPipe or syscall.EPIPE, and apply it to the
clilog stdout/stderr sinks and the port-forward verbose sink.

Background goroutines (e.g. port-forward -v tailnet goroutines) keep
logging after the reader on the log destination is gone. slog reports
those failed writes to stderr, which is noise and can interleave with
and corrupt go test/test2json output, misreporting passing tests as
failed. os.ErrClosed and all other errors are still returned, so writes
to a writer we closed ourselves are not hidden, and normal CLI pipe
semantics are unchanged.
2026-06-05 14:33:15 +03:00

286 lines
7.1 KiB
Go

package clilog_test
import (
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clilog"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func TestBuilder(t *testing.T) {
t.Parallel()
t.Run("NoConfiguration", func(t *testing.T) {
t.Parallel()
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t),
}
err := cmd.Invoke().Run()
require.ErrorContains(t, err, "no loggers provided, use /dev/null to disable logging")
})
t.Run("Verbose", func(t *testing.T) {
t.Parallel()
tempFile := filepath.Join(t.TempDir(), "test.log")
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t,
clilog.WithHuman(tempFile),
clilog.WithVerbose(),
),
}
err := cmd.Invoke().Run()
require.NoError(t, err)
assertLogs(t, tempFile, debugLog, infoLog, warnLog, filterLog)
})
t.Run("WithFilter", func(t *testing.T) {
t.Parallel()
tempFile := filepath.Join(t.TempDir(), "test.log")
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t,
clilog.WithHuman(tempFile),
// clilog.WithVerbose(), // implicit
clilog.WithFilter("important debug message"),
),
}
err := cmd.Invoke().Run()
require.NoError(t, err)
assertLogs(t, tempFile, infoLog, warnLog, filterLog)
})
t.Run("WithHuman", func(t *testing.T) {
t.Parallel()
tempFile := filepath.Join(t.TempDir(), "test.log")
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t, clilog.WithHuman(tempFile)),
}
err := cmd.Invoke().Run()
require.NoError(t, err)
assertLogs(t, tempFile, infoLog, warnLog)
})
t.Run("WithJSON", func(t *testing.T) {
t.Parallel()
tempFile := filepath.Join(t.TempDir(), "test.log")
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t, clilog.WithJSON(tempFile), clilog.WithVerbose()),
}
err := cmd.Invoke().Run()
require.NoError(t, err)
assertLogsJSON(t, tempFile, debug, debugLog, info, infoLog, warn, warnLog, debug, filterLog)
})
t.Run("FromDeploymentValues", func(t *testing.T) {
t.Parallel()
t.Run("Defaults", func(t *testing.T) {
stdoutPath := filepath.Join(t.TempDir(), "stdout")
stderrPath := filepath.Join(t.TempDir(), "stderr")
stdout, err := os.OpenFile(stdoutPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
require.NoError(t, err)
t.Cleanup(func() { _ = stdout.Close() })
stderr, err := os.OpenFile(stderrPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
require.NoError(t, err)
t.Cleanup(func() { _ = stderr.Close() })
// Use the default deployment values.
dv := coderdtest.DeploymentValues(t)
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t, clilog.FromDeploymentValues(dv)),
}
inv := cmd.Invoke()
inv.Stdout = stdout
inv.Stderr = stderr
err = inv.Run()
require.NoError(t, err)
assertLogs(t, stdoutPath, "")
assertLogs(t, stderrPath, infoLog, warnLog)
})
t.Run("Override", func(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "test.log")
tempJSON := filepath.Join(t.TempDir(), "test.json")
dv := &codersdk.DeploymentValues{
Logging: codersdk.LoggingConfig{
Filter: []string{"foo", "baz"},
Human: serpent.String(tempFile),
JSON: serpent.String(tempJSON),
},
Verbose: true,
Trace: codersdk.TraceConfig{
Enable: true,
},
}
cmd := &serpent.Command{
Use: "test",
Handler: testHandler(t, clilog.FromDeploymentValues(dv)),
}
err := cmd.Invoke().Run()
require.NoError(t, err)
assertLogs(t, tempFile, infoLog, warnLog)
assertLogsJSON(t, tempJSON, info, infoLog, warn, warnLog)
})
})
}
func TestMaybeDiscardOnPipeError(t *testing.T) {
t.Parallel()
const payload = "log entry"
t.Run("LeavesStdoutStderrUnchanged", func(t *testing.T) {
t.Parallel()
require.Same(t, os.Stdout, clilog.MaybeDiscardOnPipeError(os.Stdout))
require.Same(t, os.Stderr, clilog.MaybeDiscardOnPipeError(os.Stderr))
})
t.Run("DiscardsClosedPipe", func(t *testing.T) {
t.Parallel()
for _, target := range []error{
io.ErrClosedPipe,
syscall.EPIPE,
xerrors.Errorf("wrapped: %w", io.ErrClosedPipe),
xerrors.Errorf("wrapped: %w", syscall.EPIPE),
} {
fw := &fakeWriter{err: target}
n, err := clilog.MaybeDiscardOnPipeError(fw).Write([]byte(payload))
require.NoError(t, err, "%v should be discarded", target)
assert.Equal(t, len(payload), n)
}
})
t.Run("ReportsOtherErrors", func(t *testing.T) {
t.Parallel()
// os.ErrClosed stays reported: a write to a writer we closed ourselves
// is worth surfacing.
for _, target := range []error{os.ErrClosed, io.ErrShortWrite, xerrors.New("boom")} {
fw := &fakeWriter{err: target}
_, err := clilog.MaybeDiscardOnPipeError(fw).Write([]byte(payload))
require.ErrorIs(t, err, target)
}
})
t.Run("PassesThroughSuccess", func(t *testing.T) {
t.Parallel()
fw := &fakeWriter{}
n, err := clilog.MaybeDiscardOnPipeError(fw).Write([]byte(payload))
require.NoError(t, err)
assert.Equal(t, len(payload), n)
assert.Equal(t, payload, fw.buf.String())
})
}
var (
debug = "DEBUG"
info = "INFO"
warn = "WARN"
debugLog = "this is a debug message"
infoLog = "this is an info message"
warnLog = "this is a warning message"
filterLog = "this is an important debug message you want to see"
)
func testHandler(t testing.TB, opts ...clilog.Option) serpent.HandlerFunc {
t.Helper()
return func(inv *serpent.Invocation) error {
logger, closeLog, err := clilog.New(opts...).Build(inv)
if err != nil {
return err
}
defer closeLog()
logger.Debug(inv.Context(), debugLog)
logger.Info(inv.Context(), infoLog)
logger.Warn(inv.Context(), warnLog)
logger.Debug(inv.Context(), filterLog)
return nil
}
}
func assertLogs(t testing.TB, path string, expected ...string) {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err)
logs := strings.Split(strings.TrimSpace(string(data)), "\n")
if !assert.Len(t, logs, len(expected)) {
t.Log(string(data))
t.FailNow()
}
for i, log := range logs {
require.Contains(t, log, expected[i])
}
}
func assertLogsJSON(t testing.TB, path string, levelExpected ...string) {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err)
if len(levelExpected)%2 != 0 {
t.Errorf("levelExpected must be a list of level-message pairs")
return
}
logs := strings.Split(strings.TrimSpace(string(data)), "\n")
if !assert.Len(t, logs, len(levelExpected)/2) {
t.Log(string(data))
t.FailNow()
}
for i, log := range logs {
var entry struct {
Level string `json:"level"`
Message string `json:"msg"`
}
err := json.NewDecoder(strings.NewReader(log)).Decode(&entry)
require.NoError(t, err)
require.Equal(t, levelExpected[2*i], entry.Level)
require.Equal(t, levelExpected[2*i+1], entry.Message)
}
}
type fakeWriter struct {
buf bytes.Buffer
err error
}
func (f *fakeWriter) Write(p []byte) (int, error) {
if f.err != nil {
return 0, f.err
}
return f.buf.Write(p)
}