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
3 changes: 1 addition & 2 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ version: 2
linters:
disable:
- unused
- unusedfunc
- unusedparams

issues:
exclude-rules:
- linters:
Expand Down
29 changes: 29 additions & 0 deletions pkg/util/utilfn/streamtolines.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,32 @@ func StreamToLinesChan(input io.Reader) chan LineOutput {
}()
return ch
}

// LineWriter is an io.Writer that processes data line-by-line via a callback.
// Lines do not include the trailing newline. Lines longer than maxLineLength are dropped.
type LineWriter struct {
lineBuf lineBuf
lineFn func([]byte)
}

// NewLineWriter creates a new LineWriter with the given callback function.
func NewLineWriter(lineFn func([]byte)) *LineWriter {
return &LineWriter{
lineFn: lineFn,
}
}

// Write implements io.Writer, processing the data and calling the callback for each complete line.
func (lw *LineWriter) Write(p []byte) (n int, err error) {
streamToLines_processBuf(&lw.lineBuf, p, lw.lineFn)
return len(p), nil
}

// Flush outputs any remaining buffered data as a final line.
// Should be called when the input stream is complete (e.g., at EOF).
func (lw *LineWriter) Flush() {
if len(lw.lineBuf.buf) > 0 && !lw.lineBuf.inLongLine {
lw.lineFn(lw.lineBuf.buf)
lw.lineBuf.buf = nil
}
Comment on lines +102 to +113
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Guard utilfn.LineWriter with a mutex.

This copy of LineWriter is hit by the same concurrent Stdout/Stderr writes. Without locking, shared lineBuf is racy and corrupts captured lines. Please add the same synchronization as suggested in the util package version so both stay in sync.

Apply the identical mutex changes shown on the util version (import sync, add a mutex field, and lock in Write/Flush).

🤖 Prompt for AI Agents
In pkg/util/utilfn/streamtolines.go around lines 102 to 113, the LineWriter is
not concurrency-safe: add synchronization matching the util package variant by
importing "sync", adding a mutex field (e.g., mu sync.Mutex) to the LineWriter
struct, and surrounding the body of Write and Flush with mu.Lock()/defer
mu.Unlock() so accesses to lw.lineBuf are serialized; ensure the Mutex is used
in both methods and no other behavior changes are introduced.

}
46 changes: 36 additions & 10 deletions tsunami/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,42 @@ const MinSupportedGoMinorVersion = 22
const TsunamiUIImportPath = "github.com/wavetermdev/waveterm/tsunami/ui"

type OutputCapture struct {
lock sync.Mutex
lines []string
lock sync.Mutex
lines []string
lineWriter *util.LineWriter
}

