Close piped processes on cancellation

This commit is contained in:
2026-11-02 03:16:36 +00:00
parent 9c02ed769b
commit 595745c7b1
6 changed files with 150 additions and 1 deletions

View File

@@ -51,6 +51,9 @@ func (reader *BlobReader) Read(ctx context.Context, objectID string) ([]byte, er
reader.mu.Lock()
defer reader.mu.Unlock()
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
}
if reader.terminal != nil {
return nil, reader.terminal
}

View File

@@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
@@ -156,6 +157,46 @@ exec sleep 60`)
}
}
func TestBlobReaderCanceledQueuedReadKeepsSessionUsable(t *testing.T) {
fixture := newRemoteFixture(t)
object := gitText(t, fixture.work, "rev-parse", "HEAD:source.txt")
reader := NewBlobReader(t.Context(), fixture.work)
defer reader.Close()
if body, err := reader.Read(t.Context(), object); err != nil || string(body) != "candidate" {
t.Fatalf("first read = %q, %v", body, err)
}
reader.mu.Lock()
ctx, cancel := context.WithCancel(t.Context())
observed := make(chan struct{})
queued := &observedContext{Context: ctx, observed: observed}
result := make(chan error, 1)
go func() {
_, err := reader.Read(queued, object)
result <- err
}()
<-observed
cancel()
reader.mu.Unlock()
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatalf("queued read error = %v", err)
}
if body, err := reader.Read(t.Context(), object); err != nil || string(body) != "candidate" {
t.Fatalf("later read = %q, %v", body, err)
}
}
type observedContext struct {
context.Context
once sync.Once
observed chan struct{}
}
func (ctx *observedContext) Err() error {
err := ctx.Context.Err()
ctx.once.Do(func() { close(ctx.observed) })
return err
}
func TestBlobReaderPreservesGitFailureAndStderr(t *testing.T) {
installFakeGit(t, `printf 'fixture Git failure\n' >&2
exit 23`)

View File

@@ -55,6 +55,8 @@ type PipedProcess struct {
stdout io.ReadCloser
parent context.Context
cancel context.CancelFunc
stopIO func() bool
ioDone <-chan struct{}
waitOnce sync.Once
waitErr error
}
@@ -80,6 +82,9 @@ func (child *PipedProcess) Cancel() {
func (child *PipedProcess) Wait() error {
child.waitOnce.Do(func() {
err := child.command.Wait()
if !child.stopIO() {
<-child.ioDone
}
child.cancel()
if err == nil {
return
@@ -121,12 +126,20 @@ func StartPiped(ctx context.Context, invocation Invocation) (*PipedProcess, erro
cancel()
return nil, fmt.Errorf("start %q: %w", invocation.Args[0], err)
}
done := make(chan struct{})
stopIO := context.AfterFunc(childContext, func() {
defer close(done)
_ = stdin.Close()
_ = stdout.Close()
})
return &PipedProcess{
command: command,
stdin: stdin,
stdout: stdout,
parent: ctx,
cancel: cancel,
stopIO: stopIO,
ioDone: done,
}, nil
}

View File

@@ -260,6 +260,52 @@ func TestStartPipedCancellationAfterReadinessReapsChild(t *testing.T) {
}
}
func TestStartPipedCancellationInterruptsInheritedOutputDrain(t *testing.T) {
ready := filepath.Join(t.TempDir(), "descendant")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
child, err := StartPiped(ctx, Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit-descendant"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1", "RELEASEKIT_DESCENDANT_READY": ready},
})
if err != nil {
t.Fatal(err)
}
_ = child.Stdin().Close()
drained := make(chan error, 1)
go func() {
_, err := io.Copy(io.Discard, child.Stdout())
drained <- err
}()
deadline := time.After(10 * time.Second)
var pid int
for pid == 0 {
if data, err := os.ReadFile(ready); err == nil {
pid, _ = strconv.Atoi(string(data))
}
select {
case <-deadline:
t.Fatal("descendant did not become ready")
default:
time.Sleep(10 * time.Millisecond)
}
}
t.Cleanup(func() {
if descendant, err := os.FindProcess(pid); err == nil {
_ = descendant.Kill()
}
})
cancel()
select {
case <-drained:
case <-time.After(10 * time.Second):
t.Fatal("inherited stdout did not close after cancellation")
}
if err := child.Wait(); err != nil && !errors.Is(err, context.Canceled) {
t.Fatalf("wait after cancellation: %v", err)
}
}
func TestStartPipedCancelDoesNotCancelParentContext(t *testing.T) {
ctx := context.Background()
child, err := StartPiped(ctx, Invocation{