From 831f0c1b3a67fa1190ca053b642875d5964a1416 Mon Sep 17 00:00:00 2001 From: "Youri T. K. K. Mattar" Date: Tue, 11 Aug 2026 16:31:17 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(demo):=20a=20way=20in=20=E2=80=94=20a?= =?UTF-8?q?=20demo=20image,=20a=20release,=20and=20a=20README=20that=20sta?= =?UTF-8?q?rts=20at=20the=20beginning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything written so far was for somebody implementing an init system. Nothing was for somebody deciding whether to look at one, and the shortest path to running it needed a Rust toolchain, a musl target, QEMU, cpio and a kernel you had to go and find. The unit set was the problem. units/ is the test suite: a service that fails forever, one that hangs, one that ignores SIGTERM, one that requires something broken, one that never signals readiness. All deliberate, all necessary, and all of it reads as a crash to somebody seeing oxinit for the first time. demo/ is eight files that work, each showing one idea — ordering, readiness, logs, a schedule, socket activation. `cargo xtask demo` builds and runs it; the published image means the first command a newcomer types needs Docker and nothing else. listen-probe learned to answer HTTP, so socket activation can be demonstrated with curl. Nothing is running on port 8080; the request is what starts the service; the reply comes back on a connection that was never refused. That is the shortest path from "what is socket activation" to seeing it happen. The demo is tested, and that is the point. M13 found seven behaviours verified once and never again, the worst of them a unit file whose comment described a test nothing ran. A README's opening is the same hazard with a wider blast radius, so `cargo xtask test-demo` runs every command it gives a newcomer — oxctl list, oxctl logs counter, curl, and docker stop — and checks that webhook is *not* running before the request that starts it. It runs in CI. The release workflow fires on a pushed tag and nothing else: static binaries for both architectures with checksums, notes generated from the merged pull requests, and the demo image to ghcr.io. No tag is cut here. A version number is a claim about stability and the person making that claim should be the one typing it. Two mermaid diagrams in the README — what an init system is, and the service state machine — and a repair to CONTRIBUTING, where an earlier edit of mine had broken a sentence and left kernel instructions that contradicted the ones above them. --- .github/workflows/ci.yml | 25 ++++ .github/workflows/release.yml | 159 +++++++++++++++++++++++++ ARCHITECTURE.md | 3 +- CONTRIBUTING.md | 101 ++++++++-------- README.md | 122 ++++++++++++++++++- ROADMAP.md | 43 +++++++ crates/listen-probe/src/main.rs | 49 +++++++- crates/xtask/src/main.rs | 202 ++++++++++++++++++++++++++++++-- demo/clock.toml | 13 ++ demo/counter.toml | 14 +++ demo/default.toml | 16 +++ demo/greeter.toml | 9 ++ demo/logs.toml | 13 ++ demo/stamp.toml | 8 ++ demo/webhook-socket.toml | 9 ++ demo/webhook.toml | 11 ++ 16 files changed, 729 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 demo/clock.toml create mode 100644 demo/counter.toml create mode 100644 demo/default.toml create mode 100644 demo/greeter.toml create mode 100644 demo/logs.toml create mode 100644 demo/stamp.toml create mode 100644 demo/webhook-socket.toml create mode 100644 demo/webhook.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8029b52..9e0cc3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,31 @@ jobs: # beyond the build. - run: cargo xtask container + demo: + name: the README's demo + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + - run: rustup target add x86_64-unknown-linux-musl + - uses: Swatinem/rust-cache@v2 + - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cpio + + - name: Cache the boot artifacts + uses: actions/cache@v4 + with: + path: target/busybox-x86_64 + key: alpine-v3.22-x86_64-shell + + - run: cargo xtask fetch --arch x86_64 + + # Every command the README's opening section tells a newcomer to type. + # The demo is the first thing anybody sees, so it is the worst thing to + # let rot — and M13 already found what happens to an artifact nothing + # runs. A README that tells someone to type something that no longer + # works is worse than a README with nothing in it. + - run: cargo xtask test-demo + distro: name: a real userspace runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ee1e882 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,159 @@ +# Cut by pushing a tag: +# +# git tag -a v0.1.0 -m "…" && git push origin v0.1.0 +# +# Nothing here runs on its own. A version number is a claim about stability, +# and the person making that claim should be the one typing it. +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag to build, for a dry run against an existing tag" + required: true + +permissions: + contents: write + packages: write + +env: + CARGO_TERM_COLOR: always + +jobs: + binaries: + name: ${{ matrix.arch }} binaries + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + arch: [x86_64, aarch64] + steps: + - uses: actions/checkout@v4 + - run: rustup target add ${{ matrix.arch }}-unknown-linux-musl + - uses: Swatinem/rust-cache@v2 + + - name: Build + run: | + cargo build --release --target ${{ matrix.arch }}-unknown-linux-musl \ + -p oxinit -p oxctl -p oxlogd + + # Statically linked, so the tarball is the whole dependency: an init + # system that needed a loader from the filesystem it is about to mount + # would be a poor one. + - name: Pack + run: | + DIR=oxinit-${{ github.ref_name }}-${{ matrix.arch }}-linux-musl + mkdir -p "$DIR" + cp target/${{ matrix.arch }}-unknown-linux-musl/release/{oxinit,oxctl,oxlogd} "$DIR/" + cp README.md LICENSE-MIT LICENSE-APACHE docs/UNIT_FORMAT.md "$DIR/" + tar czf "$DIR.tar.gz" "$DIR" + sha256sum "$DIR.tar.gz" > "$DIR.tar.gz.sha256" + file "$DIR/oxinit" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.arch }} + path: | + *.tar.gz + *.tar.gz.sha256 + + image: + name: demo image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: rustup target add x86_64-unknown-linux-musl + - uses: Swatinem/rust-cache@v2 + - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cpio + + - name: Cache the boot artifacts + uses: actions/cache@v4 + with: + path: target/busybox-x86_64 + key: alpine-v3.22-x86_64-shell + + - run: cargo xtask fetch --arch x86_64 + + # The image the README's first section tells people to run. Built from + # demo/, not units/ — the latter is the test suite and contains services + # that fail on purpose. + - name: Build the demo image + run: cargo xtask demo --build-only --tag ghcr.io/${{ github.repository_owner }}/oxinit:demo + + - name: Log in to ghcr.io + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Push + run: | + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + docker tag ghcr.io/${{ github.repository_owner }}/oxinit:demo ghcr.io/$OWNER/oxinit:demo + docker tag ghcr.io/$OWNER/oxinit:demo ghcr.io/$OWNER/oxinit:${{ github.ref_name }} + docker push ghcr.io/$OWNER/oxinit:demo + docker push ghcr.io/$OWNER/oxinit:${{ github.ref_name }} + + release: + name: publish + runs-on: ubuntu-latest + needs: [binaries, image] + if: startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v4 + with: + merge-multiple: true + + # `--generate-notes` writes the pull requests merged since the previous + # tag. The milestone titles are the changelog, which is the reason PR + # titles in this repository are written as sentences. + - name: Create the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cat > header.md <<'EOF' + **Pre-alpha.** Nothing is stable, and the unit format may change + between any two releases. + + ### Try it + + ```bash + docker run --rm --name oxinit-demo -p 8080:8080 ghcr.io/OWNER/oxinit:TAG + ``` + + ### Install + + Download the tarball for your architecture below, check it, and put + the binaries somewhere on the target system. They are statically + linked against musl and need nothing else. + + ```bash + sha256sum -c oxinit-TAG-x86_64-linux-musl.tar.gz.sha256 + tar xzf oxinit-TAG-x86_64-linux-musl.tar.gz + ``` + + `oxinit` is the supervisor and belongs at `/sbin/init`; `oxctl` is + the client and `oxlogd` the log writer. Units go in + `/etc/oxinit/units/`, and every key is specified in + [docs/UNIT_FORMAT.md](docs/UNIT_FORMAT.md). + + Verified on x86_64 and aarch64, under QEMU, in a container, and + against a real Alpine userspace. See [ROADMAP.md](ROADMAP.md) for + what each milestone was checked against. + + --- + EOF + + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + sed -i "s|OWNER|$OWNER|g; s|TAG|${{ github.ref_name }}|g" header.md + + gh release create "${{ github.ref_name }}" \ + --title "${{ github.ref_name }}" \ + --notes-file header.md \ + --generate-notes \ + --prerelease \ + *.tar.gz *.tar.gz.sha256 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 05ba050..3e62559 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -963,7 +963,8 @@ tested without a kernel should not be in it. 3. Boots it: ```bash -qemu-system-x86_64 -kernel bzImage -initrd oxinit.cpio.gz -nographic -append "console=ttyS0" +qemu-system-x86_64 -kernel target/vmlinuz-x86_64 \ + -initrd target/oxinit-x86_64.cpio.gz -nographic -append "console=ttyS0" ``` Edit to boot is a few seconds. `-nographic` puts the guest's serial console on diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd4dacc..04e3d88 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,49 +22,38 @@ rustup target add aarch64-unknown-linux-musl MSRV is stable minus two releases. No nightly features are used. **Tools.** `qemu-system-x86_64`, `qemu-system-aarch64` and `cpio`. Docker or -podman as well, if you are running `cargo xtask container`. - -**Boot artifacts.** One command per architecture: - -```bash -cargo xtask fetch --arch x86_64 -cargo xtask fetch --arch aarch64 -``` - -That downloads a kernel and a statically linked busybox from Alpine into -`target/`, where `xtask` looks for them by name. `--kernel` and `--shell` -override, and so do `$OXINIT_KERNEL_` and `$OXINIT_SHELL_`, if you -would rather use a kernel you already have. +podman as well, for `cargo xtask container`, `test-distro` and `test-demo`. ```bash # Debian/Ubuntu -sudo apt install qemu-system-x86 cpio +sudo apt install qemu-system-x86 qemu-system-arm cpio # Fedora -sudo dnf install qemu-system-x86 cpio +sudo dnf install qemu-system-x86 qemu-system-aarch64 cpio # Arch -sudo pacman -S qemu-system-x86 cpio +sudo pacman -S qemu-system-x86 qemu-system-aarch64 cpio ``` -**Kernel.** You need a bootable `bzImage`. Any distribution kernel with cgroup -v2 and devtmpfs works; on most Linux systems the installed one is in `/boot`. +**Boot artifacts.** A kernel and a statically linked shell per architecture. +One command each: ```bash -cargo xtask boot --kernel /boot/vmlinuz-$(uname -r) +cargo xtask fetch --arch x86_64 +cargo xtask fetch --arch aarch64 ``` -Or set it once: +That downloads them from Alpine into `target/`, where `xtask` looks for them by +name — `vmlinuz-` and `busybox-`. To use a kernel you already have +instead, `--kernel` and `--shell` override, and so do `$OXINIT_KERNEL_` +and `$OXINIT_SHELL_`. On a Linux host the installed kernel usually works: ```bash -export OXINIT_KERNEL=/path/to/bzImage +cargo xtask boot --kernel /boot/vmlinuz-$(uname -r) ``` -`xtask` looks for `--kernel`, then `OXINIT_KERNEL`, then a `bzImage` in the -repository root. - -The kernel must have `CONFIG_DEVTMPFS`, `CONFIG_CGROUPS` with the v2 hierarchy, -and `CONFIG_BLK_DEV_INITRD`. Kernel 5.14 or later, for `cgroup.kill`. +Any kernel needs `CONFIG_DEVTMPFS`, `CONFIG_CGROUPS` with the v2 hierarchy, and +`CONFIG_BLK_DEV_INITRD`. Version 5.14 or later, for `cgroup.kill`. ## Development loop @@ -76,21 +65,18 @@ This builds a static `oxinit` for `x86_64-unknown-linux-musl`, packs it as `/init` into a cpio initramfs, and boots: ```bash -qemu-system-x86_64 -kernel bzImage -initrd oxinit.cpio.gz -nographic -append "console=ttyS0" +qemu-system-x86_64 -kernel target/vmlinuz-x86_64 \ + -initrd target/oxinit-x86_64.cpio.gz -nographic -append "console=ttyS0" ``` Edit to boot takes a few seconds. `-nographic` puts the guest serial console on your terminal. `Ctrl-A X` exits QEMU. `Ctrl-C` goes to the guest, not to QEMU. -The image holds only `/init` unless you give it a shell: - -```bash -cargo xtask boot --shell /path/to/busybox -``` - -It must be statically linked, since the image has no libraries. Without it the -boot still exercises the mounts, console, signalfd, and reap loop, but oxinit -has nothing to supervise and logs a spawn failure for `/bin/sh`. +The image holds only `/init` unless there is a shell to put in it, which is +what `cargo xtask fetch` provides. It must be statically linked, since the +image has no libraries. Without one the boot still exercises the mounts, the +console, the signalfd and the reap loop, but oxinit has nothing to supervise +and logs a spawn failure for `/bin/sh`. Use this loop rather than reasoning about whether the boot path works. Early boot has too many ways to fail silently. @@ -103,16 +89,14 @@ oxinit-graph` runs on any host with a Rust toolchain, and that is where most of Booting from macOS needs two extra pieces: -- **A cross linker.** `rustup target add x86_64-unknown-linux-musl -rustup target add aarch64-unknown-linux-musl` installs the - standard library but not something that can link it. Use - [`cargo-zigbuild`](https://github.com/rust-cross/cargo-zigbuild), which needs - only `zig`, or a `musl-cross` toolchain from Homebrew with the linker - configured in `.cargo/config.toml`. -- **Patience on Apple Silicon.** `qemu-system-x86_64` there is full emulation, - not virtualization, so the boot is a few seconds rather than under one. It - works. Building for `aarch64-unknown-linux-musl` and booting - `qemu-system-aarch64` is faster but is not the tested path. +- **A cross linker.** `rustup target add` installs the standard library but not + something that can link it. `.cargo/config.toml` in this repository points + both musl targets at `rust-lld`, which ships with every rustup toolchain, so + this is already handled — but that is why the file exists. +- **Patience for the foreign architecture.** Emulating the one your host is not + has no hardware acceleration behind it, and the whole boot runs through + QEMU's JIT. Both are tested — `cargo xtask test-boot --arch all` — and the + slower of the two takes about ninety seconds rather than about thirty. A Linux VM with a distribution kernel is the shorter route if you are doing kernel-facing work. @@ -137,12 +121,14 @@ cargo test -p oxinit-timer # M8: schedules and the calendar cargo xtask test-boot # M5: boots QEMU, asserts on serial cargo xtask test-boot --arch all # M9: x86_64 and aarch64, in turn cargo xtask container # M6: runs it in Docker, same +cargo xtask test-demo # M16: every command the README gives cargo xtask test-distro # M10: a real Alpine userspace ``` -`oxinit-unit` and `oxinit-graph` will have no Linux dependencies and will run -anywhere. Parser and graph logic belongs there, with tests, rather than in -`oxinit`. +The library crates have no Linux dependencies and run anywhere. Parser, graph, +restart policy, log format and calendar arithmetic belong there, with tests, +rather than in `oxinit` — which refuses to compile off Linux and so cannot be +tested on a host at all. `test-boot` boots with a timeout and matches expected lines in the serial log. A test that hangs fails on the timeout instead of blocking the run. @@ -152,6 +138,10 @@ packs the same staged root filesystem as a `FROM scratch` image, runs it, stops it the way an operator would, and asserts on the log and the exit code. `--privileged` adds a writable cgroupfs and the assertions that need one. +`test-demo` runs every command the README's opening section tells a newcomer to +type, against the image built from `demo/`. That README is the first thing +anybody reads, and this is what stops it quietly ceasing to be true. + ## Code policy Full rules are in [CLAUDE.md](CLAUDE.md). The ones that get PRs rejected: @@ -165,11 +155,14 @@ in PID 1 is a kernel panic. **No async in `crates/oxinit`.** One thread, one `epoll` loop. Other crates are unconstrained. -**Unsafe is quarantined.** Syscalls go through `rustix`. Anything `rustix` does -not cover lives in `oxinit::sys::raw`, with a `// SAFETY:` comment per block -stating the invariant and why it holds at that call site. A PR adding `unsafe` -elsewhere will be asked to move it. A PR adding `unsafe` without a `// SAFETY:` -comment will be asked to write one. +**Unsafe is quarantined, and the build enforces it.** Syscalls go through +`rustix`. Anything `rustix` does not cover lives in `oxinit::sys::raw`, with a +`// SAFETY:` comment per block stating the invariant and why it holds at that +call site. The `oxinit` crate denies `unsafe_code` with that one module +relaxing it, and every crate that should contain none carries `forbid`, so a PR +adding `unsafe` elsewhere does not compile rather than being asked to move it. +A PR adding one to `sys::raw` without a `// SAFETY:` comment will be asked to +write one. **Dependencies need a reason.** The dependency tree of PID 1 is part of its attack surface. State the reason in the commit message. diff --git a/README.md b/README.md index af3ac86..49733f9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,88 @@ A service manager and PID 1 for Linux, written in Rust. **Pre-alpha. Nothing is stable.** See [ROADMAP.md](ROADMAP.md) for what works. +## What this is, in one picture + +An init system is the first program the kernel starts. It is process number 1, +and it is responsible for every other process on the machine: starting them in +the right order, restarting them when they die, and stopping them cleanly when +the machine goes down. If it exits, the kernel panics and the machine is gone. + +```mermaid +flowchart TD + K[Linux kernel] -->|starts as PID 1| O[oxinit] + O -->|starts, in order| A[database] + O -->|restarts when it dies| B[web server] + O -->|starts on a schedule| C[backup job] + O -->|starts on a connection| D[rarely-used service] + O -.->|stdout and stderr| L[oxlogd → /var/log/oxinit] + E[you] -->|oxctl| O +``` + +`oxctl` and `oxlogd` are separate programs that talk to oxinit over a socket. +That is deliberate: a bug in the log writer kills the log writer, and a bug in +PID 1 kills the machine. + +## Try it — one command + +You need [Docker](https://docs.docker.com/get-started/get-docker/) and nothing +else. No Rust, no virtual machine, no kernel. + +```bash +docker run --rm --name oxinit-demo -p 8080:8080 ghcr.io/youhide/oxinit:demo +``` + +oxinit boots as PID 1 inside the container and starts a handful of small +services. You will see it resolve them in order, report each one, and then sit +there supervising. + +The first few lines are oxinit saying what the container runtime will not let +it do — mount `/run`, set the hostname, write to the cgroup filesystem. That is +not a failure and not noise: a supervisor that pretended those worked would be +lying about the machine it is on. + +**Look at what it is running.** In a second terminal: + +```bash +docker exec oxinit-demo /bin/oxctl list +``` + +**Read a service's log.** `counter` writes a line every two seconds, into its +own file rather than onto the console: + +```bash +docker exec oxinit-demo /bin/oxctl logs counter +``` + +**Watch a service start because you asked for something.** Nothing is +listening on port 8080 — no process, that is, though the port is bound. The +request is what starts the service: + +```bash +curl localhost:8080 +``` + +**Stop it the way a real system would.** `docker stop` sends `SIGTERM`, which +is a request to shut down, not a kill: + +```bash +docker stop oxinit-demo +``` + +oxinit stops every service in the reverse of the order it started them, waits +for each to actually be gone, and exits `0`. Most container images have to be +killed after a ten-second grace period; that shows up as exit code 137. + +The units the demo runs are in [demo/](demo/), one small file each, and they +are meant to be read. + +To build and run it yourself instead of pulling the published image: + +```bash +cargo xtask fetch --arch x86_64 # a kernel and a busybox, into target/ +cargo xtask demo # build the image and run it +``` + ## Why Every init system in production use is written in C: systemd, OpenRC, runit, @@ -115,6 +197,40 @@ clock being corrected under it. A firing that arrives while the service is still running is skipped rather than queued, and a failed run does not stop the schedule. +## What oxinit does with a service + +Every service moves through the same states, and the edges are what the +`restart` policy and the readiness `type` decide between. + +```mermaid +stateDiagram-v2 + [*] --> Inactive + Inactive --> Activating: start requested,
and everything it is `after` has finished + Activating --> Active: ready — depends on `type` + Activating --> Failed: exec failed, or `start-sec` expired + Activating --> Inactive: a `oneshot` exited 0 + Active --> Deactivating: stop requested, or a watchdog miss + Active --> Failed: exited nonzero, or on a signal + Deactivating --> Inactive: its cgroup emptied in time + Deactivating --> Failed: `stop-sec` expired, killed by cgroup + Failed --> Restarting: the `restart` policy applies + Inactive --> Restarting: the `restart` policy applies + Restarting --> Activating: the backoff elapsed +``` + +"Ready" is the interesting one, and it is what `type` selects: + +| `type` | Becomes `Active` when | +|-----------|-----------------------------------------------------------------| +| `simple` | `exec` succeeded. No more accurate than that. | +| `oneshot` | The process exited with status 0 — and it goes `Inactive`, not `Active`. | +| `notify` | The service sent `READY=1` on the notify socket. | +| `forking` | The initial process exited and the cgroup still has something in it. | + +Backoff doubles per consecutive failure and resets once the service has stayed +up longer than its own current delay — without that, a service that crashes +once a day eventually takes hours to come back. + ## oxctl `oxctl` talks to PID 1 over `/run/oxinit/control.sock`. It is a separate @@ -226,12 +342,13 @@ container's PID 1. | **M13** | Coverage for seven behaviours the milestones claimed and nothing re-ran. | | **M14** | Calendar schedules on the wall clock; datagram sockets; the list closed. | | **M15** | The documents' invariants enforced by the build, not by discipline. | +| **M16** | A demo, a released binary, and a README that starts at the beginning. | One `epoll` loop multiplexes the signalfd, the timerfd, the notify socket, the control socket, every socket unit's listening descriptor and every service cgroup's `cgroup.events`. One thread. No async runtime. -Nothing is scheduled after M15. [ROADMAP.md](ROADMAP.md) has the breakdown — +Nothing is scheduled after M16. [ROADMAP.md](ROADMAP.md) has the breakdown — what each milestone was verified against, what was deferred out of it, and which remaining ideas are deliberately not being taken. @@ -254,7 +371,8 @@ This builds a static binary for `x86_64-unknown-linux-musl`, packs it into a single-file cpio initramfs, and boots it: ```bash -qemu-system-x86_64 -kernel bzImage -initrd oxinit.cpio.gz -nographic -append "console=ttyS0" +qemu-system-x86_64 -kernel target/vmlinuz-x86_64 \ + -initrd target/oxinit-x86_64.cpio.gz -nographic -append "console=ttyS0" ``` Edit to boot takes a few seconds. `Ctrl-A X` exits QEMU. Setup details are in diff --git a/ROADMAP.md b/ROADMAP.md index 760d773..d550b37 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -894,6 +894,49 @@ CLAUDE.md and ARCHITECTURE.md and had never existed. The integration tests are the `xtask` suites, and the directory that does exist and was not listed is `units/`. +## M16 — A way in + +**Done.** + +Everything above is written for someone implementing an init system. Nothing +was written for someone deciding whether to look at one, and the shortest path +to running it required a Rust toolchain, a musl target, QEMU, `cpio` and a +kernel you had to go and find. + +- [x] `demo/`: eight units that show what oxinit does, none of which fail on + purpose. +- [x] `cargo xtask demo`, and a published image, so the first command needs + Docker and nothing else. +- [x] A README opening that explains what an init system *is* before saying + why this one exists, with diagrams. +- [x] `cargo xtask test-demo`, in CI, so the README cannot quietly stop being + true. +- [x] A release workflow: static binaries for both architectures, checksums, + generated notes, and the demo image on ghcr.io. + +**The unit set was the problem.** `units/` is the test suite. It contains a +service that fails forever, one that hangs, one that ignores `SIGTERM`, one +that requires something broken, and one that never signals readiness — all +deliberate, all necessary, and all of it reads as a crash to somebody seeing +oxinit for the first time. `demo/` is eight files that work, each one showing +exactly one idea: ordering, readiness, logs, a schedule, socket activation. + +**The demo is tested, and that is the point.** M13 found seven behaviours that +had been verified once and never again, and the worst of them had a unit file +whose comment described a test nothing ran. The README's opening is the same +hazard with a wider blast radius: `test-demo` runs every command it tells a +newcomer to type — `oxctl list`, `oxctl logs counter`, `curl localhost:8080`, +`docker stop` — and checks that `webhook` is *not* running before the request +that starts it. + +**No tag is cut here.** The workflow fires on a pushed tag and nothing else. A +version number is a claim about stability, and the person making that claim +should be the one typing it. + +Verified: the demo boots, `curl localhost:8080` returns from a service that +was inactive a moment earlier, `oxctl logs counter` shows timestamped ticks, +and `docker stop` exits 0 in under a second. + ## Not doing, and why These were on the list. They are coming off it with a reason rather than diff --git a/crates/listen-probe/src/main.rs b/crates/listen-probe/src/main.rs index c89bd0a..181a493 100644 --- a/crates/listen-probe/src/main.rs +++ b/crates/listen-probe/src/main.rs @@ -7,6 +7,7 @@ //! Usage: //! //! ```text +//! listen-probe --http NAME # the activated service, answering HTTP //! listen-probe --serve NAME # the activated service, over a stream //! listen-probe --recv NAME # the activated service, over a datagram //! listen-probe --connect PATH # the client that triggers a stream @@ -25,6 +26,7 @@ use std::io::{Read as _, Write as _}; use std::net::Shutdown; +use std::net::TcpListener; use std::os::fd::FromRawFd as _; use std::os::unix::net::{UnixDatagram, UnixListener, UnixStream}; use std::process::ExitCode; @@ -35,7 +37,9 @@ const FIRST_LISTEN_FD: i32 = 3; fn main() -> ExitCode { let args: Vec = std::env::args().collect(); - let result = if let Some(name) = flag(&args, "--serve") { + let result = if let Some(name) = flag(&args, "--http") { + http(name) + } else if let Some(name) = flag(&args, "--serve") { serve(name) } else if let Some(name) = flag(&args, "--recv") { recv(name) @@ -44,7 +48,7 @@ fn main() -> ExitCode { } else if let Some(path) = flag(&args, "--send") { send(path) } else { - Err("usage: listen-probe --serve|--recv NAME | --connect|--send PATH".to_owned()) + Err("usage: listen-probe --http|--serve|--recv NAME | --connect|--send PATH".to_owned()) }; match result { @@ -122,6 +126,47 @@ fn inherited(name: &str) -> Result { Ok(index) } +/// Answer one HTTP request and exit. +/// +/// Not a web server. It exists so that the demo image can be poked with +/// `curl`, which is the shortest path from "what is socket activation" to +/// seeing it happen: nothing is running, the request arrives, oxinit starts +/// this, and the reply comes back on a connection that was never refused. +fn http(name: &str) -> Result<(), String> { + let index = inherited(name)?; + + // SAFETY: the descriptor was inherited from oxinit at this number, nothing + // else in this process owns it, and the protocol says it is a listening + // socket. A test fixture, not PID 1. + let listener = unsafe { TcpListener::from_raw_fd(FIRST_LISTEN_FD + index) }; + + let (mut stream, from) = listener.accept().map_err(|e| format!("accept: {e}"))?; + println!("listen-probe: connection from {from}"); + + // Read and discard the request line. Answering without reading would have + // the client see a reset before it finished writing. + let mut request = [0u8; 1024]; + let _ = stream.read(&mut request); + + const BODY: &str = "oxinit started this service because you connected.\n\ + It was not running a moment ago.\n"; + + let response = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: text/plain\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n{BODY}", + BODY.len() + ); + + stream + .write_all(response.as_bytes()) + .map_err(|e| format!("write: {e}"))?; + + Ok(()) +} + /// The datagram half of `serve`. /// /// The datagram that started this service is already in the socket's queue — diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index c707c26..e26776b 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -99,6 +99,8 @@ fn main() { .map(|path| println!("{}", path.display())), "test-boot" => test_boot_all(&rest), "container" => container(&rest), + "demo" => demo(&rest), + "test-demo" => test_demo(&rest), "test-distro" => test_distro(&rest), "fetch" => fetch(&rest), "" | "help" | "--help" | "-h" => { @@ -123,6 +125,8 @@ commands: boot build, pack an initramfs, and boot it under QEMU test-boot boot with a timeout and assert on the serial log container build a container image, run it, and assert on its output + demo build the demo image and run it, the way the README says to + test-demo build the demo image and check every claim the README makes test-distro boot a real distribution userspace and assert on the serial log fetch download the kernel and busybox a boot needs, into target/ @@ -889,6 +893,150 @@ const BOOT_TIMEOUT: u32 = 30; /// 137 no matter how long the wait was. const STOP_GRACE: u32 = 30; +/// The image the README tells a newcomer to run. +/// +/// Same build path as `container`, from `demo/` instead of `units/`, and it +/// runs in the foreground so that what a reader sees here is exactly what the +/// README says they will see. +fn demo(args: &[String]) -> Result<(), String> { + let engine = flag(args, "--engine")?.unwrap_or("docker").to_owned(); + let arch = find_arch(args)?; + let tag = flag(args, "--tag")?.unwrap_or(DEMO_IMAGE).to_owned(); + + let shell = find_shell(arch, args)?; + if shell.is_none() { + return Err(format!( + "the demo needs a shell in the image. Run `cargo xtask fetch --arch {}` first.", + arch.name + )); + } + + let binary = build(arch)?; + let staging = stage_units(arch, &binary, shell.as_deref(), false, "demo")?; + let dockerfile = write_dockerfile()?; + + println!("xtask: building {tag}"); + run(Command::new(&engine).args([ + "build".as_ref(), + "-q".as_ref(), + "-t".as_ref(), + tag.as_ref(), + "-f".as_ref(), + dockerfile.as_os_str(), + staging.as_os_str(), + ]))?; + + if args.iter().any(|arg| arg == "--build-only") { + println!("xtask: built {tag}"); + return Ok(()); + } + + remove_named(&engine, DEMO_NAME); + + println!("xtask: running it. Ctrl-C, or `{engine} stop {DEMO_NAME}`, ends it.\n"); + run(Command::new(&engine).args(["run", "--rm", "--name", DEMO_NAME, "-p", "8080:8080", &tag])) +} + +/// Every command the README's opening section tells a newcomer to run, and +/// what each one has to produce. +/// +/// The demo is the first thing anybody sees, so it is the worst thing to let +/// rot — and this project has already learned once, in M13, that an artifact +/// nothing runs stops being true quietly. A README that tells someone to type +/// something that no longer works is worse than a README with nothing in it. +fn test_demo(args: &[String]) -> Result<(), String> { + let engine = flag(args, "--engine")?.unwrap_or("docker").to_owned(); + let mut build = args.to_vec(); + build.push("--build-only".to_owned()); + demo(&build)?; + + remove_named(&engine, DEMO_NAME); + run(Command::new(&engine) + .args([ + "run", + "-d", + "--name", + DEMO_NAME, + "-p", + "8080:8080", + DEMO_IMAGE, + ]) + .stdout(Stdio::null()))?; + + let result = drive_demo(&engine); + remove_named(&engine, DEMO_NAME); + result +} + +fn drive_demo(engine: &str) -> Result<(), String> { + let mut failures = Vec::new(); + + println!("xtask: waiting for the demo to finish booting"); + if !wait_for_line_in(engine, DEMO_NAME, "oxinit: reached target default", 30)? { + return Err("the demo never reached its default target".to_owned()); + } + + // `docker exec oxinit-demo /bin/oxctl list` + let list = exec_in(engine, DEMO_NAME, &["/bin/oxctl", "list"])?; + for unit in ["clock", "counter", "logs", "webhook-socket"] { + if !list.contains(unit) { + failures.push(format!("`oxctl list` does not mention `{unit}`")); + } + } + + // `docker exec oxinit-demo /bin/oxctl logs counter` + let logs = exec_in(engine, DEMO_NAME, &["/bin/oxctl", "logs", "counter"])?; + if !logs.contains("tick ") { + failures.push("`oxctl logs counter` shows no ticks".to_owned()); + } + + // `curl localhost:8080` — the socket-activated service, which was not + // running until this line. + let before = exec_in(engine, DEMO_NAME, &["/bin/oxctl", "status", "webhook"])?; + if !before.contains("inactive") { + failures.push("`webhook` was already running before anything connected".to_owned()); + } + + let answer = capture("curl", &["-s", "--max-time", "10", "http://localhost:8080"])?; + if !answer.contains("oxinit started this service because you connected") { + failures.push(format!("curl localhost:8080 said {answer:?}")); + } + + // `docker stop` — the whole point, and the thing most images fail. + println!("xtask: stopping it the way the README says to"); + let started = std::time::Instant::now(); + run(Command::new(engine) + .args(["stop", "-t", "30", DEMO_NAME]) + .stdout(Stdio::null()))?; + let took = started.elapsed(); + + let status = inspect_named(engine, DEMO_NAME, "{{.State.ExitCode}}")?; + println!( + "xtask: stopped in {:.1}s, exit code {status}", + took.as_secs_f32() + ); + if status != "0" { + failures.push(format!( + "exit code {status}, not 0 — the README promises a clean stop" + )); + } + + if failures.is_empty() { + println!("xtask: every command the README gives a newcomer works"); + return Ok(()); + } + + Err(format!( + "{} README claim(s) failed:\n {}", + failures.len(), + failures.join("\n ") + )) +} + +/// The published demo image, and the container it runs as. +const DEMO_IMAGE: &str = "oxinit:demo"; +const DEMO_NAME: &str = "oxinit-demo"; + /// `test-boot` for a runtime that is not QEMU. /// /// Builds the same root filesystem the initramfs is packed from, as a @@ -907,7 +1055,7 @@ fn container(args: &[String]) -> Result<(), String> { // `false`, unlike `test-boot`: no unit that ends the test by signalling // PID 1, because what ends this one is the runtime's stop — which is the // thing being tested. - let staging = stage(arch, &binary, shell.as_deref(), false)?; + let staging = stage_units(arch, &binary, shell.as_deref(), false, "units")?; let dockerfile = write_dockerfile()?; println!("xtask: building {CONTAINER_IMAGE} with {engine}"); @@ -1042,8 +1190,12 @@ fn survives_a_shipper_restart(engine: &str) -> Result, String> { /// Run something inside the container and return its standard output. fn exec(engine: &str, argv: &[&str]) -> Result { + exec_in(engine, CONTAINER_NAME, argv) +} + +fn exec_in(engine: &str, name: &str, argv: &[&str]) -> Result { let mut command = Command::new(engine); - command.args(["exec", CONTAINER_NAME]).args(argv); + command.args(["exec", name]).args(argv); let out = command .output() @@ -1062,8 +1214,12 @@ fn exec(engine: &str, argv: &[&str]) -> Result { /// Poll the log until `needle` shows up. `false` on timeout. fn wait_for_line(engine: &str, needle: &str, seconds: u32) -> Result { + wait_for_line_in(engine, CONTAINER_NAME, needle, seconds) +} + +fn wait_for_line_in(engine: &str, name: &str, needle: &str, seconds: u32) -> Result { for _ in 0..seconds * 5 { - if logs(engine)?.contains(needle) { + if logs_of(engine, name)?.contains(needle) { return Ok(true); } std::thread::sleep(std::time::Duration::from_millis(200)); @@ -1077,8 +1233,12 @@ fn wait_for_line(engine: &str, needle: &str, seconds: u32) -> Result Result { + logs_of(engine, CONTAINER_NAME) +} + +fn logs_of(engine: &str, name: &str) -> Result { let out = Command::new(engine) - .args(["logs", CONTAINER_NAME]) + .args(["logs", name]) .output() .map_err(|e| format!("run {engine} logs: {e}"))?; @@ -1086,8 +1246,12 @@ fn logs(engine: &str) -> Result { } fn inspect(engine: &str, format: &str) -> Result { + inspect_named(engine, CONTAINER_NAME, format) +} + +fn inspect_named(engine: &str, name: &str, format: &str) -> Result { let out = Command::new(engine) - .args(["inspect", "-f", format, CONTAINER_NAME]) + .args(["inspect", "-f", format, name]) .output() .map_err(|e| format!("run {engine} inspect: {e}"))?; @@ -1096,8 +1260,12 @@ fn inspect(engine: &str, format: &str) -> Result { /// Best-effort, and called on every path out of a run. fn remove(engine: &str) { + remove_named(engine, CONTAINER_NAME); +} + +fn remove_named(engine: &str, name: &str) { let _ = Command::new(engine) - .args(["rm", "-f", CONTAINER_NAME]) + .args(["rm", "-f", name]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); @@ -1165,7 +1333,23 @@ fn pack_initramfs( /// `/bin/sh`. Without it the boot still proves the mount, console, signalfd, /// and reap paths, which is most of M0. fn stage(arch: Arch, binary: &Path, shell: Option<&Path>, test: bool) -> Result { - let staging = root().join(format!("target/initramfs-{}", arch.name)); + stage_units(arch, binary, shell, test, "units") +} + +/// As [`stage`], but from a named unit directory. +/// +/// `units/` is the test suite: it holds services that fail on purpose, hang on +/// purpose and refuse to stop, because that is what a test image is for. +/// `demo/` is the small clean set the published image runs — none of that +/// belongs in the first thing somebody sees. +fn stage_units( + arch: Arch, + binary: &Path, + shell: Option<&Path>, + test: bool, + units: &str, +) -> Result { + let staging = root().join(format!("target/initramfs-{}-{units}", arch.name)); let _ = fs::remove_dir_all(&staging); fs::create_dir_all(&staging).map_err(|e| format!("create {}: {e}", staging.display()))?; @@ -1210,7 +1394,7 @@ fn stage(arch: Arch, binary: &Path, shell: Option<&Path>, test: bool) -> Result< // Units, if any. `/etc` rather than `/usr/lib`, since a hand-assembled // test image is the operator's, not a package's. - let units_src = root().join("units"); + let units_src = root().join(units); if units_src.is_dir() { let units_dst = staging.join("etc/oxinit/units"); fs::create_dir_all(&units_dst) @@ -1229,7 +1413,7 @@ fn stage(arch: Arch, binary: &Path, shell: Option<&Path>, test: bool) -> Result< } } } - println!("xtask: installed {count} units from units/"); + println!("xtask: installed {count} units from {units}/"); if test { install_test_shutdown(&units_dst)?; diff --git a/demo/clock.toml b/demo/clock.toml new file mode 100644 index 0000000..990bc7a --- /dev/null +++ b/demo/clock.toml @@ -0,0 +1,13 @@ +# A timer. Starts `stamp` five seconds in, then every fifteen seconds after +# that — measured from the previous firing, not from a clock. +# +# `on-calendar = "hourly"` would put it on the wall clock instead, so a +# correction to the system time moves it to the right hour rather than by the +# size of the correction. +[unit] +description = "Runs the stamp job on a schedule" + +[timer] +service = "stamp" +on-boot = "5s" +interval = "15s" diff --git a/demo/counter.toml b/demo/counter.toml new file mode 100644 index 0000000..8413896 --- /dev/null +++ b/demo/counter.toml @@ -0,0 +1,14 @@ +# A long-running service whose output goes to a file instead of the console. +# +# Read it with `oxctl logs counter`. Each line is timestamped by oxlogd, and +# the file is rotated by size so a chatty service cannot fill the disk. +[unit] +description = "Counts, into its own log" +after = ["logs"] +requires = ["logs"] + +[service] +exec = '/bin/sh -c "n=1; while /bin/true; do /bin/echo \"tick $n\"; n=$((n+1)); /bin/sleep 2; done"' +output = "log" +restart = "on-failure" +stop-sec = "2s" diff --git a/demo/default.toml b/demo/default.toml new file mode 100644 index 0000000..3effea1 --- /dev/null +++ b/demo/default.toml @@ -0,0 +1,16 @@ +# The demo. Everything reachable from here starts at boot. +# +# Deliberately small. The unit set under units/ is the test suite — it contains +# services that fail on purpose, hang on purpose, and refuse to stop, because +# that is what a test image is for. None of that belongs in the first thing +# somebody sees. +[target] + +[unit] +description = "Demo" +requires = ["logs"] +# `webhook-socket`, not `webhook`. The socket is what boot brings up; the +# service it names is deliberately unreachable from here, so the only thing +# that ever starts it is a connection. +wants = ["greeter", "clock", "webhook-socket", "counter"] +after = ["greeter"] diff --git a/demo/greeter.toml b/demo/greeter.toml new file mode 100644 index 0000000..cfc0fcb --- /dev/null +++ b/demo/greeter.toml @@ -0,0 +1,9 @@ +# Runs once and exits. `oneshot` means "started" is not enough — the unit is +# not finished until the process has exited successfully, and anything ordered +# after it waits for that. +[unit] +description = "Says hello, once" + +[service] +type = "oneshot" +exec = '/bin/sh -c "/bin/echo; /bin/echo \" oxinit is PID 1 here. Try: docker exec -it oxinit-demo /bin/oxctl list\"; /bin/echo"' diff --git a/demo/logs.toml b/demo/logs.toml new file mode 100644 index 0000000..f0fcf60 --- /dev/null +++ b/demo/logs.toml @@ -0,0 +1,13 @@ +# The log writer. Everything with `output = "log"` goes through this, and +# `oxctl logs NAME` reads what it wrote. +# +# `notify` means a unit ordered after this one does not start until oxlogd has +# actually connected — not merely until it has been exec'd. +[unit] +description = "Log writer" + +[service] +type = "notify" +exec = "/bin/oxlogd" +restart = "always" +restart-sec = "500ms" diff --git a/demo/stamp.toml b/demo/stamp.toml new file mode 100644 index 0000000..cef41e8 --- /dev/null +++ b/demo/stamp.toml @@ -0,0 +1,8 @@ +# Started only by the timer. Nothing wants this unit, so boot never starts it — +# which is the point of a schedule. +[unit] +description = "Started by the clock" + +[service] +type = "oneshot" +exec = "/bin/date -u" diff --git a/demo/webhook-socket.toml b/demo/webhook-socket.toml new file mode 100644 index 0000000..f25dd57 --- /dev/null +++ b/demo/webhook-socket.toml @@ -0,0 +1,9 @@ +# oxinit binds this port at boot and holds it. Nothing runs until something +# connects — and because the socket is bound before any service starts, +# a client is never refused while the service comes up. +[unit] +description = "Webhook socket" + +[socket] +listen = ["0.0.0.0:8080"] +service = "webhook" diff --git a/demo/webhook.toml b/demo/webhook.toml new file mode 100644 index 0000000..57414ae --- /dev/null +++ b/demo/webhook.toml @@ -0,0 +1,11 @@ +# Started by a connection, not by boot. oxinit hands it the already-bound +# listening socket as file descriptor 3, and tells it so through LISTEN_FDS. +# +# Try it from your own machine: curl localhost:8080 +[unit] +description = "Socket-activated service" + +[service] +type = "oneshot" +exec = "/bin/listen-probe --http webhook-socket" +restart = "no" From 4440acaf977517496562ccb7f9958535da66fda3 Mon Sep 17 00:00:00 2001 From: "Youri T. K. K. Mattar" Date: Tue, 11 Aug 2026 16:44:57 -0300 Subject: [PATCH 2/2] fix(oxinit): do not take the restart policy on the way down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI found this while M16 was in review: the distribution suite failed one assertion that had passed on main and had nothing to do with the demo. It reproduced as a hang rather than as the missing line it reported. A unit whose stop *completes* during a shutdown took its restart policy and went to Restarting. `settled` waits for every unit that is not Inactive or Failed, and nothing starts one again on the way down, so the machine sat there until the ninety-second whole-shutdown deadline and then went down anyway with everything already stopped. `stop` has always cancelled a pending backoff for exactly this reason, and the comment saying so is still there. The hole was the stop that finishes afterwards — reachable only when the cause was a watchdog miss, because a requested stop overrides the policy and shutdown's own stops are requested. Two things came out of it. The clock that ends a test image is now anchored to the default target rather than to the start of boot. Counting from boot made the budget the sum of two unrelated things: how long the machine takes to come up, which varies with the image and the host, and how long the assertions need, which does not. M11 made boot wait for each unit to finish activating rather than merely to be issued, and M13 and M14 added units to wait for; the number had not moved since M5. And the bug got a unit. `lingers` misses its watchdog thirteen seconds after the target and ignores SIGTERM, so only cgroup.kill ends it twelve seconds later, and the image asks for a shutdown sixteen seconds in — inside that window with margin on both sides. Reproducing something by luck once is not a regression test. 42 checks per architecture, 44 against the distribution image. --- ROADMAP.md | 26 ++++++++++++++++++++++++++ crates/oxinit/src/supervisor.rs | 20 ++++++++++++++++++++ crates/xtask/src/main.rs | 31 ++++++++++++++++++++++++++----- units/default.toml | 1 + units/lingers.toml | 24 ++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 units/lingers.toml diff --git a/ROADMAP.md b/ROADMAP.md index d550b37..8f229ba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -937,6 +937,32 @@ Verified: the demo boots, `curl localhost:8080` returns from a service that was inactive a moment earlier, `oxctl logs counter` shows timestamped ticks, and `docker stop` exits 0 in under a second. +**And CI found a bug in PID 1 while this was in review.** The distribution +suite failed one assertion — not on anything M16 touched, and it had passed on +`main`. It reproduced as a hang, not as the missing line it reported. + +A unit whose stop *completes* during a shutdown took its restart policy and +went to `Restarting`. `settled` waits for every unit that is not `Inactive` or +`Failed`, and nothing ever starts one again on the way down, so the machine sat +there until the ninety-second whole-shutdown deadline and then went down +anyway. `stop` had always cancelled a *pending* backoff for exactly this reason +— the comment saying so is still there — and the hole was the stop that +finishes afterwards. + +Two things came out of it. The clock that ends a test image is now anchored to +the default target rather than to the start of boot: counting from boot made +the budget the sum of how long the machine takes to come up, which varies with +the image and the host, and how long the assertions need, which does not. M11 +made boot wait for each unit to finish activating rather than merely to be +issued, and M13 and M14 added units to wait for; the number had not moved since +M5, and it went over on the slowest image on a loaded runner. + +And the bug got a unit of its own. `lingers` misses its watchdog thirteen +seconds after the target and ignores `SIGTERM`, so only `cgroup.kill` ends it +twelve seconds later — and the test image asks for a shutdown sixteen seconds +in, inside that window with margin on both sides. Reproducing it by luck once +is not a regression test. + ## Not doing, and why These were on the list. They are coming off it with a reason rather than diff --git a/crates/oxinit/src/supervisor.rs b/crates/oxinit/src/supervisor.rs index 0fb0812..7093559 100644 --- a/crates/oxinit/src/supervisor.rs +++ b/crates/oxinit/src/supervisor.rs @@ -666,7 +666,15 @@ impl Supervisor { let deactivating = instance.state == State::Deactivating; let oneshot = ty == Some(ServiceType::Oneshot); + let going_down = self.shutdown.is_some(); + match instance.on_exit(exit) { + // Same as above: on the way down a restart is a unit `settled` + // waits for and nothing ever starts. + Some(_) if going_down => { + instance.cancel_restart(); + println!("oxinit: {name} {exit}; not restarting, the machine is going down"); + } Some(delay) => { println!( "oxinit: {name} {exit}, restarting in {:?} (restart {})", @@ -810,7 +818,19 @@ impl Supervisor { return; } + let going_down = self.shutdown.is_some(); + match instance.deactivated() { + // Nothing comes back on the way down, and a unit left `Restarting` + // is a unit `settled` waits for — so this would hold the machine + // open until the whole-shutdown deadline and then go down anyway. + // `stop` has always cancelled a pending backoff for exactly this + // reason; the hole was a stop that *completes* mid-shutdown and + // takes the policy on the way out. + Some(_) if going_down => { + instance.cancel_restart(); + println!("oxinit: {name} stopped; not restarting, the machine is going down"); + } Some(delay) => { println!( "oxinit: {name} stopped, restarting in {:?} (restart {})", diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index e26776b..bc5f629 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -339,6 +339,11 @@ const EXPECTED: &[(&str, &str)] = &[ "M13: %N and %u expand against a running machine", ), ("reloaded", "M5: oxctl reload, which no boot had ever run"), + ( + "lingers stopped; not restarting, the machine is going down", + "M16: a stop that completes mid-shutdown does not take the restart \ + policy, which used to hang PID 1 until the whole-shutdown deadline", + ), ( "oxinit: shutting down to power off", "M5: SIGTERM is handled", @@ -1442,11 +1447,22 @@ fn is_busybox(path: &Path) -> bool { .is_some_and(|name| name.contains("busybox")) } -/// How long the test image runs before asking oxinit to shut down. +/// How long the test image runs *after boot finishes* before asking oxinit to +/// shut down. +/// +/// Long enough for the slowest thing being asserted on: the watchdog miss at +/// three seconds, its stop timeout, the escalation to `cgroup.kill` after +/// that, and a timer that has to fire more than once. /// -/// Long enough for the slowest thing being asserted on — the watchdog miss at -/// three seconds, its stop timeout, and the escalation to `cgroup.kill` after -/// that. +/// Measured from the default target rather than from the start of boot, and +/// that distinction is the whole of it. Counting from boot made the budget the +/// sum of two unrelated things — how long the machine takes to come up, which +/// varies with the image and the host, and how long the assertions need, which +/// does not. M11 then made boot wait for each unit to finish activating rather +/// than merely to be issued, and M13 and M14 added units to wait for; the +/// number here had not moved since M5. It went over on a loaded CI runner +/// against the distribution image, where the boot is slowest, and the unit +/// whose assertion lost the race was the last one in the queue. const TEST_UPTIME: u32 = 16; /// The unit that makes `test-boot` end. @@ -1454,10 +1470,15 @@ const TEST_UPTIME: u32 = 16; /// Only ever written into a test image. `cargo xtask boot` gives you a machine /// that stays up, which is the whole point of it. fn install_test_shutdown(units: &Path) -> Result<(), String> { + // `after = ["default"]` is what anchors the clock to the end of boot. The + // target is reached when everything ordered before it has finished + // activating, so the sleep below covers only what the assertions need and + // none of what the image takes to come up. let unit = format!( "# Written by `cargo xtask test-boot`. Not in units/.\n\ [unit]\n\ - description = \"End the test\"\n\n\ + description = \"End the test\"\n\ + after = [\"default\"]\n\n\ [service]\n\ exec = '/bin/sh -c \"/bin/sleep {TEST_UPTIME}; /bin/kill -TERM 1\"'\n" ); diff --git a/units/default.toml b/units/default.toml index a3ad477..c6a81d5 100644 --- a/units/default.toml +++ b/units/default.toml @@ -24,6 +24,7 @@ wants = [ "flaky", "signalled", "hangs-back", + "lingers", "specifiers", "muted", "reloader", diff --git a/units/lingers.toml b/units/lingers.toml new file mode 100644 index 0000000..be48ba2 --- /dev/null +++ b/units/lingers.toml @@ -0,0 +1,24 @@ +# Still deactivating when the machine is asked to go down, with a restart +# policy that would apply. +# +# That combination hung PID 1's shutdown for the full ninety-second deadline: +# the watchdog stop completes *during* shutdown, the policy puts the unit in +# `Restarting`, and `settled` waits for every unit that is not `Inactive` or +# `Failed`. Nothing ever started it again — `start` refuses while going down — +# so the machine sat there until the backstop fired. +# +# The timing is deliberate, not incidental. It misses its watchdog about +# thirteen seconds after the default target, and ignores `SIGTERM` so that +# only `cgroup.kill` at `stop-sec` ends it, twelve seconds later. The test +# image asks oxinit to shut down sixteen seconds after the target — inside that +# window with margin on both sides. +[unit] +description = "Deactivates across a shutdown" + +[service] +type = "notify" +exec = "/bin/notify-probe --hang-after 10 --ignore-term" +watchdog-sec = "3s" +stop-sec = "12s" +restart = "on-failure" +restart-sec = "500ms"