Skip to content

Commit d5c8db4

Browse files
fix(go): capture CLI stderr and fix SetProcessDone race (#863)
* test(go): validate stderr not captured and SetProcessDone race on process exit * fix(go): capture CLI stderr and fix SetProcessDone race * test(go): avoid breaking portability Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(tests): read stderr msg and exit code from env vars in TestHelperProcess to match newStderrTestCommand * fix: use bounded ring buffer for CLI stderr capture to prevent unbounded memory growth * fix: add new package truncbuffer --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 30a76a5 commit d5c8db4

6 files changed

Lines changed: 437 additions & 18 deletions

File tree

go/client.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import (
4848

4949
"github.com/github/copilot-sdk/go/internal/embeddedcli"
5050
"github.com/github/copilot-sdk/go/internal/jsonrpc2"
51+
"github.com/github/copilot-sdk/go/internal/truncbuffer"
5152
"github.com/github/copilot-sdk/go/rpc"
5253
)
5354

@@ -1442,6 +1443,11 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error {
14421443
return nil
14431444
}
14441445

1446+
// stderrBufferSize is the maximum number of bytes kept from the CLI process's
1447+
// stderr. Only the tail is retained so that memory stays bounded even when the
1448+
// process produces a large amount of diagnostic output.
1449+
const stderrBufferSize = 64 * 1024
1450+
14451451
// startCLIServer starts the CLI server process.
14461452
//
14471453
// This spawns the CLI server as a subprocess using the configured transport
@@ -1558,6 +1564,8 @@ func (c *Client) startCLIServer(ctx context.Context) error {
15581564
return fmt.Errorf("failed to create stdout pipe: %w", err)
15591565
}
15601566

1567+
c.process.Stderr = truncbuffer.NewTruncBuffer(stderrBufferSize)
1568+
15611569
if err := c.process.Start(); err != nil {
15621570
return fmt.Errorf("failed to start CLI server: %w", err)
15631571
}
@@ -1589,12 +1597,15 @@ func (c *Client) startCLIServer(ctx context.Context) error {
15891597
return fmt.Errorf("failed to create stdout pipe: %w", err)
15901598
}
15911599

1600+
c.process.Stderr = truncbuffer.NewTruncBuffer(stderrBufferSize)
1601+
15921602
if err := c.process.Start(); err != nil {
15931603
return fmt.Errorf("failed to start CLI server: %w", err)
15941604
}
15951605

15961606
c.monitorProcess()
15971607

1608+
proc := c.process
15981609
scanner := bufio.NewScanner(stdout)
15991610
portRegex := regexp.MustCompile(`listening on port (\d+)`)
16001611

@@ -1605,10 +1616,22 @@ func (c *Client) startCLIServer(ctx context.Context) error {
16051616
select {
16061617
case <-ctx.Done():
16071618
killErr := c.killProcess()
1608-
return errors.Join(fmt.Errorf("failed waiting for CLI server to start: %w", ctx.Err()), killErr)
1619+
baseErr := fmt.Errorf("failed waiting for CLI server to start: %w", ctx.Err())
1620+
if buf, ok := proc.Stderr.(*truncbuffer.TruncBuffer); ok {
1621+
if stderr := strings.TrimSpace(buf.String()); stderr != "" {
1622+
baseErr = fmt.Errorf("%w; stderr: %s", baseErr, stderr)
1623+
}
1624+
}
1625+
return errors.Join(baseErr, killErr)
16091626
case <-c.processDone:
16101627
killErr := c.killProcess()
1611-
return errors.Join(errors.New("CLI server process exited before reporting port"), killErr)
1628+
baseErr := errors.New("CLI server process exited before reporting port")
1629+
if buf, ok := proc.Stderr.(*truncbuffer.TruncBuffer); ok {
1630+
if stderr := strings.TrimSpace(buf.String()); stderr != "" {
1631+
baseErr = fmt.Errorf("%w; stderr: %s", baseErr, stderr)
1632+
}
1633+
}
1634+
return errors.Join(baseErr, killErr)
16121635
default:
16131636
if scanner.Scan() {
16141637
line := scanner.Text()
@@ -1651,10 +1674,22 @@ func (c *Client) monitorProcess() {
16511674
c.processErrorPtr = &processError
16521675
go func() {
16531676
waitErr := proc.Wait()
1677+
var stderrOutput string
1678+
if buf, ok := proc.Stderr.(*truncbuffer.TruncBuffer); ok {
1679+
stderrOutput = strings.TrimSpace(buf.String())
1680+
}
16541681
if waitErr != nil {
1655-
processError = fmt.Errorf("CLI process exited: %w", waitErr)
1682+
if stderrOutput != "" {
1683+
processError = fmt.Errorf("CLI process exited: %w\nstderr: %s", waitErr, stderrOutput)
1684+
} else {
1685+
processError = fmt.Errorf("CLI process exited: %w", waitErr)
1686+
}
16561687
} else {
1657-
processError = errors.New("CLI process exited unexpectedly")
1688+
if stderrOutput != "" {
1689+
processError = fmt.Errorf("CLI process exited unexpectedly\nstderr: %s", stderrOutput)
1690+
} else {
1691+
processError = errors.New("CLI process exited unexpectedly")
1692+
}
16581693
}
16591694
close(done)
16601695
}()

go/client_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import (
44
"context"
55
"encoding/json"
66
"os"
7+
"os/exec"
78
"path/filepath"
89
"reflect"
910
"regexp"
11+
"strconv"
12+
"strings"
1013
"sync"
1114
"testing"
1215

16+
"github.com/github/copilot-sdk/go/internal/truncbuffer"
1317
"github.com/github/copilot-sdk/go/rpc"
1418
)
1519

@@ -1297,3 +1301,127 @@ func TestCreateSessionResponse_Capabilities(t *testing.T) {
12971301
}
12981302
})
12991303
}
1304+
1305+
// TestHelperProcess is a helper used by tests that need to spawn a process
1306+
// which writes to stderr and exits with a given status. It is invoked
1307+
// via "go test" by running the test binary itself with -test.run.
1308+
// The stderr message and exit code are passed via environment variables
1309+
// HELPER_STDERR_MSG and HELPER_EXIT_CODE (defaulting to "" and 1).
1310+
func TestHelperProcess(t *testing.T) {
1311+
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
1312+
// Not in helper process mode; let the test run normally.
1313+
return
1314+
}
1315+
1316+
msg := os.Getenv("HELPER_STDERR_MSG")
1317+
if msg == "" {
1318+
// Fall back to command-line args after "--" for backwards compat.
1319+
for i, arg := range os.Args {
1320+
if arg == "--" && i+1 < len(os.Args) {
1321+
msg = os.Args[i+1]
1322+
break
1323+
}
1324+
}
1325+
}
1326+
if msg != "" {
1327+
_, _ = os.Stderr.WriteString(msg + "\n")
1328+
}
1329+
1330+
exitCode := 1
1331+
if ec := os.Getenv("HELPER_EXIT_CODE"); ec != "" {
1332+
if v, err := strconv.Atoi(ec); err == nil {
1333+
exitCode = v
1334+
}
1335+
}
1336+
os.Exit(exitCode)
1337+
}
1338+
1339+
// newStderrTestCommand constructs a command that re-invokes the current test
1340+
// binary to run TestHelperProcess with the provided stderr message and exit
1341+
// code. This avoids any dependency on a shell like "sh" and is portable.
1342+
func newStderrTestCommand(stderrMsg string, exitCode int) *exec.Cmd {
1343+
cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess")
1344+
cmd.Env = append(os.Environ(),
1345+
"GO_WANT_HELPER_PROCESS=1",
1346+
"HELPER_STDERR_MSG="+stderrMsg,
1347+
"HELPER_EXIT_CODE="+strconv.Itoa(exitCode),
1348+
)
1349+
return cmd
1350+
}
1351+
1352+
// TestMonitorProcess_StderrCaptured validates that when the CLI process
1353+
// writes an error to stderr and exits, the stderr content IS included
1354+
// in the process error (now that startCLIServer sets Stderr).
1355+
func TestMonitorProcess_StderrCaptured(t *testing.T) {
1356+
client := &Client{
1357+
sessions: make(map[string]*Session),
1358+
}
1359+
1360+
stderrMsg := "error: authentication failed: invalid token"
1361+
client.process = exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--", stderrMsg)
1362+
client.process.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
1363+
1364+
// Replicate what startCLIServer now does: capture stderr.
1365+
client.process.Stderr = truncbuffer.NewTruncBuffer(stderrBufferSize)
1366+
1367+
if err := client.process.Start(); err != nil {
1368+
t.Fatalf("failed to start test process: %v", err)
1369+
}
1370+
1371+
client.monitorProcess()
1372+
1373+
// Wait for the process to exit.
1374+
<-client.processDone
1375+
1376+
processError := *client.processErrorPtr
1377+
if processError == nil {
1378+
t.Fatal("expected a process error after non-zero exit, got nil")
1379+
}
1380+
1381+
if !strings.Contains(processError.Error(), stderrMsg) {
1382+
t.Errorf("stderr output not included in process error.\n"+
1383+
" got: %q\n"+
1384+
" want: error containing %q", processError.Error(), stderrMsg)
1385+
}
1386+
}
1387+
1388+
// TestMonitorProcess_StderrCapturedOnZeroExit validates that even when the
1389+
// CLI process exits with code 0, stderr content is included in the error.
1390+
func TestMonitorProcess_StderrCapturedOnZeroExit(t *testing.T) {
1391+
client := &Client{
1392+
sessions: make(map[string]*Session),
1393+
}
1394+
1395+
stderrMsg := "warning: version mismatch, shutting down"
1396+
client.process = newStderrTestCommand(stderrMsg, 0)
1397+
client.process.Stderr = truncbuffer.NewTruncBuffer(stderrBufferSize)
1398+
1399+
if err := client.process.Start(); err != nil {
1400+
t.Fatalf("failed to start test process: %v", err)
1401+
}
1402+
1403+
client.monitorProcess()
1404+
<-client.processDone
1405+
1406+
processError := *client.processErrorPtr
1407+
if processError == nil {
1408+
t.Fatal("expected a process error for unexpected exit, got nil")
1409+
}
1410+
1411+
if !strings.Contains(processError.Error(), stderrMsg) {
1412+
t.Errorf("stderr output not included in process error for exit code 0.\n"+
1413+
" got: %q\n"+
1414+
" want: error containing %q", processError.Error(), stderrMsg)
1415+
}
1416+
}
1417+
1418+
// TestStartCLIServer_StderrFieldSet verifies that startCLIServer sets
1419+
// exec.Cmd.Stderr to a *truncbuffer.TruncBuffer so CLI diagnostic output is captured.
1420+
func TestStartCLIServer_StderrFieldSet(t *testing.T) {
1421+
cmd := exec.Command(os.Args[0])
1422+
buf := truncbuffer.NewTruncBuffer(stderrBufferSize)
1423+
cmd.Stderr = buf
1424+
if _, ok := cmd.Stderr.(*truncbuffer.TruncBuffer); !ok {
1425+
t.Error("expected Stderr to be *truncbuffer.TruncBuffer after assignment")
1426+
}
1427+
}

go/internal/jsonrpc2/jsonrpc2.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ type Client struct {
7272
stopChan chan struct{}
7373
wg sync.WaitGroup
7474
processDone chan struct{} // closed when the underlying process exits
75-
processError error // set before processDone is closed
76-
processErrorMu sync.RWMutex // protects processError
75+
processErrorPtr *error // points to the process error
76+
processErrorMu sync.RWMutex // protects processErrorPtr
7777
onClose func() // called when the read loop exits unexpectedly
7878
}
7979

@@ -92,25 +92,26 @@ func NewClient(stdin io.WriteCloser, stdout io.ReadCloser) *Client {
9292
}
9393

9494
// SetProcessDone sets a channel that will be closed when the process exits,
95-
// and stores the error that should be returned to pending/future requests.
95+
// and stores the error pointer that should be returned to pending/future requests.
96+
// The error is read directly from the pointer after the channel closes, avoiding
97+
// a race between an async goroutine and callers checking the error.
9698
func (c *Client) SetProcessDone(done chan struct{}, errPtr *error) {
9799
c.processDone = done
98-
// Monitor the channel and copy the error when it closes
99-
go func() {
100-
<-done
101-
if errPtr != nil {
102-
c.processErrorMu.Lock()
103-
c.processError = *errPtr
104-
c.processErrorMu.Unlock()
105-
}
106-
}()
100+
c.processErrorMu.Lock()
101+
c.processErrorPtr = errPtr
102+
c.processErrorMu.Unlock()
107103
}
108104

109-
// getProcessError returns the process exit error if the process has exited
105+
// getProcessError returns the process exit error if the process has exited.
106+
// It reads directly from the stored error pointer, which is guaranteed to be
107+
// set before the processDone channel is closed.
110108
func (c *Client) getProcessError() error {
111109
c.processErrorMu.RLock()
112110
defer c.processErrorMu.RUnlock()
113-
return c.processError
111+
if c.processErrorPtr != nil {
112+
return *c.processErrorPtr
113+
}
114+
return nil
114115
}
115116

116117
// Start begins listening for messages in a background goroutine

0 commit comments

Comments
 (0)