// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package tentacle import ( "bufio" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "sync" "syscall" "time" ) func (session *Session) run() { var cleanupError error starting := true ready := func() { if starting { starting = false <-session.manager.starting close(session.ready) } } defer func() { session.cancel() session.mutex.Lock() if cleanupError != nil { session.fail_locked(cleanupError) // Even a requested disable must report incomplete containment. session.state, session.failure = Failed, cleanupError } else if session.state == Stopping { session.state = Stopped } else if session.state != Failed { session.fail_locked(ErrExited) } session.mutex.Unlock() session.callbacks.Wait() // A retained status/session handle must not retain unsent argument text. for len(session.outbound) != 0 { <-session.outbound } ready() session.manager.mutex.Lock() if cleanupError != nil { // Keep the admission slot and reject further starts. Do not claim // capacity was reclaimed when owned processes may remain alive. session.manager.cleanupError = cleanupError } else { delete(session.manager.sessions, session.spec.ID) } close(session.done) session.manager.mutex.Unlock() }() select { case <-session.stop: return case <-session.ctx.Done(): session.fail(session.ctx.Err()) return default: } // Use owned files instead of exec's copier goroutines. Closing our ends // unblocks workers even when a descendant retains an inherited pipe. var pipes []*os.File defer func() { for _, pipe := range pipes { pipe.Close() } }() newPipe := func() (*os.File, *os.File, error) { reader, writer, err := os.Pipe() if err == nil { pipes = append(pipes, reader, writer) } return reader, writer, err } childInput, input, err := newPipe() if err != nil { session.fail(err) return } output, childOutput, err := newPipe() if err != nil { session.fail(err) return } diagnostics, childDiagnostics, err := newPipe() if err != nil { session.fail(err) return } command := exec.Command(session.spec.Program[0], session.spec.Program[1:]...) command.Dir = session.spec.Directory command.Stdin, command.Stdout, command.Stderr = childInput, childOutput, childDiagnostics command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} var group *cgroup if containment := session.manager.containment; containment != nil { group, err = create_cgroup(filepath.Join(containment.group.path, session.identity), containment.limits.PerSession, 0) if err != nil { session.fail(err) return } defer func() { cleanupError = group.kill_and_remove(containment.limits.CleanupTimeout) }() // clone3(CLONE_INTO_CGROUP) applies limits before the child executes. // Moving an already-running PID would leave a fork/allocation race. command.SysProcAttr.UseCgroupFD = true command.SysProcAttr.CgroupFD = int(group.file.Fd()) } var pid int var exited <-chan struct{} if session.manager.delegation != nil { var closeLaunch func() pid, exited, closeLaunch, err = session.manager.delegation.launch(session.ctx, session.spec, childInput, childOutput, childDiagnostics, group.file) if err == nil { defer closeLaunch() } } else { err = command.Start() if err == nil { pid = command.Process.Pid done := make(chan struct{}) exited = done go func() { _ = command.Wait(); close(done) }() } } if err != nil { // Do not retain the command line, environment or child-supplied text. if group != nil { var errno syscall.Errno if errors.As(err, &errno) { session.fail(fmt.Errorf("%w: child placement/exec: %w", ErrContainment, errno)) } else { session.fail(ErrContainment) } } else { session.fail(ErrExited) } return } childInput.Close() childOutput.Close() childDiagnostics.Close() var workers sync.WaitGroup workers.Add(3) go func() { defer workers.Done(); session.write_requests(input) }() go func() { defer workers.Done(); session.read_replies(output) }() go func() { defer workers.Done(); session.drain_diagnostics(diagnostics) }() defer func() { session.cancel() if group != nil { _ = write_control(group.path, "cgroup.kill", "1") if command.Process != nil { _ = command.Process.Kill() } } else { // Explicit trusted-fixture mode only; detached descendants escape. _ = syscall.Kill(-pid, syscall.SIGKILL) } input.Close() output.Close() diagnostics.Close() <-exited workers.Wait() }() session.mutex.Lock() var initialized <-chan Result if session.state == Starting && session.ctx.Err() == nil { apis := []string{"commands"} if session.spec.Presentation { apis = append(apis, "presentation") } if session.spec.Interaction { apis = append(apis, "interaction") } initialized, err = session.request_locked(session.ctx, "initialize", Initialization{ Protocol: Version{Major: 1, Minor: 0}, Session: session.identity, APIs: apis, Capabilities: apis, }, session.manager.limits.StartupTimeout) } else { err = ErrUnavailable } session.mutex.Unlock() if err != nil { session.fail(err) return } select { case result := <-initialized: if result.Err != nil { session.fail(result.Err) return } var registration Registration if decode_strict(result.Value, ®istration) != nil || validate_registration(registration, session.identity) != nil { session.fail(ErrProtocol) return } for _, command := range registration.Commands { if command.Presentation && !session.spec.Presentation || command.Interaction && (!command.Presentation || !session.spec.Interaction) { session.fail(ErrProtocol) return } } session.mutex.Lock() if session.state == Starting && session.ctx.Err() == nil { session.commands = registration.Commands session.state = Ready } session.mutex.Unlock() ready() case <-session.ctx.Done(): session.fail(session.ctx.Err()) return case <-exited: session.fail(ErrExited) return case <-session.stop: session.wait_shutdown(exited) return } select { case <-session.ctx.Done(): session.fail(session.ctx.Err()) case <-exited: session.fail(ErrExited) case <-session.stop: session.wait_shutdown(exited) } } func (session *Session) wait_shutdown(exited <-chan struct{}) { timer := time.NewTimer(session.manager.limits.ShutdownTimeout) defer timer.Stop() select { case <-exited: case <-timer.C: case <-session.ctx.Done(): } } func (session *Session) write_requests(writer io.Writer) { for { select { case <-session.ctx.Done(): return case item := <-session.outbound: if item.id != "" { session.mutex.Lock() call := session.pending[item.id] eligible := call != nil && call.ctx.Err() == nil session.mutex.Unlock() if !eligible { continue } } if _, err := writer.Write(item.frame); err != nil { session.fail(ErrExited) return } } } } func (session *Session) read_replies(reader io.Reader) { buffer := bufio.NewReaderSize(reader, 4096) for session.ctx.Err() == nil { frame, err := read_frame(buffer) if err != nil { if err == io.EOF { err = ErrExited } session.fail(err) return } reply, err := decode_response(frame) if err != nil { session.fail(err) return } session.accept_reply(reply) } } func (session *Session) drain_diagnostics(reader io.Reader) { count, err := io.Copy(io.Discard, io.LimitReader(reader, MaxDiagnosticBytes+1)) if count > MaxDiagnosticBytes { session.fail(ErrLimit) } else if err != nil && session.ctx.Err() == nil { session.fail(ErrExited) } }