Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions internal/output/tui_sink.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,39 @@ type Sender interface {
Send(msg any)
}

// LogLinePrinter abstracts Bubble Tea's Program.Println — permanent output
// that persists across renders, unlike a Sender's capped/repainted lines.
type LogLinePrinter interface {
PrintLogLine(event LogLineEvent)
}

type TUISink struct {
sender Sender
sender Sender
logPrinter LogLinePrinter
}

func NewTUISink(sender Sender) *TUISink {
return &TUISink{sender: sender}
}

// NewStreamingLogSink is like NewTUISink but routes LogLineEvent through
// logPrinter instead of sender, giving log lines real terminal scrollback
// instead of the TUI model's capped/repainted history.
func NewStreamingLogSink(sender Sender, logPrinter LogLinePrinter) *TUISink {
return &TUISink{sender: sender, logPrinter: logPrinter}
}

func (s *TUISink) Emit(event Event) {
if s == nil || s.sender == nil {
if s == nil {
return
}
if s.logPrinter != nil {
if logLine, ok := event.(LogLineEvent); ok {
s.logPrinter.PrintLogLine(logLine)
return
}
}
if s.sender == nil {
return
}
s.sender.Send(event)
Expand Down
49 changes: 49 additions & 0 deletions internal/output/tui_sink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,52 @@ func TestTUISinkNilSenderNoPanic(t *testing.T) {
sink := NewTUISink(nil)
sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "noop"})
}

type testLogPrinter struct {
lines []LogLineEvent
}

func (p *testLogPrinter) PrintLogLine(event LogLineEvent) {
p.lines = append(p.lines, event)
}

// A streaming log sink routes LogLineEvent to the log printer and every
// other event to the sender, as before.
func TestStreamingLogSinkRoutesLogLinesToPrinter(t *testing.T) {
t.Parallel()

sender := &testSender{}
printer := &testLogPrinter{}
sink := NewStreamingLogSink(sender, printer)

sink.Emit(LogLineEvent{Source: "emulator", Line: "first", Level: LogLevelInfo})
sink.Emit(MessageEvent{Severity: SeverityWarning, Text: "careful"})
sink.Emit(LogLineEvent{Source: "emulator", Line: "second", Level: LogLevelInfo})

wantLines := []LogLineEvent{
{Source: "emulator", Line: "first", Level: LogLevelInfo},
{Source: "emulator", Line: "second", Level: LogLevelInfo},
}
if !reflect.DeepEqual(printer.lines, wantLines) {
t.Fatalf("unexpected printed lines: got=%#v want=%#v", printer.lines, wantLines)
}

wantSent := []any{MessageEvent{Severity: SeverityWarning, Text: "careful"}}
if !reflect.DeepEqual(sender.msgs, wantSent) {
t.Fatalf("unexpected sent msgs: got=%#v want=%#v", sender.msgs, wantSent)
}
}

func TestStreamingLogSinkNilLogPrinterFallsBackToSender(t *testing.T) {
t.Parallel()

sender := &testSender{}
sink := NewStreamingLogSink(sender, nil)

sink.Emit(LogLineEvent{Source: "emulator", Line: "first", Level: LogLevelInfo})

want := []any{LogLineEvent{Source: "emulator", Line: "first", Level: LogLevelInfo}}
if !reflect.DeepEqual(sender.msgs, want) {
t.Fatalf("unexpected sent msgs: got=%#v want=%#v", sender.msgs, want)
}
}
34 changes: 33 additions & 1 deletion internal/ui/run_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,37 @@ import (
"github.com/localstack/lstk/internal/runtime"
)

// printlner lets tests substitute a fake *tea.Program.
type printlner interface {
Println(args ...interface{})
}

// programLogPrinter prints log lines permanently above the Program instead of
// the App model's capped a.lines buffer. Must be called synchronously from a
// single goroutine, not returned as a tea.Cmd — Cmds run concurrently and
// would reorder lines.
type programLogPrinter struct {
p printlner
ctx context.Context
}

