Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
682 lines
19 KiB
Go
682 lines
19 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/tutti-os/tutti/packages/agent/daemon/runtimecmd"
|
|
)
|
|
|
|
type localProcessTransport struct{}
|
|
|
|
// managedProcessGroup owns the operating-system process group for one local
|
|
// provider launch. Windows providers can outlive their command shim, so the
|
|
// group must remain attached until the transport has reaped the complete
|
|
// stdout/stderr tree.
|
|
type managedProcessGroup interface {
|
|
terminate() error
|
|
kill() error
|
|
close() error
|
|
}
|
|
|
|
// RunVerifiedExecutable starts a short-lived managed-runtime command from the
|
|
// same verified descriptor or immutable snapshot used by the ACP transport.
|
|
// Keeping preparation and process start in this package prevents callers from
|
|
// reintroducing a pathname gap between identity verification and execution.
|
|
func RunVerifiedExecutable(ctx context.Context, path string, args []string, identity *ExecutableIdentity) ([]byte, error) {
|
|
if identity == nil {
|
|
return nil, errors.New("verified process executable identity is required")
|
|
}
|
|
preparedExecutable, err := prepareProcessExecutable(path, identity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = preparedExecutable.Close() }()
|
|
cmd := newManagedProcessCommand(ctx, preparedExecutable.path, args...)
|
|
if preparedExecutable.file != nil {
|
|
cmd.ExtraFiles = []*os.File{preparedExecutable.file}
|
|
}
|
|
return cmd.CombinedOutput()
|
|
}
|
|
|
|
// RunVerifiedExecutableBounded captures only stdout from a verified short-lived
|
|
// executable and rejects output beyond maxBytes. Stderr is intentionally not
|
|
// returned so provider diagnostics cannot cross a host contract by accident.
|
|
func RunVerifiedExecutableBounded(ctx context.Context, path string, args []string, identity *ExecutableIdentity, maxBytes int) ([]byte, error) {
|
|
if identity == nil {
|
|
return nil, errors.New("verified process executable identity is required")
|
|
}
|
|
if maxBytes <= 0 {
|
|
return nil, errors.New("verified process output limit is required")
|
|
}
|
|
preparedExecutable, err := prepareProcessExecutable(path, identity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = preparedExecutable.Close() }()
|
|
cmd := newManagedProcessCommand(ctx, preparedExecutable.path, args...)
|
|
if preparedExecutable.file != nil {
|
|
cmd.ExtraFiles = []*os.File{preparedExecutable.file}
|
|
}
|
|
output := boundedProcessOutput{limit: maxBytes}
|
|
cmd.Stdout = &output
|
|
cmd.Stderr = io.Discard
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, err
|
|
}
|
|
if output.overflow {
|
|
return nil, errors.New("verified process output exceeded limit")
|
|
}
|
|
return output.bytes, nil
|
|
}
|
|
|
|
// RunVerifiedNodeScriptBounded verifies a fixed Node interpreter and a
|
|
// provider-owned JavaScript file independently. The verified script bytes are
|
|
// supplied on stdin, so execution never depends on reopening a mutable script
|
|
// pathname or on platform-specific npm shims.
|
|
func RunVerifiedNodeScriptBounded(
|
|
ctx context.Context,
|
|
nodePath string,
|
|
scriptPath string,
|
|
args []string,
|
|
nodeIdentity *ExecutableIdentity,
|
|
scriptIdentity *ExecutableIdentity,
|
|
maxBytes int,
|
|
) ([]byte, error) {
|
|
return NewVerifiedNodeScriptRunner("").Run(
|
|
ctx, nodePath, scriptPath, args, nil, nodeIdentity, scriptIdentity, maxBytes,
|
|
)
|
|
}
|
|
|
|
// VerifiedNodeScriptRunner reuses verified Node snapshots when the platform
|
|
// cannot safely execute an already-open interpreter descriptor. snapshotRoot
|
|
// must be daemon-owned private state when reuse across probes is required.
|
|
type VerifiedNodeScriptRunner struct {
|
|
snapshotRoot string
|
|
snapshotMu sync.Mutex
|
|
verifiedSnapshots map[string]*os.File
|
|
}
|
|
|
|
func NewVerifiedNodeScriptRunner(snapshotRoot string) *VerifiedNodeScriptRunner {
|
|
return &VerifiedNodeScriptRunner{snapshotRoot: strings.TrimSpace(snapshotRoot)}
|
|
}
|
|
|
|
func (runner *VerifiedNodeScriptRunner) Close() error {
|
|
if runner == nil {
|
|
return nil
|
|
}
|
|
runner.snapshotMu.Lock()
|
|
defer runner.snapshotMu.Unlock()
|
|
var closeErr error
|
|
for identity, file := range runner.verifiedSnapshots {
|
|
closeErr = errors.Join(closeErr, file.Close())
|
|
delete(runner.verifiedSnapshots, identity)
|
|
}
|
|
return closeErr
|
|
}
|
|
|
|
func (runner *VerifiedNodeScriptRunner) Run(
|
|
ctx context.Context,
|
|
nodePath string,
|
|
scriptPath string,
|
|
args []string,
|
|
extraEnv []string,
|
|
nodeIdentity *ExecutableIdentity,
|
|
scriptIdentity *ExecutableIdentity,
|
|
maxBytes int,
|
|
) ([]byte, error) {
|
|
if nodeIdentity == nil || scriptIdentity == nil {
|
|
return nil, errors.New("verified Node interpreter and script identities are required")
|
|
}
|
|
if maxBytes <= 0 {
|
|
return nil, errors.New("verified process output limit is required")
|
|
}
|
|
script, err := readVerifiedProcessInput(ctx, scriptPath, scriptIdentity, 16<<20)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
preparedNode, err := prepareReusableNodeInterpreter(ctx, runner, nodePath, nodeIdentity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = preparedNode.Close() }()
|
|
nodeArgs := append([]string{"--input-type=commonjs", "-"}, args...)
|
|
cmd := newManagedProcessCommand(ctx, preparedNode.path, nodeArgs...)
|
|
if preparedNode.file != nil {
|
|
cmd.ExtraFiles = []*os.File{preparedNode.file}
|
|
}
|
|
if len(extraEnv) > 0 {
|
|
cmd.Env = append(os.Environ(), extraEnv...)
|
|
}
|
|
cmd.Stdin = bytes.NewReader(script)
|
|
output := boundedProcessOutput{limit: maxBytes}
|
|
cmd.Stdout = &output
|
|
cmd.Stderr = io.Discard
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, err
|
|
}
|
|
if output.overflow {
|
|
return nil, errors.New("verified process output exceeded limit")
|
|
}
|
|
return output.bytes, nil
|
|
}
|
|
|
|
func readVerifiedProcessInput(ctx context.Context, path string, expected *ExecutableIdentity, maxBytes int64) ([]byte, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if !validProcessInputIdentity(expected) || maxBytes <= 0 || expected.SizeBytes > maxBytes {
|
|
return nil, errors.New("verified process input identity is invalid")
|
|
}
|
|
pathInfo, err := os.Lstat(path)
|
|
if err != nil || !pathInfo.Mode().IsRegular() || pathInfo.Mode()&os.ModeSymlink != 0 {
|
|
return nil, errors.New("verified process input is not an ordinary file")
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open verified process input: %w", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
fileInfo, err := file.Stat()
|
|
if err != nil || !fileInfo.Mode().IsRegular() || !os.SameFile(pathInfo, fileInfo) {
|
|
return nil, errors.New("verified process input changed while opening")
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(&contextReader{ctx: ctx, reader: file}, maxBytes+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read verified process input: %w", err)
|
|
}
|
|
if int64(len(data)) != expected.SizeBytes {
|
|
return nil, errors.New("verified process input does not match expected identity")
|
|
}
|
|
digest := sha256.Sum256(data)
|
|
if hex.EncodeToString(digest[:]) != expected.SHA256 {
|
|
return nil, errors.New("verified process input does not match expected identity")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
type contextReader struct {
|
|
ctx context.Context
|
|
reader io.Reader
|
|
}
|
|
|
|
func (reader *contextReader) Read(value []byte) (int, error) {
|
|
if err := reader.ctx.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
count, err := reader.reader.Read(value)
|
|
if err == nil {
|
|
if contextErr := reader.ctx.Err(); contextErr != nil {
|
|
return count, contextErr
|
|
}
|
|
}
|
|
return count, err
|
|
}
|
|
|
|
func validProcessInputIdentity(identity *ExecutableIdentity) bool {
|
|
if identity == nil || identity.SizeBytes <= 0 || len(identity.SHA256) != sha256.Size*2 || identity.SHA256 != strings.ToLower(identity.SHA256) {
|
|
return false
|
|
}
|
|
_, err := hex.DecodeString(identity.SHA256)
|
|
return err == nil
|
|
}
|
|
|
|
type boundedProcessOutput struct {
|
|
bytes []byte
|
|
limit int
|
|
overflow bool
|
|
}
|
|
|
|
func (output *boundedProcessOutput) Write(value []byte) (int, error) {
|
|
remaining := output.limit - len(output.bytes)
|
|
if remaining > 0 {
|
|
count := len(value)
|
|
if count > remaining {
|
|
count = remaining
|
|
}
|
|
output.bytes = append(output.bytes, value[:count]...)
|
|
}
|
|
if len(value) > remaining {
|
|
output.overflow = true
|
|
}
|
|
return len(value), nil
|
|
}
|
|
|
|
type localProcessConnection struct {
|
|
cancel context.CancelFunc
|
|
cmd *exec.Cmd
|
|
processGroup managedProcessGroup
|
|
preparedExecutable *preparedProcessExecutable
|
|
done chan struct{}
|
|
closing chan struct{}
|
|
frames chan ProcessFrame
|
|
stdin io.WriteCloser
|
|
|
|
closeMu sync.Mutex
|
|
closingOnce sync.Once
|
|
sendMu sync.Mutex
|
|
inputOnce sync.Once
|
|
}
|
|
|
|
func NewLocalProcessTransport() ProcessTransport {
|
|
return localProcessTransport{}
|
|
}
|
|
|
|
func (localProcessTransport) Start(ctx context.Context, spec ProcessSpec) (ProcessConnection, error) {
|
|
if len(spec.Command) == 0 || spec.Command[0] == "" {
|
|
return nil, errors.New("process command is required")
|
|
}
|
|
if len(spec.SensitiveInheritedFiles) != 0 {
|
|
return nil, errors.New("sensitive inherited files require the connector process transport")
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
processCtx, cancel := context.WithCancel(context.Background())
|
|
resolver := runtimecmd.Resolver{}
|
|
env := resolver.Env(spec.Env)
|
|
resolvedCommand := resolver.Resolve(spec.Command[0], env)
|
|
logProcessStartEnvDiagnostics(spec, env, resolvedCommand)
|
|
preparedExecutable, err := prepareProcessExecutable(resolvedCommand, spec.ExecutableIdentity)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
started := false
|
|
defer func() {
|
|
if !started {
|
|
_ = preparedExecutable.Close()
|
|
}
|
|
}()
|
|
cmd := newManagedProcessCommand(processCtx, preparedExecutable.path, spec.Command[1:]...)
|
|
if preparedExecutable.file != nil {
|
|
cmd.ExtraFiles = []*os.File{preparedExecutable.file}
|
|
}
|
|
cmd.Env = env
|
|
if cwd := strings.TrimSpace(spec.CWD); cwd != "" {
|
|
cmd.Dir = cwd
|
|
}
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
conn := &localProcessConnection{
|
|
cancel: cancel,
|
|
cmd: cmd,
|
|
done: make(chan struct{}),
|
|
closing: make(chan struct{}),
|
|
frames: make(chan ProcessFrame, 16),
|
|
stdin: stdin,
|
|
}
|
|
// Let os/exec own the stdout/stderr copy goroutines. Wait then guarantees
|
|
// that both writers have consumed their final bytes before it returns,
|
|
// without making process reaping depend on pipe EOF. In particular, this
|
|
// preserves startup-time streaming for long-lived sidecars while keeping a
|
|
// short-lived process's final output ordered before its exit frame.
|
|
cmd.Stdout = processFrameWriter{conn: conn, stdout: true}
|
|
cmd.Stderr = processFrameWriter{conn: conn}
|
|
if err := cmd.Start(); err != nil {
|
|
_ = stdin.Close()
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
processGroup, processGroupErr := attachManagedProcessGroup(cmd)
|
|
if processGroupErr != nil {
|
|
// Process-group attachment is a best-effort hardening layer. Some hosts
|
|
// already place the daemon in a Windows job that does not allow nested
|
|
// assignment; retain the taskkill fallback rather than rejecting an
|
|
// otherwise valid provider launch.
|
|
slog.Warn("agent session process group attach failed",
|
|
"event", "agent_session.process_start.group_attach_failed",
|
|
"provider", spec.Provider,
|
|
"room_id", spec.RoomID,
|
|
"agent_session_id", spec.AgentSessionID,
|
|
"command", commandNameForLog(spec.Command),
|
|
"error", processGroupErr,
|
|
)
|
|
}
|
|
conn.processGroup = processGroup
|
|
conn.preparedExecutable = &preparedExecutable
|
|
started = true
|
|
|
|
go conn.wait()
|
|
return conn, nil
|
|
}
|
|
|
|
func logProcessStartEnvDiagnostics(spec ProcessSpec, env []string, resolvedCommand string) {
|
|
diag := processStartEnvDiagnostics(spec, env)
|
|
slog.Info("agent session process start env diagnostics",
|
|
"event", "agent_session.process_start.env_diagnostics",
|
|
"provider", spec.Provider,
|
|
"room_id", spec.RoomID,
|
|
"agent_session_id", spec.AgentSessionID,
|
|
"cwd", spec.CWD,
|
|
"command", commandNameForLog(spec.Command),
|
|
"resolved_command", resolvedCommand,
|
|
"path_override_count", diag["path_override_count"],
|
|
"path_entry_count", diag["path_entry_count"],
|
|
"path_head", diag["path_head"],
|
|
"path_contains_tutti_bin", diag["path_contains_tutti_bin"],
|
|
"path_contains_app_node_bin", diag["path_contains_app_node_bin"],
|
|
"path_contains_app_npm_bin", diag["path_contains_app_npm_bin"],
|
|
"workspace_env_present", diag["workspace_env_present"],
|
|
"agent_session_env_present", diag["agent_session_env_present"],
|
|
"proxy_env_present", diag["proxy_env_present"],
|
|
"proxy_source", diag["proxy_source"],
|
|
)
|
|
}
|
|
|
|
func processStartEnvDiagnostics(spec ProcessSpec, env []string) map[string]any {
|
|
pathValue := envValueFromList(env, "PATH")
|
|
pathDirs := filepath.SplitList(pathValue)
|
|
appNodeBin := filepath.Dir(envValueFromList(env, "TUTTI_APP_NODE"))
|
|
appNPMBin := filepath.Dir(envValueFromList(env, "TUTTI_APP_NPM"))
|
|
proxyPresent, proxySource := proxyDiagnostics(spec, env)
|
|
return map[string]any{
|
|
"path_override_count": envKeyCount(spec.Env, "PATH"),
|
|
"path_entry_count": len(pathDirs),
|
|
"path_head": pathHeadForLog(pathDirs, 6),
|
|
"path_contains_tutti_bin": pathContainsTuttiBin(pathDirs),
|
|
"path_contains_app_node_bin": appNodeBin != "." && pathContainsDir(pathDirs, appNodeBin),
|
|
"path_contains_app_npm_bin": appNPMBin != "." && pathContainsDir(pathDirs, appNPMBin),
|
|
"workspace_env_present": envHasKey(env, "TUTTI_WORKSPACE_ID"),
|
|
"agent_session_env_present": envHasKey(env, "TUTTI_AGENT_SESSION_ID"),
|
|
"proxy_env_present": proxyPresent,
|
|
"proxy_source": proxySource,
|
|
}
|
|
}
|
|
|
|
// proxyEnvKeys are checked case-insensitively; envValueFromList uses EqualFold
|
|
// so lowercase shell-style spellings match too.
|
|
var proxyEnvKeys = []string{"HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"}
|
|
|
|
// proxyDiagnostics reports whether the spawned agent sees a proxy and where it
|
|
// came from: "env" when the daemon process env or session overrides carry one
|
|
// (user shell/session explicit), "system" when only the injected macOS system
|
|
// proxy supplies it, "none" otherwise.
|
|
func proxyDiagnostics(spec ProcessSpec, env []string) (bool, string) {
|
|
present := false
|
|
for _, key := range proxyEnvKeys {
|
|
if envHasKey(env, key) {
|
|
present = true
|
|
break
|
|
}
|
|
}
|
|
if !present {
|
|
return false, "none"
|
|
}
|
|
processEnv := os.Environ()
|
|
for _, key := range proxyEnvKeys {
|
|
if envHasKey(spec.Env, key) || envHasKey(processEnv, key) {
|
|
return true, "env"
|
|
}
|
|
}
|
|
return true, "system"
|
|
}
|
|
|
|
func commandNameForLog(command []string) string {
|
|
if len(command) == 0 {
|
|
return ""
|
|
}
|
|
return command[0]
|
|
}
|
|
|
|
func pathHeadForLog(dirs []string, limit int) []string {
|
|
if limit <= 0 || len(dirs) == 0 {
|
|
return nil
|
|
}
|
|
if len(dirs) < limit {
|
|
limit = len(dirs)
|
|
}
|
|
head := make([]string, 0, limit)
|
|
for _, dir := range dirs[:limit] {
|
|
if dir = filepath.Clean(dir); dir != "." {
|
|
head = append(head, dir)
|
|
}
|
|
}
|
|
return head
|
|
}
|
|
|
|
func pathContainsTuttiBin(dirs []string) bool {
|
|
for _, dir := range dirs {
|
|
if filepath.Base(filepath.Clean(dir)) == "bin" && filepath.Base(filepath.Dir(filepath.Clean(dir))) == ".tutti" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func pathContainsDir(dirs []string, want string) bool {
|
|
want = filepath.Clean(want)
|
|
for _, dir := range dirs {
|
|
if filepath.Clean(dir) == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func envHasKey(env []string, key string) bool {
|
|
return envValueFromList(env, key) != ""
|
|
}
|
|
|
|
func envKeyCount(env []string, key string) int {
|
|
count := 0
|
|
for _, item := range env {
|
|
candidateKey, _, ok := strings.Cut(item, "=")
|
|
if ok && strings.EqualFold(candidateKey, key) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func envValueFromList(env []string, key string) string {
|
|
for i := len(env) - 1; i >= 0; i-- {
|
|
candidateKey, value, ok := strings.Cut(env[i], "=")
|
|
if ok && strings.EqualFold(candidateKey, key) {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *localProcessConnection) Send(data []byte) error {
|
|
if c == nil || c.stdin == nil {
|
|
return io.ErrClosedPipe
|
|
}
|
|
c.sendMu.Lock()
|
|
defer c.sendMu.Unlock()
|
|
_, err := c.stdin.Write(data)
|
|
return err
|
|
}
|
|
|
|
func (c *localProcessConnection) Recv() (ProcessFrame, error) {
|
|
if c == nil {
|
|
return ProcessFrame{}, io.EOF
|
|
}
|
|
frame, ok := <-c.frames
|
|
if !ok {
|
|
return ProcessFrame{}, io.EOF
|
|
}
|
|
return frame, nil
|
|
}
|
|
|
|
func (c *localProcessConnection) RecvContext(ctx context.Context) (ProcessFrame, error) {
|
|
if c == nil {
|
|
return ProcessFrame{}, io.EOF
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ProcessFrame{}, ctx.Err()
|
|
case frame, ok := <-c.frames:
|
|
if !ok {
|
|
return ProcessFrame{}, io.EOF
|
|
}
|
|
return frame, nil
|
|
}
|
|
}
|
|
|
|
func (c *localProcessConnection) Close() error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
c.closeMu.Lock()
|
|
defer c.closeMu.Unlock()
|
|
if c.waitDone(0) {
|
|
return nil
|
|
}
|
|
c.closingOnce.Do(func() { close(c.closing) })
|
|
return closeLocalProcessAttempt(c.waitDone, c.CloseInput, c.Terminate, c.Kill)
|
|
}
|
|
|
|
func closeLocalProcessAttempt(
|
|
waitDone func(time.Duration) bool,
|
|
closeInput func() error,
|
|
terminate func() error,
|
|
kill func() error,
|
|
) error {
|
|
_ = closeInput()
|
|
if waitDone(250 * time.Millisecond) {
|
|
return nil
|
|
}
|
|
_ = terminate()
|
|
if waitDone(750 * time.Millisecond) {
|
|
return nil
|
|
}
|
|
killErr := kill()
|
|
if waitDone(2 * time.Second) {
|
|
return nil
|
|
}
|
|
return errors.Join(killErr, errors.New("process did not exit after kill"))
|
|
}
|
|
|
|
func (c *localProcessConnection) CloseInput() error {
|
|
if c == nil || c.stdin == nil {
|
|
return nil
|
|
}
|
|
var err error
|
|
c.inputOnce.Do(func() {
|
|
err = c.stdin.Close()
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (c *localProcessConnection) Terminate() error {
|
|
if c == nil || c.cmd == nil || c.cmd.Process == nil {
|
|
return nil
|
|
}
|
|
return terminateManagedProcess(c.cmd, c.processGroup)
|
|
}
|
|
|
|
func (c *localProcessConnection) Kill() error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
if c.cmd == nil || c.cmd.Process == nil {
|
|
c.cancel()
|
|
return nil
|
|
}
|
|
err := killManagedProcessTree(c.cmd, c.processGroup)
|
|
c.cancel()
|
|
return err
|
|
}
|
|
|
|
func (c *localProcessConnection) waitDone(timeout time.Duration) bool {
|
|
if c == nil {
|
|
return true
|
|
}
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-c.done:
|
|
return true
|
|
case <-timer.C:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type processFrameWriter struct {
|
|
conn *localProcessConnection
|
|
stdout bool
|
|
}
|
|
|
|
func (w processFrameWriter) Write(data []byte) (int, error) {
|
|
if len(data) == 0 {
|
|
return 0, nil
|
|
}
|
|
chunk := append([]byte(nil), data...)
|
|
frame := ProcessFrame{}
|
|
if w.stdout {
|
|
frame.Stdout = chunk
|
|
} else {
|
|
frame.Stderr = chunk
|
|
}
|
|
w.conn.sendFrame(frame)
|
|
return len(data), nil
|
|
}
|
|
|
|
func (c *localProcessConnection) wait() {
|
|
defer func() {
|
|
if c.processGroup != nil {
|
|
_ = c.processGroup.close()
|
|
}
|
|
}()
|
|
err := c.cmd.Wait()
|
|
if c.preparedExecutable != nil {
|
|
_ = c.preparedExecutable.Close()
|
|
c.preparedExecutable = nil
|
|
}
|
|
exitCode := 0
|
|
if err != nil {
|
|
exitCode = 1
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
exitCode = exitErr.ExitCode()
|
|
}
|
|
}
|
|
c.sendFrame(ProcessFrame{ExitCode: &exitCode})
|
|
close(c.frames)
|
|
close(c.done)
|
|
}
|
|
|
|
// sendFrame preserves the output ordering guarantee even when Close has
|
|
// started. Once closing is signaled, a send and the close signal are both
|
|
// ready; choosing between them directly would make final stdout/stderr
|
|
// diagnostics disappear nondeterministically. Prefer an available queue slot
|
|
// before observing closing, and make one last non-blocking attempt after the
|
|
// connection starts closing.
|
|
func (c *localProcessConnection) sendFrame(frame ProcessFrame) {
|
|
select {
|
|
case c.frames <- frame:
|
|
return
|
|
default:
|
|
}
|
|
|
|
select {
|
|
case c.frames <- frame:
|
|
return
|
|
case <-c.closing:
|
|
select {
|
|
case c.frames <- frame:
|
|
default:
|
|
}
|
|
}
|
|
}
|