Files
kandev/apps/backend/internal/common/subproc/git_lifecycle.go
T
2026-09-13 21:57:54 +01:00

337 lines
7.9 KiB
Go

package subproc
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"sync"
"time"
)
const (
gitPipeWaitDelay = 500 * time.Millisecond
gitCleanupLimit = 2 * time.Second
)
// runManagedGit owns the complete Start-to-Wait lifetime of a Git command.
// The command must already have its final environment and output wiring.
func runManagedGit(ctx context.Context, cmd *exec.Cmd) error {
process, err := startManagedGit(ctx, cmd)
if err != nil {
return err
}
return process.wait()
}
func runManagedGitOutput(ctx context.Context, cmd *exec.Cmd) ([]byte, error) {
if cmd == nil {
return nil, errors.New("nil Git command")
}
if cmd.Stdout != nil {
return nil, errors.New("exec: Stdout already set")
}
var stdout synchronizedBuffer
cmd.Stdout = &stdout
var stderr *synchronizedBuffer
if cmd.Stderr == nil {
stderr = &synchronizedBuffer{}
cmd.Stderr = stderr
}
process, err := startManagedGit(ctx, cmd)
if err != nil {
return nil, err
}
runErr := process.wait()
if runErr != nil && stderr != nil {
attachGitStderr(runErr, stderr.Bytes())
}
return stdout.Bytes(), runErr
}
func runManagedGitCombinedOutput(ctx context.Context, cmd *exec.Cmd) ([]byte, error) {
if cmd == nil {
return nil, errors.New("nil Git command")
}
if cmd.Stdout != nil {
return nil, errors.New("exec: Stdout already set")
}
if cmd.Stderr != nil {
return nil, errors.New("exec: Stderr already set")
}
var output synchronizedBuffer
cmd.Stdout = &output
cmd.Stderr = &output
process, err := startManagedGit(ctx, cmd)
if err != nil {
return nil, err
}
runErr := process.wait()
return output.Bytes(), runErr
}
// GitStream owns a classified Git slot and a streaming stdout pipe. The
// caller must consume Stdout and call Wait exactly once.
type GitStream struct {
process *managedGitProcess
stdout io.ReadCloser
execErr func() error
cancel context.CancelFunc
release func()
waitOnce sync.Once
waitErr error
execDone error
}
// Stdout returns the command-owned stdout pipe. It is valid until Wait or
// Close returns.
func (s *GitStream) Stdout() io.ReadCloser {
if s == nil {
return nil
}
return s.stdout
}
// Close closes the streaming stdout pipe. Wait must still be called to reap
// the process and release the admission slot.
func (s *GitStream) Close() error {
if s == nil || s.process == nil {
return nil
}
s.process.closePipes()
s.process.mu.Lock()
defer s.process.mu.Unlock()
return s.process.closeErr
}
// Wait reaps the command, closes its lifecycle resources, and releases its
// Git admission slot. The second result is the post-admission execution
// context error, if any.
func (s *GitStream) Wait() (error, error) {
if s == nil {
return errors.New("nil Git stream"), nil
}
s.waitOnce.Do(func() {
s.waitErr = s.process.wait()
s.execDone = s.execErr()
s.cancel()
s.release()
})
return s.waitErr, s.execDone
}
// StartGitStreamAfterAcquire builds and starts a streaming Git command after
// acquiring its class slot. The slot remains held until GitStream.Wait.
func StartGitStreamAfterAcquire(
ctx context.Context,
class GitWorkClass,
execTimeout time.Duration,
build func(execCtx context.Context) *exec.Cmd,
) (*GitStream, error, error) {
release, err := gitThrottle.AcquireClass(ctx, class)
if err != nil {
return nil, wrapAdmissionError(err), nil
}
execCtx, cancel := withGitExecTimeout(ctx, execTimeout)
cmd := build(execCtx)
if cmd == nil {
cancel()
release()
return nil, errors.New("Git command builder returned nil"), execCtx.Err()
}
PrepareGitCommand(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
release()
return nil, fmt.Errorf("attach Git stdout pipe: %w", err), execCtx.Err()
}
process, err := startManagedGit(execCtx, cmd, stdout)
if err != nil {
_ = stdout.Close()
cancel()
release()
return nil, err, execCtx.Err()
}
return &GitStream{
process: process,
stdout: stdout,
execErr: execCtx.Err,
cancel: cancel,
release: release,
}, nil, nil
}
type managedGitProcess struct {
cmd *exec.Cmd
lifecycle gitLifecycleHandle
pipes []io.Closer
finished chan struct{}
cancelDone chan struct{}
closeOnce sync.Once
waitOnce sync.Once
cancelOnce sync.Once
releaseOnce sync.Once
mu sync.Mutex
waitErr error
cancelErr error
closeErr error
releaseErr error
}
func startManagedGit(ctx context.Context, cmd *exec.Cmd, pipes ...io.Closer) (*managedGitProcess, error) {
if cmd == nil {
return nil, errors.New("nil Git command")
}
if err := ctx.Err(); err != nil {
return nil, err
}
PrepareGitCommand(cmd)
if err := prepareGitLifecycleCommand(cmd); err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
_ = closeGitPipes(pipes...)
return nil, err
}
lifecycle, err := installGitLifecycle(cmd)
if err != nil {
closeErr := closeGitPipes(pipes...)
reapErr := abortStartedGit(cmd)
return nil, errors.Join(fmt.Errorf("install Git lifecycle: %w", err), closeErr, reapErr)
}
process := &managedGitProcess{
cmd: cmd,
lifecycle: lifecycle,
pipes: append([]io.Closer(nil), pipes...),
finished: make(chan struct{}),
cancelDone: make(chan struct{}),
}
go process.watchCancellation(ctx)
return process, nil
}
func (p *managedGitProcess) watchCancellation(ctx context.Context) {
defer close(p.cancelDone)
select {
case <-ctx.Done():
p.cancelOwnedProcess()
case <-p.finished:
// Both channels can be ready when cancellation races with process exit.
// Check the context after observing process completion so a canceled
// command still cleans up descendants that outlive its parent.
if ctx.Err() != nil {
p.cancelOwnedProcess()
}
}
}
func (p *managedGitProcess) cancelOwnedProcess() {
p.cancelOnce.Do(func() {
cancelErr := cancelGitLifecycle(p.lifecycle)
p.mu.Lock()
p.cancelErr = errors.Join(p.cancelErr, cancelErr)
p.mu.Unlock()
p.closePipes()
p.releaseLifecycle()
})
}
func (p *managedGitProcess) wait() error {
p.waitOnce.Do(func() {
waitErr := p.cmd.Wait()
p.mu.Lock()
p.waitErr = waitErr
p.mu.Unlock()
close(p.finished)
// The cancellation watcher may still be finishing a platform cleanup
// operation. Waiting here keeps the returned error and admission release
// ordered after owned cleanup, including when the process exits by signal.
cleanupComplete := false
select {
case <-p.cancelDone:
cleanupComplete = true
case <-time.After(gitCleanupLimit):
p.mu.Lock()
p.cancelErr = errors.Join(p.cancelErr, errors.New("Git cancellation cleanup timed out"))
p.mu.Unlock()
}
p.closePipes()
if cleanupComplete {
p.releaseLifecycle()
}
})
p.mu.Lock()
defer p.mu.Unlock()
return errors.Join(p.waitErr, p.cancelErr, p.closeErr, p.releaseErr)
}
func (p *managedGitProcess) releaseLifecycle() {
p.releaseOnce.Do(func() {
releaseErr := releaseGitLifecycle(p.lifecycle)
p.mu.Lock()
p.releaseErr = errors.Join(p.releaseErr, releaseErr)
p.mu.Unlock()
})
}
func (p *managedGitProcess) closePipes() {
p.closeOnce.Do(func() {
var err error
for _, pipe := range p.pipes {
if pipe != nil {
err = errors.Join(err, pipe.Close())
}
}
p.mu.Lock()
p.closeErr = err
p.mu.Unlock()
})
}
type synchronizedBuffer struct {
mu sync.Mutex
buffer bytes.Buffer
}
func (b *synchronizedBuffer) Write(data []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.Write(data)
}
func (b *synchronizedBuffer) Bytes() []byte {
b.mu.Lock()
defer b.mu.Unlock()
return append([]byte(nil), b.buffer.Bytes()...)
}
func attachGitStderr(err error, stderr []byte) {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitErr.Stderr = append([]byte(nil), stderr...)
}
}
func setGitWaitDelay(cmd *exec.Cmd) {
if cmd.WaitDelay <= 0 || cmd.WaitDelay > gitPipeWaitDelay {
cmd.WaitDelay = gitPipeWaitDelay
}
}
func closeGitPipes(pipes ...io.Closer) error {
var err error
for _, pipe := range pipes {
if pipe != nil {
err = errors.Join(err, pipe.Close())
}
}
return err
}