aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal/testsuite/testsuite.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/testsuite/testsuite.go')
-rw-r--r--internal/testsuite/testsuite.go910
1 files changed, 910 insertions, 0 deletions
diff --git a/internal/testsuite/testsuite.go b/internal/testsuite/testsuite.go
new file mode 100644
index 00000000..395bf8f2
--- /dev/null
+++ b/internal/testsuite/testsuite.go
@@ -0,0 +1,910 @@
+// Package testsuite provides many quick-and-dirty integration testing utilities.
+//
+// Attempting to import this package outside testing causes the resulting
+// program to panic.
+package testsuite
+
+import (
+ "bufio"
+ "context"
+ "crypto/sha512"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "iter"
+ "log"
+ "os"
+ "os/exec"
+ "os/signal"
+ "os/user"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "hakurei.app/check"
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/store"
+)
+
+// serial is the next value returned by [NewSerial].
+var serial atomic.Uint64
+
+// NewSerial returns a serial number.
+func NewSerial() uint64 { return serial.Add(1) - 1 }
+
+// ReceiveSignals blocks until a termination signal arrives, and terminates.
+func ReceiveSignals() {
+ s := make(chan os.Signal, 3)
+ signal.Notify(s, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
+ log.Fatalf("terminating on signal %s", <-s)
+}
+
+var (
+ // users caches [user.LookupId] calls.
+ users = make(map[string]*user.User)
+ // usersMu synchronises access to users.
+ usersMu sync.RWMutex
+)
+
+// mustLookupId is like [user.LookupId], but the first result is cached.
+func mustLookupId(uid string) *user.User {
+ usersMu.RLock()
+ v, ok := users[uid]
+ usersMu.RUnlock()
+ if ok {
+ return v
+ }
+
+ u, err := user.LookupId(uid)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ usersMu.Lock()
+ users[uid] = u
+ usersMu.Unlock()
+ return u
+}
+
+// MustAppendEnv adds extra environment variables to cmd.
+func MustAppendEnv(cmd *exec.Cmd, env ...string) {
+ if len(cmd.Env) == 0 {
+ var cred syscall.Credential
+ if cmd.SysProcAttr != nil && cmd.SysProcAttr.Credential != nil {
+ cred = *cmd.SysProcAttr.Credential
+ }
+ u := mustLookupId(strconv.Itoa(int(cred.Uid)))
+
+ cmd.Env = append(cmd.Env,
+ "PATH="+os.Getenv("PATH"),
+ "HOME="+u.HomeDir,
+ "USER="+u.Username,
+ "USERNAME="+u.Username,
+ )
+ }
+ cmd.Env = append(cmd.Env, env...)
+}
+
+// MustRun runs command and terminates the testsuite on error.
+func MustRun(cred *syscall.Credential, extraEnv []string, command ...string) {
+ cmd := exec.Command(command[0], command[1:]...)
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Pdeathsig: syscall.SIGKILL,
+ Credential: cred,
+ }
+ MustAppendEnv(cmd, extraEnv...)
+ if err := cmd.Run(); err != nil {
+ log.Fatalf("must run %s: %v", strings.Join(command, " "), err)
+ }
+}
+
+// ErrUnexpectedSuccess is returned for processes expected to exit with a
+// non-zero code, but failed to do so.
+var ErrUnexpectedSuccess = errors.New("process unexpectedly exited with code 0")
+
+// MustFail runs command and terminates the testsuite if the program fails to
+// start or exits with code 0.
+func MustFail(
+ cred *syscall.Credential,
+ extraEnv []string,
+ quiet bool,
+ command ...string,
+) {
+ cmd := exec.Command(command[0], command[1:]...)
+ if !quiet {
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ }
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Pdeathsig: syscall.SIGKILL,
+ Credential: cred,
+ }
+ MustAppendEnv(cmd, extraEnv...)
+ if err := cmd.Run(); err == nil {
+ log.Fatal(ErrUnexpectedSuccess)
+ } else if e, ok := errors.AsType[*exec.ExitError](err); !ok {
+ log.Fatalf("must fail %s: %v", strings.Join(command, " "), err)
+ } else if !e.Exited() {
+ log.Fatalf("must fail %s: %v", strings.Join(command, " "), e)
+ }
+}
+
+// MustStart starts cmd and returns a channel delivering its wait error.
+func MustStart(cmd *exec.Cmd) (done <-chan error) {
+ if err := cmd.Start(); err != nil {
+ log.Fatal(err)
+ }
+ d := make(chan error, 1)
+ go func() { d <- cmd.Wait() }()
+ return d
+}
+
+// MustStartWith wraps [MustStart] and creates the [exec.Cmd] object internally.
+func MustStartWith(
+ ctx context.Context,
+ cred *syscall.Credential,
+ quiet bool,
+ extraEnv []string,
+ files []*os.File,
+ command ...string,
+) (proc *os.Process, done <-chan error) {
+ cmd := exec.CommandContext(ctx, command[0], command[1:]...)
+ if !quiet {
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ }
+ cmd.ExtraFiles = files
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Pdeathsig: syscall.SIGTERM,
+ Credential: cred,
+ }
+ MustAppendEnv(cmd, extraEnv...)
+ return cmd.Process, MustStart(cmd)
+}
+
+// MustOutput is like [MustRun] and [MustFail], but collects the entire output
+// in memory and returns them as strings.
+func MustOutput(
+ ctx context.Context,
+ cred *syscall.Credential,
+ extraEnv []string,
+ exitCode int,
+ name string,
+ command ...string,
+) (string, string) {
+ var stdout, stderr strings.Builder
+ cmd := exec.CommandContext(ctx, command[0], command[1:]...)
+ cmd.Stdout, cmd.Stderr = &stdout, &stderr
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Pdeathsig: syscall.SIGTERM,
+ Credential: cred,
+ }
+ MustAppendEnv(cmd, extraEnv...)
+
+ err := cmd.Run()
+ if err == nil {
+ if exitCode == 0 {
+ return stdout.String(), stderr.String()
+ }
+ log.Fatalf(
+ "%s succeeded unexpectedly"+
+ "\nstdout = %q\nstderr = %q",
+ name,
+ stdout.String(),
+ stderr.String(),
+ )
+ }
+
+ if e, ok := errors.AsType[*exec.ExitError](err); !ok {
+ log.Fatal(err)
+ } else if e.ExitCode() != exitCode {
+ log.Fatalf(
+ "%s: %v"+
+ "\nstdout = %q\nstderr = %q",
+ name, e,
+ stdout.String(),
+ stderr.String(),
+ )
+ }
+ return stdout.String(), stderr.String()
+}
+
+// AssertOutput returns a function that terminates the program if called with
+// arguments differing from the ones it is initialised with.
+func AssertOutput(action, stdout, stderr string) (
+ assert func(stdout, stderr string),
+) {
+ return func(gotStdout, gotStderr string) {
+ if stdout != gotStdout || stderr != gotStderr {
+ log.Fatalf(
+ "unexpected output %s"+
+ "\nstdout = %q\nstderr = %q",
+ action,
+ gotStdout,
+ gotStderr,
+ )
+ }
+ }
+}
+
+// MustCheckFilter is like [CheckFilter], but terminates the test suite if a
+// non-nil error is returned. Otherwise, the tracee is terminated after it
+// resumes.
+func MustCheckFilter(name string, pid int, sum [sha512.Size]byte) {
+ if err := CheckFilter(
+ pid,
+ 1, /* podman installs its own filter */
+ sum,
+ ); err != nil {
+ log.Fatalln(name, err)
+ } else if err = syscall.Kill(pid, syscall.SIGTERM); err != nil {
+ log.Fatalf("cannot terminate tracee: %v", err)
+ }
+}
+
+// FilterTerminated returns a non-nil error if err is not an [exec.ExitError]
+// describing a process terminated by a syscall.SIGTERM signal.
+func FilterTerminated(err error) error {
+ if err == nil {
+ return ErrUnexpectedSuccess
+ }
+
+ e, ok := errors.AsType[*exec.ExitError](err)
+ if !ok {
+ return err
+ }
+
+ if e.ExitCode() == 0x80+int(syscall.SIGTERM) {
+ return nil
+ }
+ return e
+}
+
+// MustDecodeFrom runs the specified command and decodes its JSON output,
+// storing the result in the value pointed to by v.
+func MustDecodeFrom[T any](
+ ctx context.Context,
+ cred *syscall.Credential,
+ env []string,
+ v *T,
+ name string,
+ arg ...string,
+) {
+ cmd := exec.CommandContext(ctx, name, arg...)
+ cmd.Env = env
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Pdeathsig: syscall.SIGKILL,
+ Credential: cred,
+ }
+ cmd.Stderr = os.Stderr
+
+ r, err := cmd.StdoutPipe()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if err = cmd.Start(); err != nil {
+ log.Fatalf("start %s: %v", name, err)
+ }
+
+ if err = json.NewDecoder(r).Decode(v); err != nil {
+ log.Fatalf("decode %s: %v", name, err)
+ }
+
+ if err = cmd.Wait(); err != nil {
+ log.Fatalf("wait for %s: %v", name, err)
+ }
+}
+
+// Poll repeatedly runs command until it succeeds.
+func Poll(
+ d time.Duration,
+ cred *syscall.Credential,
+ extraEnv []string,
+ command ...string,
+) {
+ for range time.NewTicker(d).C {
+ cmd := exec.Command(command[0], command[1:]...)
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Pdeathsig: syscall.SIGKILL,
+ Credential: cred,
+ }
+ MustAppendEnv(cmd, extraEnv...)
+ if err := cmd.Run(); err != nil {
+ if e, ok := errors.AsType[*exec.ExitError](err); ok && e.Exited() {
+ continue
+ }
+ log.Fatalf("poll %s: %v", strings.Join(command, " "), err)
+ }
+ break
+ }
+}
+
+// PollPID polls until the process referred to by pid no longer exists.
+func PollPID(name string, pid int) {
+ for {
+ time.Sleep(10 * time.Millisecond)
+ if err := syscall.Kill(pid, 0); err == nil {
+ continue
+ } else if errors.Is(err, syscall.ESRCH) {
+ break
+ } else {
+ log.Fatalf("cannot poll for %s: %v", name, err)
+ }
+ }
+}
+
+const (
+ // XDGRuntimeDir is the hardcoded XDG runtime directory of the only user
+ // mapped in hsurc.
+ XDGRuntimeDir = "/var/run/user/1000"
+
+ // XDGRuntimeEnv is the environment variable string for XDG_RUNTIME_DIR.
+ XDGRuntimeEnv = "XDG_RUNTIME_DIR=" + XDGRuntimeDir
+
+ // HakureiPrefix is the hakurei work directory of the only user mapped in
+ // hsurc.
+ HakureiPrefix = "/tmp/hakurei.0/"
+)
+
+// MustPrepareUserDirs creates user-specific directories.
+func MustPrepareUserDirs(cred *syscall.Credential) {
+ if err := os.MkdirAll(XDGRuntimeDir, 0700); err != nil {
+ log.Fatal(err)
+ } else if err = os.Chown(
+ XDGRuntimeDir,
+ int(cred.Uid),
+ int(cred.Gid),
+ ); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// SessionEnv returns environment variables referring to the hosted session.
+func SessionEnv(dbusEnv string) []string {
+ return []string{
+ "PATH=" + os.Getenv("PATH"),
+ "TERM=xterm",
+ XDGRuntimeEnv,
+ WaylandEnv,
+ "DISPLAY=:0",
+ dbusEnv,
+ }
+}
+
+// quietServices is initialised from the environment and controls whether
+// session services should have their outputs connected.
+var quietServices = os.Getenv("HAKUREI_VERBOSE_SERVICES") == ""
+
+// MustStartSessionBus starts a session bus that is never explicitly terminated.
+// The test suite is terminated if the session bus daemon terminates.
+func MustStartSessionBus(cred *syscall.Credential) (dbusEnv string) {
+ r, w, err := os.Pipe()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // this is never explicitly terminated
+ _, done := MustStartWith(
+ context.Background(), cred, quietServices, nil, []*os.File{w},
+ "dbus-daemon",
+ "--print-address=3",
+ "--address=unix:path="+XDGRuntimeDir+"/dbus",
+ "--session",
+ "--nofork",
+ "--nopidfile",
+ )
+
+ go func() {
+ if _err := <-done; _err != nil {
+ log.Fatalf("session bus terminated unexpectedly: %v", _err)
+ }
+ log.Fatal("session bus terminated unexpectedly")
+ }()
+
+ dbusEnv, err = bufio.NewReader(r).ReadString('\n')
+ if err != nil {
+ log.Fatal(err)
+ }
+ dbusEnv = dbusEnv[:len(dbusEnv)-1]
+ log.Printf("dbus listening on %s", dbusEnv)
+ dbusEnv = "DBUS_SESSION_BUS_ADDRESS=" + dbusEnv
+
+ if err = r.Close(); err != nil {
+ log.Fatal(err)
+ }
+ return
+}
+
+const (
+ // SwayEnv is the environment variable string for the sway IPC socket.
+ SwayEnv = "SWAYSOCK=" + XDGRuntimeDir + "/sway"
+ // WaylandEnv is the environment variable string for the wayland display.
+ WaylandEnv = "WAYLAND_DISPLAY=wayland-1"
+)
+
+// MustStartSway starts the sway wayland display server which must be terminated
+// by calling [TerminateSway].
+func MustStartSway(
+ wg *sync.WaitGroup,
+ cred *syscall.Credential,
+ dbusEnv string,
+) {
+ wg.Go(func() {
+ // this is terminated via swaymsg
+ _, done := MustStartWith(
+ context.Background(), cred, quietServices, []string{
+ "WLR_BACKENDS=headless",
+ XDGRuntimeEnv,
+ SwayEnv,
+ dbusEnv,
+ }, nil,
+ "sway",
+ )
+ if err := <-done; err != nil {
+ log.Fatalf("sway terminated unexpectedly: %v", err)
+ }
+ })
+
+ Poll(
+ 50*time.Millisecond,
+ cred,
+ []string{SwayEnv},
+ "swaymsg",
+ )
+ Poll(
+ 50*time.Millisecond,
+ cred,
+ []string{"DISPLAY=:0"},
+ "xset", "q",
+ )
+ log.Printf("sway available via %s", SwayEnv)
+}
+
+// TerminateSway requests for the sway server to terminate via sway IPC.
+func TerminateSway(cred *syscall.Credential) {
+ MustFail(cred, []string{SwayEnv}, quietServices, "swaymsg", "exit")
+}
+
+// SwayNode represents a Sway window with hakurei-specific fields.
+type SwayNode struct {
+ // Window title.
+ Name string `json:"name"`
+ // Descendents.
+ Nodes []SwayNode `json:"nodes"`
+ // Wayland app id.
+ AppID string `json:"app_id,omitempty"`
+
+ // Should always be app.hakurei for cmd/hakurei containers.
+ Sandbox string `json:"sandbox_engine,omitempty"`
+ // Configured via [hst].
+ ID string `json:"sandbox_app_id,omitempty"`
+ // Hakurei instance identifier.
+ Instance hst.ID `json:"sandbox_instance_id,omitempty"`
+}
+
+// All returns an iterator over n and all descendents of n.
+func (n *SwayNode) All() iter.Seq[*SwayNode] {
+ return func(yield func(*SwayNode) bool) {
+ if !yield(n) {
+ return
+ }
+
+ for _, d := range n.Nodes {
+ for v := range d.All() {
+ if !yield(v) {
+ return
+ }
+ }
+ }
+ }
+}
+
+// PollSwayWindow continuously polls sway for a Wayland client to appear.
+func PollSwayWindow(
+ ctx context.Context,
+ cred *syscall.Credential,
+ f func(n *SwayNode) bool,
+) *SwayNode {
+ var t SwayNode
+ for {
+ time.Sleep(25 * time.Millisecond)
+ MustDecodeFrom(
+ ctx, cred, []string{SwayEnv}, &t,
+ "swaymsg", "-t", "get_tree",
+ )
+ for n := range t.All() {
+ if f(n) {
+ return n
+ }
+ }
+ }
+}
+
+// MustStartPipeWire starts a PipeWire server that is never explicitly
+// terminated. The test suite is terminated if the PipeWire server terminates.
+func MustStartPipeWire(cred *syscall.Credential, dbusEnv string) (version string) {
+ version, _ = MustOutput(
+ context.Background(), nil, nil, 0,
+ "pipewire version",
+ "pipewire", "--version",
+ )
+ _, version, _ = strings.Cut(version, "Compiled with libpipewire ")
+ _, version, _ = strings.Cut(version, "\nLinked with libpipewire")
+ version = strings.TrimSpace(version)
+
+ // this is never explicitly terminated
+ _, done := MustStartWith(
+ context.Background(), cred, quietServices, []string{
+ XDGRuntimeEnv,
+ dbusEnv,
+ }, nil,
+ "pipewire",
+ )
+
+ go func() {
+ if _err := <-done; _err != nil {
+ log.Fatalf("pipewire terminated unexpectedly: %v", _err)
+ }
+ log.Fatal("pipewire terminated unexpectedly")
+ }()
+
+ Poll(50*time.Millisecond, cred, []string{
+ XDGRuntimeEnv,
+ dbusEnv,
+ },
+ "wpctl",
+ "status",
+ )
+
+ Poll(50*time.Millisecond, cred, []string{
+ XDGRuntimeEnv,
+ dbusEnv,
+ },
+ "pw-cli",
+ "load-module",
+ "libpipewire-module-null-sink",
+ )
+
+ _, _done := MustStartWith(
+ context.Background(), cred, quietServices, []string{
+ XDGRuntimeEnv,
+ dbusEnv,
+ }, nil,
+ "wireplumber",
+ )
+
+ go func() {
+ if _err := <-_done; _err != nil {
+ log.Fatalf("wireplumber terminated unexpectedly: %v", _err)
+ }
+ log.Fatal("wireplumber terminated unexpectedly")
+ }()
+ return
+}
+
+// MustScanFor continuously scans the proc filesystem and calls f for each entry
+// visited.
+func MustScanFor(f func(ps *StatScanner) bool) int {
+ var ps StatScanner
+
+ for ps.Scan() {
+ if f(&ps) {
+ break
+ }
+ }
+ if err := ps.Err(); err != nil {
+ log.Fatal(err)
+ }
+ return ps.Stat().PID
+}
+
+// storeLookup describes a store lookup request.
+type storeLookup struct {
+ // Identifier to look up.
+ id hst.ID
+ // Address of value to write result to.
+ p *hst.State
+ // Error encountered while fulfilling the request.
+ done chan<- error
+}
+
+var (
+ // storeThreads holds channels for dispatching requests to goroutines
+ // locked to threads configured with specific credentials.
+ storeThreads = make(map[[2]uint32]chan<- storeLookup)
+ // storeThreadsMu synchronises access to storeThreads.
+ storeThreadsMu sync.Mutex
+)
+
+// LookupStore looks up the specified identifier from the hakurei state store
+// and stores its contents in the value pointed to by p.
+func LookupStore(cred *syscall.Credential, id hst.ID, p *hst.State) error {
+ var key [2]uint32
+ if cred != nil {
+ key[0], key[1] = cred.Uid, cred.Gid
+ }
+
+ storeThreadsMu.Lock()
+ c, ok := storeThreads[key]
+ if !ok {
+ s := store.New(check.MustAbs(HakureiPrefix + "state"))
+ r := make(chan storeLookup)
+ go func() {
+ if cred != nil {
+ runtime.LockOSThread()
+ if err := syscall.Setfsuid(int(cred.Uid)); err != nil {
+ log.Fatalf("setfsuid: %v", err)
+ } else if err = syscall.Setfsgid(int(cred.Gid)); err != nil {
+ log.Fatalf("setfsgid: %v", err)
+ }
+ }
+
+ req:
+ for req := range r {
+ ok = false
+ entries, copyError := s.All()
+ for entry := range entries {
+ if entry.ID == req.id {
+ ok = true
+ if _, err := entry.Load(req.p, nil); err != nil {
+ req.done <- err
+ continue req
+ }
+ break
+ }
+ }
+ if err := copyError(); err != nil {
+ req.done <- err
+ continue
+ }
+ if !ok {
+ req.done <- fmt.Errorf("instance %s is not present in store", id.String())
+ continue
+ }
+ req.done <- nil
+ }
+ }()
+ c = r
+ storeThreads[key] = c
+ }
+ storeThreadsMu.Unlock()
+
+ done := make(chan error, 1)
+ c <- storeLookup{id, p, done}
+ return <-done
+}
+
+// StartHakurei starts a hakurei container configured by c.
+func StartHakurei(
+ ctx context.Context,
+ wg *sync.WaitGroup,
+ cred *syscall.Credential,
+ dbusEnv string,
+ processCommand func(cmd *exec.Cmd),
+ onDone func(err error, output string),
+ scan, wait, verbose, insecure bool,
+ c *hst.Config,
+) (p hst.State, pid int, err error) {
+ var hakureiFlags string
+ if verbose {
+ hakureiFlags += " -v"
+ }
+ if insecure {
+ hakureiFlags += " --insecure"
+ }
+
+ var notifySuffix string
+ if wait {
+ notifySuffix += " 1>&3"
+ }
+
+ cmd := exec.CommandContext(ctx,
+ "script", "/dev/null",
+ "-E", "always",
+ "-qec",
+ "hakurei"+hakureiFlags+" run "+
+ "--identifier-fd=5"+
+ " 4"+notifySuffix,
+ )
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Pdeathsig: syscall.SIGTERM,
+ Credential: cred,
+ }
+ var output strings.Builder
+ cmd.Stdout, cmd.Stderr = &output, &output
+ cmd.Env = SessionEnv(dbusEnv)
+
+ var notify, _notify, _conf, conf, ident, _ident *os.File
+ if notify, _notify, err = os.Pipe(); err != nil {
+ return
+ }
+ cmd.ExtraFiles = append(cmd.ExtraFiles, _notify)
+ if _conf, conf, err = os.Pipe(); err != nil {
+ _, _ = notify.Close(), _notify.Close()
+ return
+ }
+ cmd.ExtraFiles = append(cmd.ExtraFiles, _conf)
+ if ident, _ident, err = os.Pipe(); err != nil {
+ _, _ = notify.Close(), _notify.Close()
+ _, _ = conf.Close(), _conf.Close()
+ return
+ }
+ cmd.ExtraFiles = append(cmd.ExtraFiles, _ident)
+ if processCommand != nil {
+ processCommand(cmd)
+ }
+
+ done := MustStart(cmd)
+ wg.Go(func() { _err := <-done; onDone(_err, output.String()) })
+
+ if err = errors.Join(
+ _notify.Close(),
+ _conf.Close(),
+ _ident.Close(),
+ ); err != nil {
+ _, _, _ = notify.Close(), conf.Close(), ident.Close()
+ return
+ }
+
+ if err = json.NewEncoder(conf).Encode(c); err != nil {
+ _, _, _ = notify.Close(), conf.Close(), ident.Close()
+ return
+ } else if err = conf.Close(); err != nil {
+ _, _ = notify.Close(), ident.Close()
+ return
+ }
+
+ if !scan && len(cmd.ExtraFiles) != 3 {
+ err = errors.Join(notify.Close(), ident.Close())
+ return
+ }
+
+ var id hst.ID
+ if _, err = io.ReadFull(ident, id[:]); err != nil {
+ _, _ = notify.Close(), ident.Close()
+ if !scan && errors.Is(err, io.EOF) {
+ err = nil
+ }
+ return
+ } else if err = ident.Close(); err != nil {
+ _ = notify.Close()
+ return
+ }
+
+ if !scan {
+ err = notify.Close()
+ return
+ }
+
+ if wait {
+ if _, err = io.ReadFull(notify, make([]byte, 8)); err != nil {
+ _ = notify.Close()
+ return
+ }
+ }
+ if err = notify.Close(); err != nil {
+ return
+ }
+
+ var identifiers []hst.ID
+ MustDecodeFrom(
+ ctx, cred, nil, &identifiers,
+ "hakurei", "--json", "ps", "--short",
+ )
+ if !slices.Contains(identifiers, id) {
+ log.Fatalf("(%s) instance not present in ps output", c.Container.Hostname)
+ }
+
+ var got hst.State
+ MustDecodeFrom(
+ ctx, cred, nil, &got,
+ "hakurei", "--json", "show", id.String(),
+ )
+
+ if err = LookupStore(cred, id, &p); err != nil {
+ return
+ }
+
+ if !reflect.DeepEqual(got, p) {
+ log.Fatalf("show: %#v, want %#v", got, p)
+ }
+
+ var stat syscall.Stat_t
+ pid = MustScanFor(func(ps *StatScanner) bool {
+ select {
+ case err = <-done:
+ if err == nil {
+ log.Fatal("test process terminated unexpectedly")
+ }
+ log.Fatal(err)
+ default:
+ break
+ }
+
+ var args []string
+ if args, err = ps.Stat().Args(); err != nil {
+ if IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ } else if !slices.Equal(args, c.Container.Args) {
+ return false
+ }
+
+ if err = ps.Stat().Stat(&stat); err != nil {
+ if IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ }
+
+ uid := hst.ToUser[uint32](0, uint32(c.Identity))
+ if stat.Uid != uid || stat.Gid != uid {
+ return false
+ }
+
+ var t []byte
+ if t, err = os.ReadFile(filepath.Join(
+ fhs.Proc,
+ strconv.Itoa(ps.Stat().PPID),
+ "stat",
+ )); err != nil {
+ if IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ }
+
+ var _stat Stat
+ if err = _stat.UnmarshalText(t); err != nil {
+ log.Fatal(err)
+ }
+ if _stat.PPID != p.ShimPID {
+ return false
+ }
+
+ return true
+ })
+ return
+}
+
+// MustStartHakurei is like [StartHakurei], but terminates the test suite if
+// an error is encountered.
+func MustStartHakurei(
+ ctx context.Context,
+ wg *sync.WaitGroup,
+ cred *syscall.Credential,
+ dbusEnv string,
+ onDone func(err error, output string),
+ scan, wait, verbose, insecure bool,
+ c *hst.Config,
+) (p hst.State, pid int) {
+ _ctx, cancel := context.WithCancel(ctx)
+
+ var err error
+ p, pid, err = StartHakurei(
+ _ctx, wg, cred, dbusEnv,
+ nil, func(err error, output string) {
+ defer cancel()
+ onDone(err, output)
+ }, scan, wait, verbose, insecure, c,
+ )
+ if err != nil {
+ log.Printf(
+ "(%s) cannot start hakurei container: %v",
+ c.Container.Hostname,
+ err,
+ )
+ cancel()
+ }
+ return
+}