func MakeOutputCapture() *OutputCapture {
return &OutputCapture{
oc := &OutputCapture{
lines: make([]string, 0),
}
oc.lineWriter = util.NewLineWriter(func(line []byte) {
// synchronized via the Write/Flush functions
oc.lines = append(oc.lines, string(line))
})
return oc
}

func (oc *OutputCapture) Printf(format string, args ...interface{}) {
func (oc *OutputCapture) Write(p []byte) (n int, err error) {
if oc == nil {
return os.Stdout.Write(p)
}
oc.lock.Lock()
defer oc.lock.Unlock()
return oc.lineWriter.Write(p)
}

func (oc *OutputCapture) Flush() {
if oc == nil || oc.lineWriter == nil {
return
}
oc.lock.Lock()
defer oc.lock.Unlock()
oc.lineWriter.Flush()
}

func (oc *OutputCapture) Printf(format string, args ...interface{}) {
if oc == nil || oc.lineWriter == nil {
log.Printf(format, args...)
return
}
Expand Down Expand Up @@ -319,15 +343,16 @@ func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose boo
tidyCmd := exec.Command("go", "mod", "tidy")
tidyCmd.Dir = tempDir

if verbose {
if oc != nil || verbose {
oc.Printf("Running go mod tidy")
tidyCmd.Stdout = os.Stdout
tidyCmd.Stderr = os.Stderr
tidyCmd.Stdout = oc
tidyCmd.Stderr = oc
}

if err := tidyCmd.Run(); err != nil {
return fmt.Errorf("failed to run go mod tidy: %w", err)
}
oc.Flush()

if verbose {
oc.Printf("Successfully ran go mod tidy")
Expand Down Expand Up @@ -651,15 +676,16 @@ func runGoBuild(tempDir string, opts BuildOpts) error {
buildCmd := exec.Command("go", args...)
buildCmd.Dir = tempDir

if opts.Verbose {
if oc != nil || opts.Verbose {
oc.Printf("Running: %s", strings.Join(buildCmd.Args, " "))
buildCmd.Stdout = os.Stdout
buildCmd.Stderr = os.Stderr
buildCmd.Stdout = oc
buildCmd.Stderr = oc
}

if err := buildCmd.Run(); err != nil {
return fmt.Errorf("failed to build application: %w", err)
}
oc.Flush()
Comment on lines +679 to +688
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Incorrect logic causes panic when verbose is true but oc is nil.

Same issue as in createGoMod: the OR condition at line 679 causes panics at lines 680 and 688 when verbose is true but oc is nil.

Apply this fix:

-	if oc != nil || opts.Verbose {
+	if oc != nil {
 		oc.Printf("Running: %s", strings.Join(buildCmd.Args, " "))
 		buildCmd.Stdout = oc
 		buildCmd.Stderr = oc
+	} else if opts.Verbose {
+		buildCmd.Stdout = os.Stdout
+		buildCmd.Stderr = os.Stderr
 	}
 
 	if err := buildCmd.Run(); err != nil {
 		return fmt.Errorf("failed to build application: %w", err)
 	}
-	oc.Flush()
+	if oc != nil {
+		oc.Flush()
+	}
🤖 Prompt for AI Agents
In tsunami/build/build.go around lines 679 to 688, the condition uses "oc != nil
|| opts.Verbose" which leads to dereferencing a nil oc when verbose is true;
change the guard so all uses of oc (oc.Printf, buildCmd.Stdout, buildCmd.Stderr,
and oc.Flush) only execute when oc != nil (e.g., use oc != nil && opts.Verbose
for the print if you need verbosity tied to opts, or simply check oc != nil
before assigning Stdout/Stderr and calling Printf), and ensure oc.Flush() is
called only when oc != nil to avoid the panic.


if opts.Verbose {
if opts.OutputFile != "" {
Expand Down
114 changes: 114 additions & 0 deletions tsunami/util/streamtolines.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package util

import (
"bytes"
"context"
"io"
"time"
)

type LineOutput struct {
Line string
Error error
}

type lineBuf struct {
buf []byte
inLongLine bool
}

const maxLineLength = 128 * 1024

func ReadLineWithTimeout(ch chan LineOutput, timeout time.Duration) (string, error) {
select {
case output := <-ch:
if output.Error != nil {
return "", output.Error
}
return output.Line, nil
case <-time.After(timeout):
return "", context.DeadlineExceeded
}
}

func streamToLines_processBuf(lineBuf *lineBuf, readBuf []byte, lineFn func([]byte)) {
for len(readBuf) > 0 {
nlIdx := bytes.IndexByte(readBuf, '\n')
if nlIdx == -1 {
if lineBuf.inLongLine || len(lineBuf.buf)+len(readBuf) > maxLineLength {
lineBuf.buf = nil
lineBuf.inLongLine = true
return
}
lineBuf.buf = append(lineBuf.buf, readBuf...)
return
}
if !lineBuf.inLongLine && len(lineBuf.buf)+nlIdx <= maxLineLength {
line := append(lineBuf.buf, readBuf[:nlIdx]...)
lineFn(line)
}
lineBuf.buf = nil
lineBuf.inLongLine = false
readBuf = readBuf[nlIdx+1:]
}
}

func StreamToLines(input io.Reader, lineFn func([]byte)) error {
var lineBuf lineBuf
readBuf := make([]byte, 64*1024)
for {
n, err := input.Read(readBuf)
streamToLines_processBuf(&lineBuf, readBuf[:n], lineFn)
if err != nil {
return err
}
}
}

// starts a goroutine to drive the channel
// line output does not include the trailing newline
func StreamToLinesChan(input io.Reader) chan LineOutput {
ch := make(chan LineOutput)
go func() {
defer close(ch)
err := StreamToLines(input, func(line []byte) {
ch <- LineOutput{Line: string(line)}
})
if err != nil && err != io.EOF {
ch <- LineOutput{Error: err}
}
}()
return ch
}

// LineWriter is an io.Writer that processes data line-by-line via a callback.
// Lines do not include the trailing newline. Lines longer than maxLineLength are dropped.
type LineWriter struct {
lineBuf lineBuf
lineFn func([]byte)
}

// NewLineWriter creates a new LineWriter with the given callback function.
func NewLineWriter(lineFn func([]byte)) *LineWriter {
return &LineWriter{
lineFn: lineFn,
}
}

// Write implements io.Writer, processing the data and calling the callback for each complete line.
func (lw *LineWriter) Write(p []byte) (n int, err error) {
streamToLines_processBuf(&lw.lineBuf, p, lw.lineFn)
return len(p), nil
}

// Flush outputs any remaining buffered data as a final line.
// Should be called when the input stream is complete (e.g., at EOF).
func (lw *LineWriter) Flush() {
if len(lw.lineBuf.buf) > 0 && !lw.lineBuf.inLongLine {
lw.lineFn(lw.lineBuf.buf)
lw.lineBuf.buf = nil
}
}
Loading