Bound subprocess output teardown
This commit is contained in:
@@ -39,7 +39,7 @@ the agent policy; an intermediate compilation dependency is recorded accurately.
|
|||||||
| 1 | A01; publication assertion portion of A19 | Complete — regression failed before the fix and passed afterward; focused publication and reconciliation tests passed. |
|
| 1 | A01; publication assertion portion of A19 | Complete — regression failed before the fix and passed afterward; focused publication and reconciliation tests passed. |
|
||||||
| 2 | A02, A12: common Git mechanics | Complete — focused Git, candidate, identity, and public build/publication/delivery tests passed; A02 regression evidence follows in Stage 3. |
|
| 2 | A02, A12: common Git mechanics | Complete — focused Git, candidate, identity, and public build/publication/delivery tests passed; A02 regression evidence follows in Stage 3. |
|
||||||
| 3 | A02: source identity regression evidence | Complete — commit, tree, and blob replacement regressions and public publication, build, and delivery cases passed. |
|
| 3 | A02: source identity regression evidence | Complete — commit, tree, and blob replacement regressions and public publication, build, and delivery cases passed. |
|
||||||
| 4 | A03: buffered command teardown | Pending |
|
| 4 | A03: buffered command teardown | Complete — inherited-pipe, cancellation, healthy-command, native build, and publication recovery checks passed. |
|
||||||
| 5 | A03, A18: piped lifetime and queued cancellation | Pending |
|
| 5 | A03, A18: piped lifetime and queued cancellation | Pending |
|
||||||
| 6 | A04 | Pending |
|
| 6 | A04 | Pending |
|
||||||
| 7 | A05 | Pending |
|
| 7 | A05 | Pending |
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package build
|
package build
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -9,7 +10,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"go.maximumdirect.net/releasekit/internal/candidate"
|
"go.maximumdirect.net/releasekit/internal/candidate"
|
||||||
"go.maximumdirect.net/releasekit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
"go.maximumdirect.net/releasekit/internal/gotool"
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
"go.maximumdirect.net/releasekit/internal/tempdir"
|
"go.maximumdirect.net/releasekit/internal/tempdir"
|
||||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
||||||
@@ -227,17 +228,14 @@ func hashArtifact(artifact *Artifact) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
|
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
|
||||||
command := exec.CommandContext(ctx, binaryPath, "version")
|
var output bytes.Buffer
|
||||||
output, err := command.CombinedOutput()
|
err := process.Run(ctx, process.Invocation{Args: []string{binaryPath, "version"}, Stdout: &output, Stderr: &output})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(output.String()))
|
||||||
return fmt.Errorf("run native version check: %w", ctx.Err())
|
|
||||||
}
|
|
||||||
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(string(output)))
|
|
||||||
}
|
}
|
||||||
want := binaryName + " " + version + "\n"
|
want := binaryName + " " + version + "\n"
|
||||||
if string(output) != want {
|
if output.String() != want {
|
||||||
return fmt.Errorf("native version output = %q, want %q", output, want)
|
return fmt.Errorf("native version output = %q, want %q", output.String(), want)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Invocation describes one child process. UnsetEnv removes inherited values
|
// Invocation describes one child process. UnsetEnv removes inherited values
|
||||||
@@ -135,6 +136,7 @@ func newCommand(ctx context.Context, invocation Invocation) (*exec.Cmd, error) {
|
|||||||
}
|
}
|
||||||
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
|
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
|
||||||
command.Dir = invocation.Dir
|
command.Dir = invocation.Dir
|
||||||
|
command.WaitDelay = time.Second
|
||||||
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
|
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
|
||||||
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
|
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -119,6 +120,68 @@ func TestRunReturnsCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunBoundsInheritedOutputPipes(t *testing.T) {
|
||||||
|
for _, mode := range []string{"cancel-descendant", "exit-descendant"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
ready := filepath.Join(t.TempDir(), "descendant")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
var output bytes.Buffer
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
result <- Run(ctx, Invocation{
|
||||||
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", mode},
|
||||||
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1", "RELEASEKIT_DESCENDANT_READY": ready},
|
||||||
|
Stdout: &output, Stderr: &output,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
deadline := time.After(10 * time.Second)
|
||||||
|
var pid int
|
||||||
|
for pid == 0 {
|
||||||
|
data, err := os.ReadFile(ready)
|
||||||
|
if 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 process, err := os.FindProcess(pid); err == nil {
|
||||||
|
_ = process.Kill()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if mode == "cancel-descendant" {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
if mode == "cancel-descendant" && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled command error = %v", err)
|
||||||
|
}
|
||||||
|
if mode == "exit-descendant" && !errors.Is(err, exec.ErrWaitDelay) {
|
||||||
|
t.Fatalf("exited command error = %v, want pipe teardown error", err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("command did not release inherited output pipes")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDoesNotLimitHealthyCommandLifetime(t *testing.T) {
|
||||||
|
err := Run(context.Background(), Invocation{
|
||||||
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "slow-success"},
|
||||||
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("healthy command failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStartPipedExchangesBeforeInputEOFAndPreservesSettings(t *testing.T) {
|
func TestStartPipedExchangesBeforeInputEOFAndPreservesSettings(t *testing.T) {
|
||||||
workdir := filepath.Join(t.TempDir(), "piped directory")
|
workdir := filepath.Join(t.TempDir(), "piped directory")
|
||||||
if err := os.Mkdir(workdir, 0o700); err != nil {
|
if err := os.Mkdir(workdir, 0o700); err != nil {
|
||||||
@@ -279,11 +342,39 @@ func TestProcessHelper(t *testing.T) {
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
case "exit":
|
case "exit":
|
||||||
os.Exit(7)
|
os.Exit(7)
|
||||||
|
case "slow-success":
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
os.Exit(0)
|
||||||
case "wait":
|
case "wait":
|
||||||
fmt.Fprintln(os.Stdout, "started")
|
fmt.Fprintln(os.Stdout, "started")
|
||||||
for {
|
for {
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
}
|
}
|
||||||
|
case "cancel-descendant", "exit-descendant":
|
||||||
|
child := exec.Command(os.Args[0], "-test.run=TestProcessHelper", "--", "unused", "descendant")
|
||||||
|
child.Stdout, child.Stderr = os.Stdout, os.Stderr
|
||||||
|
if err := child.Start(); err != nil {
|
||||||
|
os.Exit(14)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(os.Getenv("RELEASEKIT_DESCENDANT_READY")); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if mode == "exit-descendant" {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
case "descendant":
|
||||||
|
if err := os.WriteFile(os.Getenv("RELEASEKIT_DESCENDANT_READY"), []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
|
||||||
|
os.Exit(15)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
case "exchange":
|
case "exchange":
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
request, err := reader.ReadString('\n')
|
request, err := reader.ReadString('\n')
|
||||||
|
|||||||
Reference in New Issue
Block a user