Bound retained Go command diagnostics
This commit is contained in:
@@ -95,7 +95,7 @@ func New(ctx context.Context, directory string) (*Tool, error) {
|
||||
// local Go toolchain. It preserves caller module-network settings and caches,
|
||||
// while disabling workspace discovery and automatic toolchain downloads.
|
||||
func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
||||
captured := &combinedWriter{}
|
||||
captured := &combinedWriter{mode: captureStatus}
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: []string{"go", "mod", "download", "-modfile="},
|
||||
Dir: directory,
|
||||
@@ -107,7 +107,8 @@ func PrepareBuildDependencies(ctx context.Context, directory string) error {
|
||||
Stderr: captured,
|
||||
})
|
||||
if err != nil {
|
||||
if output := strings.TrimSpace(string(captured.take())); output != "" {
|
||||
omitted := captured.omitted()
|
||||
if output := diagnosticText(captured.take(), omitted); output != "" {
|
||||
return fmt.Errorf("go mod download: %w: %s", err, output)
|
||||
}
|
||||
return fmt.Errorf("go mod download: %w", err)
|
||||
@@ -140,7 +141,11 @@ func (tool *Tool) execute(ctx context.Context, directory string, mode Mode, targ
|
||||
return nil, errors.New("Go command requires arguments")
|
||||
}
|
||||
environment := tool.environment(mode, target)
|
||||
captured := &combinedWriter{output: output}
|
||||
modeCapture := captureStatus
|
||||
if keepOutput {
|
||||
modeCapture = captureFull
|
||||
}
|
||||
captured := &combinedWriter{output: output, mode: modeCapture}
|
||||
commandArgs := append([]string{"go"}, args...)
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: commandArgs,
|
||||
@@ -152,16 +157,28 @@ func (tool *Tool) execute(ctx context.Context, directory string, mode Mode, targ
|
||||
if forwardErr := captured.forwardingError(); forwardErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("forward Go output: %w", forwardErr))
|
||||
}
|
||||
if err == nil && !keepOutput {
|
||||
return nil, nil
|
||||
}
|
||||
omitted := captured.omitted()
|
||||
contents := captured.take()
|
||||
if err != nil {
|
||||
if len(contents) != 0 {
|
||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
|
||||
if diagnostic := diagnosticText(contents, omitted); diagnostic != "" {
|
||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, diagnostic)
|
||||
}
|
||||
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return outputBytes(contents, keepOutput), nil
|
||||
}
|
||||
|
||||
func diagnosticText(contents []byte, omitted bool) string {
|
||||
text := strings.TrimSpace(string(contents))
|
||||
if omitted {
|
||||
return "[earlier Go output omitted]\n" + text
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func outputBytes(contents []byte, keep bool) []byte {
|
||||
if keep {
|
||||
return contents
|
||||
@@ -232,17 +249,63 @@ type combinedWriter struct {
|
||||
mu sync.Mutex
|
||||
output io.Writer
|
||||
buffer bytes.Buffer
|
||||
mode captureMode
|
||||
tail []byte
|
||||
head int
|
||||
truncated bool
|
||||
forwardErr error
|
||||
}
|
||||
|
||||
type captureMode uint8
|
||||
|
||||
const (
|
||||
captureFull captureMode = iota
|
||||
captureStatus
|
||||
statusDiagnosticLimit = 1 << 20
|
||||
)
|
||||
|
||||
func (writer *combinedWriter) capture(contents []byte) {
|
||||
if writer.mode == captureFull {
|
||||
_, _ = writer.buffer.Write(contents)
|
||||
return
|
||||
}
|
||||
if len(contents) >= statusDiagnosticLimit {
|
||||
writer.truncated = writer.truncated || len(writer.tail) != 0 || len(contents) > statusDiagnosticLimit
|
||||
if cap(writer.tail) < statusDiagnosticLimit {
|
||||
writer.tail = make([]byte, statusDiagnosticLimit)
|
||||
} else {
|
||||
writer.tail = writer.tail[:statusDiagnosticLimit]
|
||||
}
|
||||
copy(writer.tail, contents[len(contents)-statusDiagnosticLimit:])
|
||||
writer.head = 0
|
||||
return
|
||||
}
|
||||
if available := statusDiagnosticLimit - len(writer.tail); available > 0 {
|
||||
n := min(available, len(contents))
|
||||
if needed := len(writer.tail) + n; needed > cap(writer.tail) {
|
||||
capacity := min(statusDiagnosticLimit, max(needed, 2*cap(writer.tail)))
|
||||
grown := make([]byte, len(writer.tail), capacity)
|
||||
copy(grown, writer.tail)
|
||||
writer.tail = grown
|
||||
}
|
||||
writer.tail = append(writer.tail, contents[:n]...)
|
||||
contents = contents[n:]
|
||||
}
|
||||
if len(contents) == 0 {
|
||||
return
|
||||
}
|
||||
writer.truncated = true
|
||||
n := copy(writer.tail[writer.head:], contents)
|
||||
copy(writer.tail, contents[n:])
|
||||
writer.head = (writer.head + len(contents)) % statusDiagnosticLimit
|
||||
}
|
||||
|
||||
func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
writer.capture(contents)
|
||||
if writer.output == nil {
|
||||
return writer.buffer.Write(contents)
|
||||
}
|
||||
if _, err := writer.buffer.Write(contents); err != nil {
|
||||
return 0, err
|
||||
return len(contents), nil
|
||||
}
|
||||
written, err := writer.output.Write(contents)
|
||||
if err == nil && written != len(contents) {
|
||||
@@ -254,6 +317,12 @@ func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
||||
return written, err
|
||||
}
|
||||
|
||||
func (writer *combinedWriter) omitted() bool {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
return writer.truncated
|
||||
}
|
||||
|
||||
func (writer *combinedWriter) forwardingError() error {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
@@ -265,5 +334,18 @@ func (writer *combinedWriter) forwardingError() error {
|
||||
func (writer *combinedWriter) take() []byte {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
return writer.buffer.Bytes()
|
||||
if writer.mode == captureFull {
|
||||
contents := writer.buffer.Bytes()
|
||||
writer.buffer = bytes.Buffer{}
|
||||
return contents
|
||||
}
|
||||
contents := writer.tail
|
||||
if writer.head != 0 {
|
||||
contents = make([]byte, 0, len(writer.tail))
|
||||
contents = append(contents, writer.tail[writer.head:]...)
|
||||
contents = append(contents, writer.tail[:writer.head]...)
|
||||
}
|
||||
writer.tail = nil
|
||||
writer.head = 0
|
||||
return contents
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -453,6 +455,96 @@ func TestOutputReturnsLargeFailureDiagnosticsAndCause(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCaptureRetainsTailAndForwardsAll(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
size int
|
||||
chunkSize int
|
||||
}{
|
||||
{"below", statusDiagnosticLimit - 1, 8191},
|
||||
{"exact", statusDiagnosticLimit, 8191},
|
||||
{"above", statusDiagnosticLimit + 1, 8191},
|
||||
{"single oversized", statusDiagnosticLimit*2 + 3, statusDiagnosticLimit*2 + 3},
|
||||
{"wrapped small writes", statusDiagnosticLimit*3 + 17, 113},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
input := make([]byte, test.size)
|
||||
for i := range input {
|
||||
input[i] = byte(i % 251)
|
||||
}
|
||||
var forwarded bytes.Buffer
|
||||
writer := &combinedWriter{mode: captureStatus, output: &forwarded}
|
||||
for start := 0; start < len(input); start += test.chunkSize {
|
||||
chunk := input[start:min(start+test.chunkSize, len(input))]
|
||||
if n, err := writer.Write(chunk); n != len(chunk) || err != nil {
|
||||
t.Fatalf("write = %d, %v", n, err)
|
||||
}
|
||||
}
|
||||
if writer.omitted() != (len(input) > statusDiagnosticLimit) || cap(writer.tail) > statusDiagnosticLimit {
|
||||
t.Fatalf("omitted = %t, capacity = %d", writer.omitted(), cap(writer.tail))
|
||||
}
|
||||
want := input[max(0, len(input)-statusDiagnosticLimit):]
|
||||
if got := writer.take(); !bytes.Equal(got, want) || !bytes.Equal(forwarded.Bytes(), input) {
|
||||
t.Fatalf("capture size = %d, forwarded size = %d", len(got), forwarded.Len())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCaptureSerializesConcurrentWriters(t *testing.T) {
|
||||
var forwarded bytes.Buffer
|
||||
writer := &combinedWriter{mode: captureStatus, output: &forwarded}
|
||||
var group sync.WaitGroup
|
||||
for _, value := range []byte{'a', 'b'} {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
chunk := bytes.Repeat([]byte{value}, 4096)
|
||||
for i := 0; i < 300; i++ {
|
||||
_, _ = writer.Write(chunk)
|
||||
}
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
contents := forwarded.Bytes()
|
||||
if !writer.omitted() || !bytes.Equal(writer.take(), contents[len(contents)-statusDiagnosticLimit:]) {
|
||||
t.Fatal("concurrent capture differs from forwarded tail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCommandsRetainBoundedFailureDiagnostics(t *testing.T) {
|
||||
repository, _ := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "large-fail")
|
||||
var forwarded bytes.Buffer
|
||||
err := fixtureTool().Run(context.Background(), repository, Native, Target{}, &forwarded, "version")
|
||||
var exitError *exec.ExitError
|
||||
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "earlier Go output omitted") || !strings.Contains(err.Error(), "fixture large failure") || len(err.Error()) > statusDiagnosticLimit+200 || forwarded.Len() != (8<<20)+len("fixture large failure\n") {
|
||||
t.Fatalf("Run() error = %v, forwarded = %d bytes", err, forwarded.Len())
|
||||
}
|
||||
err = PrepareBuildDependencies(context.Background(), repository)
|
||||
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "earlier Go output omitted") || !strings.Contains(err.Error(), "fixture large failure") || len(err.Error()) > statusDiagnosticLimit+200 {
|
||||
t.Fatalf("preparation error did not retain bounded diagnostics: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCommandPreservesForwardingError(t *testing.T) {
|
||||
repository, _ := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output")
|
||||
writerErr := errors.New("forwarding failed")
|
||||
err := fixtureTool().Run(context.Background(), repository, Native, Target{}, failingOutputWriter{err: writerErr}, "version")
|
||||
if !errors.Is(err, writerErr) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
err = fixtureTool().Run(context.Background(), repository, Native, Target{}, shortOutputWriter{}, "version")
|
||||
if !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("Run() short-write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type shortOutputWriter struct{}
|
||||
|
||||
func (shortOutputWriter) Write([]byte) (int, error) { return 0, nil }
|
||||
|
||||
func TestOutputReturnsCancellation(t *testing.T) {
|
||||
repository, recordPath := installGoPreparationFixture(t)
|
||||
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "wait")
|
||||
|
||||
@@ -20,36 +20,45 @@ func BenchmarkPerformanceCombinedOutput(b *testing.B) {
|
||||
} {
|
||||
input := bytes.Repeat([]byte("go tool diagnostic output\n"), (testCase.size+25)/26)
|
||||
input = input[:testCase.size]
|
||||
b.Run(testCase.name+"/discard-forwarding", func(b *testing.B) {
|
||||
// Verify capture and forwarding for this workload outside the measurement.
|
||||
var forwarded bytes.Buffer
|
||||
verification := &combinedWriter{output: &forwarded}
|
||||
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
||||
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
||||
if written, err := verification.Write(chunk); err != nil || written != len(chunk) {
|
||||
b.Fatalf("verify output write = %d, err %v; want %d bytes", written, err, len(chunk))
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(verification.take(), input) || !bytes.Equal(forwarded.Bytes(), input) {
|
||||
b.Fatal("captured and forwarded output must equal the complete input")
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(input)))
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(len(input)), "bytes/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
writer := &combinedWriter{output: io.Discard}
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
mode captureMode
|
||||
}{{"full", captureFull}, {"status", captureStatus}} {
|
||||
b.Run(testCase.name+"/"+mode.name, func(b *testing.B) {
|
||||
// Verify capture and forwarding for this workload outside the measurement.
|
||||
var forwarded bytes.Buffer
|
||||
verification := &combinedWriter{output: &forwarded, mode: mode.mode}
|
||||
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
||||
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
||||
if written, err := writer.Write(chunk); err != nil || written != len(chunk) {
|
||||
b.Fatalf("write captured output = %d, err %v; want %d bytes", written, err, len(chunk))
|
||||
if written, err := verification.Write(chunk); err != nil || written != len(chunk) {
|
||||
b.Fatalf("verify output write = %d, err %v; want %d bytes", written, err, len(chunk))
|
||||
}
|
||||
}
|
||||
captured := writer.take()
|
||||
if len(captured) != len(input) {
|
||||
b.Fatalf("captured output = %d bytes, want %d", len(captured), len(input))
|
||||
want := input
|
||||
if mode.mode == captureStatus && len(want) > statusDiagnosticLimit {
|
||||
want = want[len(want)-statusDiagnosticLimit:]
|
||||
}
|
||||
}
|
||||
})
|
||||
if !bytes.Equal(verification.take(), want) || !bytes.Equal(forwarded.Bytes(), input) {
|
||||
b.Fatal("captured output or forwarding did not match the selected mode")
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(input)))
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(len(input)), "bytes/op")
|
||||
for i := 0; i < b.N; i++ {
|
||||
writer := &combinedWriter{output: io.Discard, mode: mode.mode}
|
||||
for offset := 0; offset < len(input); offset += testCase.chunkSize {
|
||||
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
|
||||
if written, err := writer.Write(chunk); err != nil || written != len(chunk) {
|
||||
b.Fatalf("write captured output = %d, err %v; want %d bytes", written, err, len(chunk))
|
||||
}
|
||||
}
|
||||
captured := writer.take()
|
||||
if len(captured) != len(want) {
|
||||
b.Fatalf("captured output = %d bytes, want %d", len(captured), len(want))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user