# 76-daemonize.test -- daemonize 1.7.8 (meta-oe recipes-support, bmc/daemonize). # # daemonize turns any command into a Unix daemon: it validates the path, takes an # optional flock(2) single-instance lock, writes a PID file, optionally switches # user, redirects stdio, chdirs, calls daemon(3) -- fork(2) + setsid(2) -- and # finally execvp(2)s the command. So this suite is not really about a 500-line # tool: it is a test of the wasm32 kernel's PROCESS-DETACHMENT path, with # daemonize as the instrument. Six syscall families, each of which has bitten a # sibling port on this Epic, are on the line at once -- fork, setsid, execvp, # flock, setuid/setgid/initgroups, and the /proc plumbing that lets us prove any # of it happened. # # Sits next to 76-libdaemon.test deliberately: libdaemon is the LIBRARY for # writing a daemon and can only be contract-tested here (nothing on the image # links it, and there is no compiler); daemonize is the same job as a BINARY, so # it is the half that can actually be RUN. Together they cover the topic. # # Calibrated against a NATIVE x86_64 build of the same upstream commit # (18869a797dab, the recipe's SRCREV) with the same shipped configure, driven # green before it ever ran here -- so a red is a target-only divergence and not a # miscalibrated assertion. # # FOUR CASES ARE EXPECTED RED HERE, and both causes are the platform's rather # than the port's. They are deliberately left FAILING, not skipped, so they flip # green by themselves when the kernel is fixed: # * daemonize/reparented -> brintos/linux#35 (an exiting task does # not reparent its children, so a # daemon's ppid stays at the daemonize # process that already _exit(2)'d) # * daemonize/user-{uid,gid,pidfile-owner} # -> brintos/linux#19 (the whole credential # -setter family is unwired; setgid(2) # is ENOSYS, so -u dies before the fork. # busybox `su` fails identically on # setgroups(2) — nothing on this image # can drop privilege) # # THREE PLATFORM CONSTRAINTS SHAPE EVERY CASE BELOW, and each was learned the # hard way by a sibling suite rather than guessed at here: # * sleep(1) is a NO-OP on this target and timeout(1) never fires (the relative # timer path does not exist) -- so nothing here sleeps. Waiting is a poll of # /proc/uptime using shell builtins only, which forks nothing per iteration. # * `cmd & wait` HANGS on this image (job control, not this package -- see # 99-inotify-tools.test), so no case backgrounds anything and no case calls # wait. daemonize needs neither: it detaches by itself and its parent returns. # * every exec stages a multi-MB wasm binary, ~200-400ms, two orders more than # the native host. Cases are batched accordingly -- ONE daemon run reports # eight facts through a single file rather than eight runs reporting one each. . "$BST_LIB" # The recipe installs to ${sbindir}, so the tool is /usr/sbin/daemonize and a # root PATH that omits /usr/sbin would make `command -v` skip the whole suite # silently. Resolve both ways: the PATH lookup is asserted as its own case (a # tool nobody can type is a packaging fault worth seeing), and the suite itself # runs against the absolute path so one answer never hides the other. DZ_PATH=$(command -v daemonize 2>/dev/null) DZ=${DZ_PATH:-/usr/sbin/daemonize} if [ ! -x "$DZ" ]; then bst_skip daemonize/present "no daemonize at $DZ -- package not installed" return 0 2>/dev/null || exit 0 fi bst_ok daemonize/present case "$DZ" in /usr/*) check daemonize/sbin-path test -x /usr/sbin/daemonize ;; *) bst_skip daemonize/sbin-path "running a control build at $DZ, not an installed image" ;; esac if [ -n "$DZ_PATH" ]; then bst_ok daemonize/on-path else bst_notok daemonize/on-path "installed but not on PATH ($PATH)"; fi W=${TMPDIR:-/tmp}/bst-dz.$$ mkdir -p "$W" 2>/dev/null || { bst_notok daemonize/workdir "cannot mkdir $W"; exit 0; } chmod 777 "$W" 2>/dev/null # the -u case's daemon writes here as another user # --- helpers --------------------------------------------------------------- # wait_for : block until exists, bounded. Builtin-only -- # `read < /proc/uptime` is a redirect into a builtin, so an iteration forks # nothing; sleep would be a no-op here anyway. wait_for() { read _u _r < /proc/uptime; _s0=${_u%.*} while [ ! -e "$1" ]; do read _u _r < /proc/uptime; _n=${_u%.*} [ $((_n - _s0)) -ge "$2" ] && return 1 done return 0 } # wait_data : like wait_for, but waits for CONTENT. daemonize # creates a -o/-p file in the PARENT, before it forks, so `[ -e ]` is true long # before the daemon has written anything -- a race the native control lost on # its second run after winning it on the first. wait_data() { read _u _r < /proc/uptime; _s0=${_u%.*} while [ ! -s "$1" ]; do read _u _r < /proc/uptime; _n=${_u%.*} [ $((_n - _s0)) -ge "$2" ] && return 1 done return 0 } # wait_dead : wait until is gone or a zombie. Not "until # /proc/ disappears": an orphan is never reaped on this target (see # daemonize/reparented), so its directory outlives it and that loop would burn # the whole suite budget. Zombie == exited == every fd closed, which is the # condition the -l lock cases actually care about. wait_dead() { read _u _r < /proc/uptime; _s0=${_u%.*} while [ -r "/proc/$1/stat" ]; do read _p _c _st _rest < "/proc/$1/stat" 2>/dev/null [ "$_st" = Z ] && return 0 read _u _r < /proc/uptime; _n=${_u%.*} [ $((_n - _s0)) -ge "$2" ] && return 1 done return 0 } # dz : run daemonize, capturing combined output in DZ_OUT and status in # DZ_RC. Never let it read the suite's stdin. dz() { DZ_OUT=$("$DZ" "$@" 2>&1 : assert on the LAST dz() call's rc and output. rc_out() { if [ "$DZ_RC" = "$2" ]; then case "$DZ_OUT" in *"$3"*) bst_ok "$1" ;; *) bst_notok "$1" "rc ok ($2) but want:'$3' got:'$(_bst_1line "$DZ_OUT")'" ;; esac else bst_notok "$1" "rc=$DZ_RC want=$2 out:'$(_bst_1line "$DZ_OUT")'" fi } # fact : value of "=..." in $W/facts (written by the probe daemon). fact() { sed -n "s/^$1=//p" "$W/facts" 2>/dev/null; } # --- identity -------------------------------------------------------------- # Usage is an ERROR exit (rc 1) upstream, so capture it rather than using # check_out, which would fail on the status alone and say nothing about identity. dz rc_out daemonize/usage-rc 1 "Usage: daemonize [OPTIONS] path" rc_out daemonize/version 1 "daemonize, version 1.7.8" rc_out daemonize/usage-lock 1 "-l " rc_out daemonize/usage-user 1 "-u " # --- argument validation, all decided before any fork ---------------------- dz sh -c true rc_out daemonize/reject-relative 1 "must be an absolute path name" dz /no/such/thing rc_out daemonize/reject-missing 1 'File "/no/such/thing" is not executable.' dz /tmp rc_out daemonize/reject-directory 1 "is not regular file" dz -Z /bin/true rc_out daemonize/bad-option 1 "Bad option: -Z" dz -E NOEQUALS /bin/true rc_out daemonize/bad-env 1 "is not of the form name=value" # The execute-bit case. daemonize gates on access(2) X_OK before it forks, and # that gate is the ONLY thing standing between a mode-0644 file and an execve of # it -- so this pair is a direct probe of whether the target enforces the execute # bit, in a tool that asks the question explicitly rather than by accident. It # matters here because brintos/linux#34 records that execve on this arm does NOT # enforce it (a 0644 script runs where Linux gives root EACCES); if access(2) # has the same hole, daemonize would happily launch a non-executable file. The # native control runs this as ROOT IN A USER NAMESPACE, matching the target's # uid 0 -- an unprivileged control would be the credentials trap BRINTOS-243/-209 # both recorded, since access(X_OK) answers differently for root. printf '#!/bin/sh\necho ran > %s/ran644\n' "$W" > "$W/s644"; chmod 644 "$W/s644" dz "$W/s644" rc_out daemonize/reject-noexec 1 "is not executable" if wait_for "$W/ran644" 3; then bst_notok daemonize/noexec-not-run "the 0644 script EXECUTED anyway" else bst_ok daemonize/noexec-not-run fi # --- the core claim: fork + setsid + execvp -------------------------------- # ONE daemon run answers eight questions. It reports its own /proc facts and # then drops a sentinel, so the poll below waits on completion, not on a timer. # fds 9/8/7 hold the daemon's ORIGINAL stdout/stdin/stderr: reading # /proc/self/fd/1 inside `$(...)` would report the command substitution's pipe # instead, which is exactly the wrong answer (the native control caught it). rm -f "$W/facts" "$W/facts.done" "$W/pid" dz -p "$W/pid" /bin/sh -c "exec 9>&1 8<&0 7>&2 read a b c pp pg sd tt r < /proc/self/stat { echo \"pid=\$\$\"; echo \"ppid=\$pp\"; echo \"pgrp=\$pg\"; echo \"sid=\$sd\"; echo \"tty=\$tt\" echo \"fd0=\$(readlink /proc/self/fd/8)\"; echo \"fd1=\$(readlink /proc/self/fd/9)\" echo \"fd2=\$(readlink /proc/self/fd/7)\"; echo \"cwd=\$(readlink /proc/self/cwd)\" } > $W/facts echo d > $W/facts.done" if [ "$DZ_RC" = 0 ]; then bst_ok daemonize/parent-rc else bst_notok daemonize/parent-rc "rc=$DZ_RC out:'$(_bst_1line "$DZ_OUT")'"; fi if wait_for "$W/facts.done" 25; then bst_ok daemonize/daemon-ran dpid=$(fact pid) case "$(cat "$W/pid" 2>/dev/null)" in '' ) bst_notok daemonize/pidfile "PID file empty or absent" ;; *[!0-9]* ) bst_notok daemonize/pidfile "PID file not numeric: $(_bst_1line "$(cat "$W/pid")")" ;; * ) bst_ok daemonize/pidfile ;; esac check_eq daemonize/pidfile-is-daemon "$dpid" "$(cat "$W/pid" 2>/dev/null)" # A daemon is defined by these three: its parent is gone (reparented to init), # it leads its own session, and it leads its own process group. setsid(2) is # what buys the last two, and nothing else on this image proves setsid works. # The first is RED here and stays red: brintos/linux#35 — the wasm32 exit path # has no forget_original_parent() equivalent, so the ppid reported is the # daemonize process that already exited. The daemon itself runs correctly; # what is wrong is the process tree around it. check_eq daemonize/reparented 1 "$(fact ppid)" check_eq daemonize/session-lead "$dpid" "$(fact sid)" check_eq daemonize/pgroup-lead "$dpid" "$(fact pgrp)" check_eq daemonize/no-tty 0 "$(fact tty)" # ...and that its session is NOT the caller's, which is the whole point of the # fork: a daemon that merely inherited our session would pass the two above. read _a _b _c _pp _pg _mysid _r < /proc/self/stat if [ "$(fact sid)" = "$_mysid" ]; then bst_notok daemonize/session-detached "daemon shares the caller's session $_mysid" else bst_ok daemonize/session-detached fi # With no -o/-e, daemon(3) is called with noclose=0 and points all three # standard descriptors at /dev/null itself. check_eq daemonize/stdin-null /dev/null "$(fact fd0)" check_eq daemonize/stdout-null /dev/null "$(fact fd1)" check_eq daemonize/stderr-null /dev/null "$(fact fd2)" # Default working directory is "/" -- daemonize chdirs there itself (it passes # nochdir=1 to daemon(3) and does the chdir before and after the fork). check_eq daemonize/cwd-default / "$(fact cwd)" else for c in daemon-ran pidfile pidfile-is-daemon reparented session-lead pgroup-lead \ no-tty session-detached stdin-null stdout-null stderr-null cwd-default; do bst_notok "daemonize/$c" "daemon never completed (no $W/facts.done within 25s)" done fi # --- stdio redirection, cwd, environment, argv ----------------------------- # Two runs cover -o -e -a -c -E and argv passthrough. The second appends to the # first's file, so the append case also proves the first did not re-truncate it. rm -f "$W/so" "$W/se" "$W/d1" "$W/d2" dz -o "$W/so" -e "$W/se" /bin/sh -c "echo OUT-ONE; echo ERR-ONE 1>&2; echo x > $W/d1" wait_for "$W/d1" 25 check_eq daemonize/stdout-file OUT-ONE "$(cat "$W/so" 2>/dev/null)" check_eq daemonize/stderr-file ERR-ONE "$(cat "$W/se" 2>/dev/null)" dz -a -o "$W/so" -c /etc -E ZQ=v7 -E ZR=v8 /bin/sh -c "pwd; echo \"ZQ=\$ZQ ZR=\$ZR\"; echo x > $W/d2" wait_for "$W/d2" 25 _so=$(tr '\n' '|' < "$W/so" 2>/dev/null) check_eq daemonize/append "OUT-ONE|/etc|ZQ=v7 ZR=v8|" "$_so" check_eq daemonize/cwd-opt "/etc" "$(sed -n 2p "$W/so" 2>/dev/null)" check_eq daemonize/env-pass "ZQ=v7 ZR=v8" "$(sed -n 3p "$W/so" 2>/dev/null)" # Without -a the file is truncated, so the same file now holds only the new line. rm -f "$W/d3" dz -o "$W/so" /bin/sh -c "echo OUT-TWO; echo x > $W/d3" wait_for "$W/d3" 25 check_eq daemonize/truncate "OUT-TWO" "$(tr '\n' '|' < "$W/so" 2>/dev/null | sed 's/|$//')" # Everything after `path` is the daemon's argv, unparsed by daemonize. This one # has no sentinel of its own, so it waits on CONTENT -- the -o file itself is # created pre-fork and would otherwise be "ready" while still empty. rm -f "$W/args" dz -o "$W/args" /bin/echo alpha beta gamma wait_data "$W/args" 25 check_eq daemonize/argv "alpha beta gamma" "$(cat "$W/args" 2>/dev/null)" # --- -l: the flock(2) single-instance lock --------------------------------- # The one case that needs a LIVE second process, so the holder blocks on a FIFO # open rather than on a sleep that would never return. flock(2) is a distinct # syscall from the fcntl(F_SETLK) advisory locks that fail EINVAL platform-wide # (BRINTOS-214, dpkg-wasm-nolock.patch), so this is a real question and not a # restatement of that one. # The lock lives on a descriptor the daemon inherits across execvp, so it is # held until the DAEMON exits -- not until daemonize returns, which happens one # fork earlier. The second acquire therefore waits for the first holder to be # dead; skipping that wait is a race the native control lost on its second run. rm -f "$W/lp1" dz -l "$W/lk1" -p "$W/lp1" /bin/true if [ "$DZ_RC" = 0 ]; then bst_ok daemonize/lock-acquire else bst_notok daemonize/lock-acquire "rc=$DZ_RC out:'$(_bst_1line "$DZ_OUT")'"; fi wait_data "$W/lp1" 20 && wait_dead "$(cat "$W/lp1" 2>/dev/null)" 20 dz -l "$W/lk1" /bin/true if [ "$DZ_RC" = 0 ]; then bst_ok daemonize/lock-release else bst_notok daemonize/lock-release "second run refused a released lock: rc=$DZ_RC out:'$(_bst_1line "$DZ_OUT")'"; fi if mkfifo "$W/fifo" 2>/dev/null; then rm -f "$W/hp" dz -l "$W/lk2" -p "$W/hp" /bin/cat "$W/fifo" wait_for "$W/hp" 20 hp=$(cat "$W/hp" 2>/dev/null) if [ -n "$hp" ] && [ -d "/proc/$hp" ]; then dz -l "$W/lk2" /bin/true rc_out daemonize/lock-contend 1 "Is another instance running?" : > "$W/fifo" # let the holder's cat see EOF and exit wait_dead "$hp" 15 dz -l "$W/lk2" /bin/true if [ "$DZ_RC" = 0 ]; then bst_ok daemonize/lock-freed else bst_notok daemonize/lock-freed "lock not released after the holder exited: rc=$DZ_RC out:'$(_bst_1line "$DZ_OUT")'"; fi else bst_skip daemonize/lock-contend "the FIFO holder did not stay alive to hold the lock" bst_skip daemonize/lock-freed "no live holder to release" fi else bst_skip daemonize/lock-contend "mkfifo unavailable -- cannot hold a lock across a second invocation" bst_skip daemonize/lock-freed "mkfifo unavailable" fi # --- -u: setgid + initgroups + setuid -------------------------------------- # Root-only by construction (daemonize refuses it for anyone else), so on target # this runs and on an unprivileged control it is the refusal that is asserted. # `daemon` (uid 1, gid 1) comes from base-passwd and exists on every image here. # RED on target and staying red: brintos/linux#19 — setgid(2) has no syscall # table entry, so this dies "Can't set gid to 1: Function not implemented" at # daemonize.c's first credential call, before any fork. The suite asserts the # WORKING contract rather than the broken one, so the day the setters are wired # these three go green with no edit here. if [ "$(id -u)" = 0 ] && grep -q '^daemon:' /etc/passwd 2>/dev/null; then rm -f "$W/uid" "$W/d4" dz -u daemon -p "$W/upid" -o "$W/uid" /bin/sh -c "id -u; id -g; echo x > $W/d4" if wait_for "$W/d4" 25; then check_eq daemonize/user-uid 1 "$(sed -n 1p "$W/uid" 2>/dev/null)" check_eq daemonize/user-gid 1 "$(sed -n 2p "$W/uid" 2>/dev/null)" # daemonize chowns the PID file to the target user before dropping privilege. check_eq daemonize/user-pidfile-owner daemon "$(ls -l "$W/upid" 2>/dev/null | awk '{print $3}')" else bst_notok daemonize/user-uid "daemon never ran under -u daemon: rc=$DZ_RC out:'$(_bst_1line "$DZ_OUT")'" bst_notok daemonize/user-gid "see daemonize/user-uid" bst_notok daemonize/user-pidfile-owner "see daemonize/user-uid" fi else dz -u daemon /bin/true rc_out daemonize/user-refused-nonroot 1 "Must be root to specify a different user." bst_skip daemonize/user-uid "not uid 0 (or no 'daemon' user) -- -u is root-only" bst_skip daemonize/user-gid "not uid 0 (or no 'daemon' user)" bst_skip daemonize/user-pidfile-owner "not uid 0 (or no 'daemon' user)" fi # --- failure paths, each reported before the fork -------------------------- dz -p /no/such/dir/p /bin/true rc_out daemonize/err-pidfile 1 "Can't create PID file" dz -l /no/such/dir/l /bin/true rc_out daemonize/err-lockfile 1 "Can't create lock file" dz -c /no/such/dir /bin/true rc_out daemonize/err-cwd 1 "Can't change working directory" dz -o /no/such/dir/o /bin/true rc_out daemonize/err-outfile 1 'Can'"'"'t open "/no/such/dir/o" for stdout' # --- -v ------------------------------------------------------------------- # -v exits 0 and changes nothing functionally. Its messages, however, are # INVISIBLE whenever stdout is not a terminal, and that is upstream's stdio, not # this target's: verbose() writes through a fully-buffered stdout, then the # parent leaves via _exit(2) and the child via execvp(2), and neither flushes. # Measured identical on the native control, so the assertion is that the output # is empty -- a target that printed something would be the divergence. rm -f "$W/d5" dz -v -p "$W/vpid" /bin/sh -c "echo x > $W/d5" if [ "$DZ_RC" = 0 ]; then bst_ok daemonize/verbose-rc else bst_notok daemonize/verbose-rc "rc=$DZ_RC out:'$(_bst_1line "$DZ_OUT")'"; fi wait_for "$W/d5" 25 check_eq daemonize/verbose-buffered "" "$DZ_OUT" rm -rf "$W"