diff --git a/internal/docker/log_reader.go b/internal/docker/log_reader.go index f4c8cc67..a78da9a3 100644 --- a/internal/docker/log_reader.go +++ b/internal/docker/log_reader.go @@ -41,9 +41,6 @@ func NewLogReader(r io.Reader, tty bool) *LogReader { func (d *LogReader) Read() (string, container.StdType, error) { message, stdType, err := d.readEvent() - if err != nil { - return "", 0, err - } var std container.StdType switch stdType { @@ -53,10 +50,18 @@ func (d *LogReader) Read() (string, container.StdType, error) { std = container.STDERR } + // A final line without a trailing newline (e.g. a container's last write + // before exiting) arrives together with EOF. Return the partial message + // with the error instead of dropping it; the event generator emits the + // message before handling the error. + if err != nil { + return message, std, err + } + for !strings.HasSuffix(message, "\n") { tail, _, err := d.readEvent() if err != nil { - return "", std, err + return message, std, err } _, after, _ := strings.Cut(tail, " ") diff --git a/internal/docker/log_reader_test.go b/internal/docker/log_reader_test.go new file mode 100644 index 00000000..05526b23 --- /dev/null +++ b/internal/docker/log_reader_test.go @@ -0,0 +1,84 @@ +package docker + +import ( + "bytes" + "encoding/binary" + "io" + "strings" + "testing" + + "github.com/amir20/dozzle/internal/container" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeFrame(buf *bytes.Buffer, streamType byte, payload string) { + header := make([]byte, 8) + header[0] = streamType + binary.BigEndian.PutUint32(header[4:], uint32(len(payload))) + buf.Write(header) + buf.WriteString(payload) +} + +func TestRead_multiplexed(t *testing.T) { + buf := &bytes.Buffer{} + writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z out\n") + writeFrame(buf, 2, "2024-01-01T00:00:01.000000000Z err\n") + + reader := NewLogReader(buf, false) + + message, std, err := reader.Read() + require.NoError(t, err) + assert.Equal(t, "2024-01-01T00:00:00.000000000Z out\n", message) + assert.Equal(t, container.STDOUT, std) + + message, std, err = reader.Read() + require.NoError(t, err) + assert.Equal(t, "2024-01-01T00:00:01.000000000Z err\n", message) + assert.Equal(t, container.STDERR, std) + + _, _, err = reader.Read() + assert.Equal(t, io.EOF, err) +} + +func TestRead_lastLineWithoutNewline(t *testing.T) { + buf := &bytes.Buffer{} + writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z first\n") + writeFrame(buf, 1, "2024-01-01T00:00:01.000000000Z last line without newline") + + reader := NewLogReader(buf, false) + + message, _, err := reader.Read() + require.NoError(t, err) + assert.Equal(t, "2024-01-01T00:00:00.000000000Z first\n", message) + + message, std, err := reader.Read() + assert.Equal(t, io.EOF, err) + assert.Equal(t, container.STDOUT, std) + assert.Equal(t, "2024-01-01T00:00:01.000000000Z last line without newline", message) +} + +func TestRead_lastLineWithoutNewlineTTY(t *testing.T) { + input := "2024-01-01T00:00:00.000000000Z first\n2024-01-01T00:00:01.000000000Z last line without newline" + reader := NewLogReader(strings.NewReader(input), true) + + message, _, err := reader.Read() + require.NoError(t, err) + assert.Equal(t, "2024-01-01T00:00:00.000000000Z first\n", message) + + message, _, err = reader.Read() + assert.Equal(t, io.EOF, err) + assert.Equal(t, "2024-01-01T00:00:01.000000000Z last line without newline", message) +} + +func TestRead_continuedFrames(t *testing.T) { + buf := &bytes.Buffer{} + writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z part one ") + writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z and part two\n") + + reader := NewLogReader(buf, false) + + message, _, err := reader.Read() + require.NoError(t, err) + assert.Equal(t, "2024-01-01T00:00:00.000000000Z part one and part two\n", message) +} diff --git a/internal/k8s/log_reader.go b/internal/k8s/log_reader.go index 2419f329..44782d23 100644 --- a/internal/k8s/log_reader.go +++ b/internal/k8s/log_reader.go @@ -18,10 +18,9 @@ func NewLogReader(reader io.ReadCloser) *LogReader { } func (r *LogReader) Read() (string, container.StdType, error) { + // A final line without a trailing newline arrives together with EOF. + // Return the partial line with the error instead of dropping it; the + // event generator emits the message before handling the error. line, err := r.reader.ReadString('\n') - if err != nil { - return "", 0, err - } - - return line, container.STDOUT, nil + return line, container.STDOUT, err } diff --git a/internal/k8s/log_reader_test.go b/internal/k8s/log_reader_test.go new file mode 100644 index 00000000..f6c191bb --- /dev/null +++ b/internal/k8s/log_reader_test.go @@ -0,0 +1,23 @@ +package k8s + +import ( + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRead_lastLineWithoutNewline(t *testing.T) { + input := "2024-01-01T00:00:00.000000000Z first\n2024-01-01T00:00:01.000000000Z last line without newline" + reader := NewLogReader(io.NopCloser(strings.NewReader(input))) + + message, _, err := reader.Read() + require.NoError(t, err) + assert.Equal(t, "2024-01-01T00:00:00.000000000Z first\n", message) + + message, _, err = reader.Read() + assert.Equal(t, io.EOF, err) + assert.Equal(t, "2024-01-01T00:00:01.000000000Z last line without newline", message) +}