From 9fc3671e9b2169f294e95efd036891ca8fba1d22 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Mon, 8 Dec 2025 06:35:36 +0900 Subject: [PATCH] container: start daemons within container This is useful for daemons internal to the container. The only current use case is pipewire-pulse. Signed-off-by: Ophestra --- container/dispatcher_test.go | 3 +- container/initdaemon.go | 91 +++++++++++++++++++++++++++++++++ container/initdaemon_test.go | 99 ++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 container/initdaemon.go create mode 100644 container/initdaemon_test.go diff --git a/container/dispatcher_test.go b/container/dispatcher_test.go index ee31f91..834c700 100644 --- a/container/dispatcher_test.go +++ b/container/dispatcher_test.go @@ -759,7 +759,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..4337c45 --- /dev/null +++ b/container/initdaemon.go @@ -0,0 +1,91 @@ +package container + +import ( + "context" + "encoding/gob" + "errors" + "fmt" + "os" + "os/exec" + "slices" + "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 + } + // Wait: reaped by wait4 loop + + 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 + } + + 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..de0193a --- /dev/null +++ b/container/initdaemon_test.go @@ -0,0 +1,99 @@ +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("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), + }}, 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"`}, + }) +}