diff --git a/container/dispatcher.go b/container/dispatcher.go index e1dae6e..e58346e 100644 --- a/container/dispatcher.go +++ b/container/dispatcher.go @@ -68,6 +68,8 @@ type syscallDispatcher interface { notify(c chan<- os.Signal, sig ...os.Signal) // start starts [os/exec.Cmd]. start(c *exec.Cmd) error + // wait waits on [os/exec.Cmd]. + wait(c *exec.Cmd) error // signal signals the underlying process of [os/exec.Cmd]. signal(c *exec.Cmd, sig os.Signal) error // evalSymlinks provides [filepath.EvalSymlinks]. @@ -170,6 +172,7 @@ func (direct) seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) erro } func (direct) notify(c chan<- os.Signal, sig ...os.Signal) { signal.Notify(c, sig...) } func (direct) start(c *exec.Cmd) error { return c.Start() } +func (direct) wait(c *exec.Cmd) error { return c.Wait() } func (direct) signal(c *exec.Cmd, sig os.Signal) error { return c.Process.Signal(sig) } func (direct) evalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) } diff --git a/container/dispatcher_test.go b/container/dispatcher_test.go index ee31f91..bbcb26a 100644 --- a/container/dispatcher_test.go +++ b/container/dispatcher_test.go @@ -493,6 +493,21 @@ func (k *kstub) start(c *exec.Cmd) error { return err } +func (k *kstub) wait(c *exec.Cmd) error { + k.Helper() + expect := k.Expects("wait") + err := expect.Error( + stub.CheckArg(k.Stub, "c.Path", c.Path, 0), + stub.CheckArgReflect(k.Stub, "c.Args", c.Args, 1), + stub.CheckArgReflect(k.Stub, "c.Env", c.Env, 2), + stub.CheckArg(k.Stub, "c.Dir", c.Dir, 3)) + + if mgc, ok := expect.Ret.(uintptr); ok && mgc == stub.PanicExit { + panic(stub.PanicExit) + } + return err +} + func (k *kstub) signal(c *exec.Cmd, sig os.Signal) error { k.Helper() expect := k.Expects("signal") @@ -759,7 +774,8 @@ func (k *kstub) checkMsg(msg message.Msg) { } func (k *kstub) GetLogger() *log.Logger { panic("unreachable") } -func (k *kstub) IsVerbose() bool { panic("unreachable") } + +func (k *kstub) IsVerbose() bool { k.Helper(); return k.Expects("isVerbose").Ret.(bool) } func (k *kstub) SwapVerbose(verbose bool) bool { k.Helper() diff --git a/container/initdaemon.go b/container/initdaemon.go new file mode 100644 index 0000000..ee360dc --- /dev/null +++ b/container/initdaemon.go @@ -0,0 +1,105 @@ +package container + +import ( + "context" + "encoding/gob" + "errors" + "fmt" + "os" + "os/exec" + "slices" + "sync/atomic" + "syscall" + "time" + + "hakurei.app/container/check" + "hakurei.app/container/fhs" +) + +func init() { gob.Register(new(DaemonOp)) } + +const ( + // daemonTimeout is the duration a [DaemonOp] is allowed to block before the + // [DaemonOp.Target] marker becomes available. + daemonTimeout = 5 * time.Second +) + +// Daemon appends an [Op] that starts a daemon in the container and blocks until +// [DaemonOp.Target] appears. +func (f *Ops) Daemon(target, path *check.Absolute, args ...string) *Ops { + *f = append(*f, &DaemonOp{target, path, args}) + return f +} + +// DaemonOp starts a daemon in the container and blocks until Target appears. +type DaemonOp struct { + // Pathname indicating readiness of daemon. + Target *check.Absolute + // Absolute pathname passed to [exec.Cmd]. + Path *check.Absolute + // Arguments (excl. first) passed to [exec.Cmd]. + Args []string +} + +func (d *DaemonOp) Valid() bool { return d != nil && d.Target != nil && d.Path != nil } +func (d *DaemonOp) early(*setupState, syscallDispatcher) error { return nil } +func (d *DaemonOp) apply(*setupState, syscallDispatcher) error { return nil } +func (d *DaemonOp) late(state *setupState, k syscallDispatcher) error { + cmd := exec.CommandContext(state.Context, d.Path.String(), d.Args...) + cmd.Env = state.Env + cmd.Dir = fhs.Root + if state.IsVerbose() { + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + } + // WaitDelay: left unset because lifetime is bound by AdoptWaitDelay on cancellation + cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } + + state.Verbosef("starting %s", d.String()) + if err := k.start(cmd); err != nil { + return err + } + + var done atomic.Pointer[error] + k.new(func(k syscallDispatcher) { + err := k.wait(cmd) + done.Store(&err) + + if err != nil { + state.Verbosef("%s %v", d.String(), err) + } + }) + + deadline := time.Now().Add(daemonTimeout) + for { + if _, err := k.stat(d.Target.String()); err != nil { + if !errors.Is(err, os.ErrNotExist) { + _ = k.signal(cmd, os.Kill) + return err + } + + if time.Now().After(deadline) { + _ = k.signal(cmd, os.Kill) + return context.DeadlineExceeded + } + + if errP := done.Load(); errP != nil { + return *errP + } + + time.Sleep(500 * time.Microsecond) + continue + } + + state.Verbosef("daemon process %d ready", cmd.Process.Pid) + return nil + } +} + +func (d *DaemonOp) Is(op Op) bool { + vd, ok := op.(*DaemonOp) + return ok && d.Valid() && vd.Valid() && + d.Target.Is(vd.Target) && d.Path.Is(vd.Path) && + slices.Equal(d.Args, vd.Args) +} +func (*DaemonOp) prefix() (string, bool) { return zeroString, false } +func (d *DaemonOp) String() string { return fmt.Sprintf("daemon providing %q", d.Target) } diff --git a/container/initdaemon_test.go b/container/initdaemon_test.go new file mode 100644 index 0000000..5ab834d --- /dev/null +++ b/container/initdaemon_test.go @@ -0,0 +1,102 @@ +package container + +import ( + "os" + "testing" + + "hakurei.app/container/check" + "hakurei.app/container/stub" +) + +func TestDaemonOp(t *testing.T) { + t.Parallel() + + checkSimple(t, "DaemonOp.late", []simpleTestCase{ + {"success", func(k *kstub) error { + state := setupState{Params: &Params{Env: []string{"\x00"}}, Context: t.Context(), Msg: k} + return (&DaemonOp{ + Target: check.MustAbs("/run/user/1971/pulse/native"), + Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), + Args: []string{"-v"}, + }).late(&state, k) + }, stub.Expect{Calls: []stub.Call{ + call("isVerbose", stub.ExpectArgs{}, true, nil), + call("verbosef", stub.ExpectArgs{"starting %s", []any{`daemon providing "/run/user/1971/pulse/native"`}}, nil, nil), + call("start", stub.ExpectArgs{"/run/current-system/sw/bin/pipewire-pulse", []string{"/run/current-system/sw/bin/pipewire-pulse", "-v"}, []string{"\x00"}, "/"}, &os.Process{Pid: 0xcafe}, nil), + call("New", stub.ExpectArgs{}, nil, nil), + call("stat", stub.ExpectArgs{"/run/user/1971/pulse/native"}, isDirFi(false), os.ErrNotExist), + call("stat", stub.ExpectArgs{"/run/user/1971/pulse/native"}, isDirFi(false), os.ErrNotExist), + call("stat", stub.ExpectArgs{"/run/user/1971/pulse/native"}, isDirFi(false), os.ErrNotExist), + call("stat", stub.ExpectArgs{"/run/user/1971/pulse/native"}, isDirFi(false), nil), + call("verbosef", stub.ExpectArgs{"daemon process %d ready", []any{0xcafe}}, nil, nil), + }, Tracks: []stub.Expect{{Calls: []stub.Call{ + call("wait", stub.ExpectArgs{"/run/current-system/sw/bin/pipewire-pulse", []string{"/run/current-system/sw/bin/pipewire-pulse", "-v"}, []string{"\x00"}, "/"}, uintptr(stub.PanicExit), nil), + }}}}, nil}, + }) + + checkOpsValid(t, []opValidTestCase{ + {"nil", (*DaemonOp)(nil), false}, + {"zero", new(DaemonOp), false}, + {"valid", &DaemonOp{ + Target: check.MustAbs("/run/user/1971/pulse/native"), + Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), + Args: []string{"-v"}, + }, true}, + }) + + checkOpsBuilder(t, []opsBuilderTestCase{ + {"pipewire-pulse", new(Ops).Daemon( + check.MustAbs("/run/user/1971/pulse/native"), + check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), "-v", + ), Ops{ + &DaemonOp{ + Target: check.MustAbs("/run/user/1971/pulse/native"), + Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), + Args: []string{"-v"}, + }, + }}, + }) + + checkOpIs(t, []opIsTestCase{ + {"zero", new(DaemonOp), new(DaemonOp), false}, + + {"args differs", &DaemonOp{ + Target: check.MustAbs("/run/user/1971/pulse/native"), + Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), + Args: []string{"-v"}, + }, &DaemonOp{ + Target: check.MustAbs("/run/user/1971/pulse/native"), + Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), + }, false}, + + {"path differs", &DaemonOp{ + Target: check.MustAbs("/run/user/1971/pulse/native"), + Path: check.MustAbs("/run/current-system/sw/bin/pipewire"), + }, &DaemonOp{ + Target: check.MustAbs("/run/user/1971/pulse/native"), + Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), + }, false}, + + {"target differs", &DaemonOp{ + Target: check.MustAbs("/run/user/65534/pulse/native"), + Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), + }, &DaemonOp{ + Target: check.MustAbs("/run/user/1971/pulse/native"), + Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), + }, false}, + + {"equals", &DaemonOp{ + Target: check.MustAbs("/run/user/1971/pulse/native"), + Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), + }, &DaemonOp{ + Target: check.MustAbs("/run/user/1971/pulse/native"), + Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), + }, true}, + }) + + checkOpMeta(t, []opMetaTestCase{ + {"pipewire-pulse", &DaemonOp{ + Target: check.MustAbs("/run/user/1971/pulse/native"), + }, zeroString, `daemon providing "/run/user/1971/pulse/native"`}, + }) +}