// Println, unlike Send, has no escape valve for a Program that already
// stopped reading its message channel (e.g. it quit, or a signal bypassed
// Update) — it would then block forever. Racing it against ctx.Done avoids
// wedging the caller; the abandoned goroutine only leaks in that shutdown
// race, and dies with the process.
func (l programLogPrinter) PrintLogLine(event output.LogLineEvent) {
done := make(chan struct{})
go func() {
l.p.Println(renderLogLineEvent(event, 0))
close(done)
}()
select {
case <-done:
case <-l.ctx.Done():
}
}

func RunLogs(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, follow bool, tail string, verbose bool) error {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
Expand All @@ -21,7 +52,8 @@ func RunLogs(parentCtx context.Context, rt runtime.Runtime, containers []config.
runErrCh := make(chan error, 1)

go func() {
err := container.Logs(ctx, rt, output.NewTUISink(programSender{p: p}), containers, follow, tail, verbose)
sink := output.NewStreamingLogSink(programSender{p: p}, programLogPrinter{p: p, ctx: ctx})
err := container.Logs(ctx, rt, sink, containers, follow, tail, verbose)
runErrCh <- err
if err != nil && !errors.Is(err, context.Canceled) {
p.Send(runErrMsg{err: err})
Expand Down
66 changes: 66 additions & 0 deletions internal/ui/run_logs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package ui

import (
"context"
"testing"
"time"

"github.com/localstack/lstk/internal/output"
)

// blockingPrintlner simulates a Program that stopped draining Println,
// blocking forever like the real unbuffered channel send would.
type blockingPrintlner struct{}

func (blockingPrintlner) Println(args ...interface{}) {
select {}
}

func TestProgramLogPrinterReturnsPromptlyWhenCtxCancelledMidPrintln(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithCancel(context.Background())
cancel()

printer := programLogPrinter{p: blockingPrintlner{}, ctx: ctx}

done := make(chan struct{})
go func() {
printer.PrintLogLine(output.LogLineEvent{Source: "emulator", Line: "hangs forever"})
close(done)
}()

select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("PrintLogLine did not return after ctx was cancelled; it would hang RunLogs forever")
}
}

type recordingPrintlner struct {
lines chan string
}

func (r recordingPrintlner) Println(args ...interface{}) {
r.lines <- args[0].(string)
}

func TestProgramLogPrinterWaitsForPrintlnWhenNotCancelled(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

recorder := recordingPrintlner{lines: make(chan string, 1)}
printer := programLogPrinter{p: recorder, ctx: ctx}
printer.PrintLogLine(output.LogLineEvent{Source: "emulator", Line: "hello", Level: output.LogLevelInfo})

select {
case line := <-recorder.lines:
if line == "" {
t.Fatal("expected rendered log line to be printed")
}
default:
t.Fatal("expected PrintLogLine to have already delivered the line to Println")
}
}
23 changes: 23 additions & 0 deletions test/integration/logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,29 @@ func TestLogsTailWithFollowStartsFromTail(t *testing.T) {
}
}

// Interactive lstk logs must preserve full scrollback like docker logs, not
// just whatever fit in the TUI's capped history. Runs under a real PTY so the
// TUI/RunLogs path is exercised.
func TestLogsInteractivePreservesFullScrollback(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)

ctx := testContext(t)
startTestContainer(t, ctx)

const lineCount = 550
writeNumberedLogLines(t, ctx, lineCount)

configFile := writeAwsConfig(t)
out, err := runLstkInPTY(t, ctx, env.Without(), "--config", configFile, "logs")
require.NoError(t, err, "lstk logs should exit cleanly in interactive mode, output: %s", out)

for i := 1; i <= lineCount; i++ {
assert.Contains(t, out, fmt.Sprintf("tail-marker-%d", i), "expected tail-marker-%d to survive scrollback", i)
}
}

func TestLogsFollowStreamsOutput(t *testing.T) {
requireDocker(t)
cleanup()
Expand Down
Loading