aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal/outcome/process.go
diff options
context:
space:
mode:
authorOphestra <cat@gensokyo.uk>2025-10-29 04:32:43 +0900
committerOphestra <cat@gensokyo.uk>2025-10-29 04:33:13 +0900
commita0b4e47acc228e48165966d8e139277b8f6f450e (patch)
treed363a6781536ce946aa445271ca1d3df587e6031 /internal/outcome/process.go
parenta52f7038e5a607dd0901244abb791c96deaf9c2d (diff)
internal/outcome: rename from app
This is less ambiguous, and more accurately describes the purpose of the package. Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'internal/outcome/process.go')
-rw-r--r--internal/outcome/process.go321
1 files changed, 321 insertions, 0 deletions
diff --git a/internal/outcome/process.go b/internal/outcome/process.go
new file mode 100644
index 00000000..d96c7efa
--- /dev/null
+++ b/internal/outcome/process.go
@@ -0,0 +1,321 @@
+package outcome
+
+import (
+ "context"
+ "encoding/gob"
+ "errors"
+ "log"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "hakurei.app/container"
+ "hakurei.app/container/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal"
+ "hakurei.app/internal/state"
+ "hakurei.app/message"
+ "hakurei.app/system"
+)
+
+// Duration to wait for shim to exit on top of container WaitDelay.
+const shimWaitTimeout = 5 * time.Second
+
+// mainState holds persistent state bound to outcome.main.
+type mainState struct {
+ // done is whether beforeExit has been called already.
+ done bool
+
+ // Time is the exact point in time where the process was created.
+ // Location must be set to UTC.
+ //
+ // Time is nil if no process was ever created.
+ Time *time.Time
+
+ store state.Store
+ cancel context.CancelFunc
+ cmd *exec.Cmd
+ cmdWait chan error
+
+ k *outcome
+ message.Msg
+ uintptr
+}
+
+const (
+ // mainNeedsRevert indicates the call to Commit has succeeded.
+ mainNeedsRevert uintptr = 1 << iota
+ // mainNeedsDestroy indicates the instance state entry is present in the store.
+ mainNeedsDestroy
+)
+
+// beforeExit must be called immediately before a call to [os.Exit].
+func (ms mainState) beforeExit(isFault bool) {
+ if ms.done {
+ panic("attempting to call beforeExit twice")
+ }
+ ms.done = true
+ defer ms.BeforeExit()
+
+ if isFault && ms.cancel != nil {
+ ms.cancel()
+ }
+
+ var hasErr bool
+ // updates hasErr but does not terminate
+ perror := func(err error, message string) {
+ hasErr = true
+ printMessageError("cannot "+message+":", err)
+ }
+ exitCode := 1
+ defer func() {
+ if hasErr {
+ os.Exit(exitCode)
+ }
+ }()
+
+ // this also handles wait for a non-fault termination
+ if ms.cmd != nil && ms.cmdWait != nil {
+ waitDone := make(chan struct{})
+
+ // this ties waitDone to ctx with the additional compensated timeout duration
+ go func() { <-ms.k.ctx.Done(); time.Sleep(ms.k.state.Shim.WaitDelay + shimWaitTimeout); close(waitDone) }()
+
+ select {
+ case err := <-ms.cmdWait:
+ wstatus, ok := ms.cmd.ProcessState.Sys().(syscall.WaitStatus)
+ if ok {
+ if v := wstatus.ExitStatus(); v != 0 {
+ hasErr = true
+ exitCode = v
+ }
+ }
+
+ if ms.IsVerbose() {
+ if !ok {
+ if err != nil {
+ ms.Verbosef("wait: %v", err)
+ }
+ } else {
+ switch {
+ case wstatus.Exited():
+ ms.Verbosef("process %d exited with code %d", ms.cmd.Process.Pid, wstatus.ExitStatus())
+
+ case wstatus.CoreDump():
+ ms.Verbosef("process %d dumped core", ms.cmd.Process.Pid)
+
+ case wstatus.Signaled():
+ ms.Verbosef("process %d got %s", ms.cmd.Process.Pid, wstatus.Signal())
+
+ default:
+ ms.Verbosef("process %d exited with status %#x", ms.cmd.Process.Pid, wstatus)
+ }
+ }
+ }
+
+ case <-waitDone:
+ ms.Resume()
+ // this is only reachable when shim did not exit within shimWaitTimeout, after its WaitDelay has elapsed.
+ // This is different from the container failing to terminate within its timeout period, as that is enforced
+ // by the shim. This path is instead reached when there is a lockup in shim preventing it from completing.
+ log.Printf("process %d did not terminate", ms.cmd.Process.Pid)
+ }
+
+ ms.Resume()
+ }
+
+ if ms.uintptr&mainNeedsRevert != 0 {
+ if ok, err := ms.store.Do(ms.k.state.identity.unwrap(), func(c state.Cursor) {
+ if ms.uintptr&mainNeedsDestroy != 0 {
+ if err := c.Destroy(ms.k.state.id.unwrap()); err != nil {
+ perror(err, "destroy state entry")
+ }
+ }
+
+ var rt hst.Enablement
+ if states, err := c.Load(); err != nil {
+ // it is impossible to continue from this point;
+ // revert per-process state here to limit damage
+ ec := system.Process
+ if revertErr := ms.k.sys.Revert((*system.Criteria)(&ec)); revertErr != nil {
+ var joinError interface {
+ Unwrap() []error
+ error
+ }
+ if !errors.As(revertErr, &joinError) || joinError == nil {
+ perror(revertErr, "revert system setup")
+ } else {
+ for _, v := range joinError.Unwrap() {
+ perror(v, "revert system setup step")
+ }
+ }
+ }
+ perror(err, "load instance states")
+ } else {
+ ec := system.Process
+ if l := len(states); l == 0 {
+ ec |= system.User
+ } else {
+ ms.Verbosef("found %d instances, cleaning up without user-scoped operations", l)
+ }
+
+ // accumulate enablements of remaining launchers
+ for i, s := range states {
+ if s.Config != nil {
+ rt |= s.Config.Enablements.Unwrap()
+ } else {
+ log.Printf("state entry %d does not contain config", i)
+ }
+ }
+
+ ec |= rt ^ (hst.EWayland | hst.EX11 | hst.EDBus | hst.EPulse)
+ if ms.IsVerbose() {
+ if ec > 0 {
+ ms.Verbose("reverting operations scope", system.TypeString(ec))
+ }
+ }
+
+ if err = ms.k.sys.Revert((*system.Criteria)(&ec)); err != nil {
+ perror(err, "revert system setup")
+ }
+ }
+ }); err != nil {
+ if ok {
+ perror(err, "unlock state store")
+ } else {
+ perror(err, "open state store")
+ }
+ }
+ } else if ms.uintptr&mainNeedsDestroy != 0 {
+ panic("unreachable")
+ }
+}
+
+// fatal calls printMessageError, performs necessary cleanup, followed by a call to [os.Exit](1).
+func (ms mainState) fatal(fallback string, ferr error) {
+ printMessageError(fallback, ferr)
+ ms.beforeExit(true)
+ os.Exit(1)
+}
+
+// main carries out outcome and terminates. main does not return.
+func (k *outcome) main(msg message.Msg) {
+ if !k.active.CompareAndSwap(false, true) {
+ panic("outcome: attempted to run twice")
+ }
+
+ if k.ctx == nil || k.sys == nil || k.state == nil {
+ panic("outcome: did not finalise")
+ }
+
+ // read comp value early for early failure
+ hsuPath := internal.MustHsuPath()
+
+ // ms.beforeExit required beyond this point
+ ms := &mainState{Msg: msg, k: k}
+
+ if err := k.sys.Commit(); err != nil {
+ ms.fatal("cannot commit system setup:", err)
+ }
+ ms.uintptr |= mainNeedsRevert
+ ms.store = state.NewMulti(msg, k.state.sc.RunDirPath)
+
+ ctx, cancel := context.WithCancel(k.ctx)
+ defer cancel()
+ ms.cancel = cancel
+
+ ms.cmd = exec.CommandContext(ctx, hsuPath.String())
+ ms.cmd.Stdin, ms.cmd.Stdout, ms.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
+ ms.cmd.Dir = fhs.Root // container init enters final working directory
+ // shim runs in the same session as monitor; see shim.go for behaviour
+ ms.cmd.Cancel = func() error { return ms.cmd.Process.Signal(syscall.SIGCONT) }
+
+ var e *gob.Encoder
+ if fd, encoder, err := container.Setup(&ms.cmd.ExtraFiles); err != nil {
+ ms.fatal("cannot create shim setup pipe:", err)
+ } else {
+ e = encoder
+ ms.cmd.Env = []string{
+ // passed through to shim by hsu
+ shimEnv + "=" + strconv.Itoa(fd),
+ // interpreted by hsu
+ "HAKUREI_IDENTITY=" + k.state.identity.String(),
+ }
+ }
+
+ if len(k.supp) > 0 {
+ msg.Verbosef("attaching supplementary group ids %s", k.supp)
+ // interpreted by hsu
+ ms.cmd.Env = append(ms.cmd.Env, "HAKUREI_GROUPS="+strings.Join(k.supp, " "))
+ }
+
+ msg.Verbosef("setuid helper at %s", hsuPath)
+ msg.Suspend()
+ if err := ms.cmd.Start(); err != nil {
+ ms.fatal("cannot start setuid wrapper:", err)
+ }
+ startTime := time.Now().UTC()
+ ms.cmdWait = make(chan error, 1)
+ // this ties context back to the life of the process
+ go func() { ms.cmdWait <- ms.cmd.Wait(); cancel() }()
+ ms.Time = &startTime
+
+ // unfortunately the I/O here cannot be directly canceled;
+ // the cancellation path leads to fatal in this case so that is fine
+ select {
+ case err := <-func() (setupErr chan error) {
+ setupErr = make(chan error, 1)
+ go func() { setupErr <- e.Encode(k.state) }()
+ return
+ }():
+ if err != nil {
+ msg.Resume()
+ ms.fatal("cannot transmit shim config:", err)
+ }
+
+ case <-ctx.Done():
+ msg.Resume()
+ ms.fatal("shim context canceled:", newWithMessageError("shim setup canceled", ctx.Err()))
+ }
+
+ // shim accepted setup payload, create process state
+ if ok, err := ms.store.Do(k.state.identity.unwrap(), func(c state.Cursor) {
+ if err := c.Save(&hst.State{
+ ID: k.state.id.unwrap(),
+ PID: os.Getpid(),
+ ShimPID: ms.cmd.Process.Pid,
+ Config: k.config,
+ Time: *ms.Time,
+ }); err != nil {
+ ms.fatal("cannot save state entry:", err)
+ }
+ }); err != nil {
+ if ok {
+ ms.uintptr |= mainNeedsDestroy
+ ms.fatal("cannot unlock state store:", err)
+ } else {
+ ms.fatal("cannot open state store:", err)
+ }
+ }
+ // state in store at this point, destroy defunct state entry on termination
+ ms.uintptr |= mainNeedsDestroy
+
+ // beforeExit ties shim process to context
+ ms.beforeExit(false)
+ os.Exit(0)
+}
+
+// printMessageError prints the error message according to [message.GetMessage],
+// or fallback prepended to err if an error message is not available.
+func printMessageError(fallback string, err error) {
+ m, ok := message.GetMessage(err)
+ if !ok {
+ log.Println(fallback, err)
+ return
+ }
+
+ log.Print(m)
+}