Skip to content

fix(startos): keep lxc-net + mounts up through the graceful teardown on host reboot/poweroff#3442

Open
helix-nine wants to merge 9 commits into
masterfrom
fix/graceful-teardown-before-shutdown-target
Open

fix(startos): keep lxc-net + mounts up through the graceful teardown on host reboot/poweroff#3442
helix-nine wants to merge 9 commits into
masterfrom
fix/graceful-teardown-before-shutdown-target

Conversation

@helix-nine

@helix-nine helix-nine commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

What

On an OS-level reboot/poweroff, service containers were torn down uncleanly — a burst of lxc-stop … is not running / Connection refused (os error 111) and a stalled shutdown — even though the graceful-teardown hooks were meant to make it behave like start-cli server restart.

Root cause (corrected after VM testing)

The teardown runs in the ExecStop of startos-restart.service / startos-shutdown.service (start-cli server {restart,shutdown}startd shutdown_all() → per-container Exit RPC + lxc-stop). That hook was ordered only against the power targets, so systemd's shutdown transaction tore down the container's supporting infrastructure concurrently with it. The load-bearing culprit is lxc.service, whose ExecStop is Debian's lxc-containers stop — it stops the container — plus lxc-monitord.service. They run at the same time as the graceful hook, so the container is already dead by the time startd's Exit RPC reaches it ("ungracefully dropped"lxc-stop … is not running111), and the hook then hangs to TimeoutStopSec.

start-cli server restart on a running system never enters a shutdown transaction, so nothing races the teardown — which is why the manual path was always clean.

Fix

Order the hooks after the LXC infrastructure + the data the teardown needs. startos-{restart,shutdown}.service now order:

After=startd.service lxc.service lxc-monitord.service lxc-net.service \
      media-startos-data-main.mount media-startos-data-package\x2ddata.mount

A unit After=X stops before X on shutdown, so each of these stays up until the hook's ExecStop graceful teardown has finished:

  • startd.service — startd must be alive to service the RPC
  • lxc.service / lxc-monitord.service — the container-stop infrastructure (this is the fix for the 111)
  • lxc-net.service — keep lxcbr0 up for container networking
  • the /media/startos/data mounts — hold embassy.db and the volume sources

Shutdown::execute no longer force-reboots mid-transaction. When systemd is already stopping (systemctl is-system-running == stopping), it leaves the power action to systemd, and now exits once the graceful teardown is done — otherwise startd lingered until systemd's SIGTERM and waited out its ~90s stop timeout.

Raspberry Pi: the systemd_is_stopping skip applies to the restart sub-case; poweroff still self-drives the standby marker + reboot (rpi has no true power-off).

An earlier revision of this PR used per-container transient "anchor" units to hold the per-guid .mount units in the shutdown transaction. VM testing showed those mounts are torn down by lxc-containers stop (not systemd's umount.target wave), so the anchors targeted the wrong vector — removed in favor of the lxc.service/lxc-monitord ordering above.

VM validation (beta.10, systemd 257)

Deployed this build to a real StartOS beta.10 VM, installed a real service (start9-pages from the registry), and rebooted, capturing the shutdown on the serial console:

  • Before the lxc.service ordering: reproduced the bug exactly — Container … was ungracefully dropped, lxc-stop … is not running, hook hangs 120s → SIGKILL.
  • With this fix: hook ExecStop completes in <1 s, lxc-monitord/lxc.service stop after it, and there are no 111 / lxc-stop errors — the container is stopped gracefully by startd. With the Shutdown::execute exit, startd no longer holds the shutdown for its 90s stop timeout either.

@helix-nine helix-nine changed the title fix(startos): graceful teardown must complete before shutdown.target on host reboot/poweroff fix(startos): keep lxc-net + mounts up through the graceful teardown on host reboot/poweroff Jul 8, 2026
helix-nine and others added 3 commits July 8, 2026 11:30
…reboot/poweroff doesn't race LXC teardown

startos-restart.service / startos-shutdown.service drive the graceful
container teardown from their ExecStop (start-cli server restart/shutdown),
but ordered it only Before=reboot.target / Before=poweroff.target. systemd
reaches those targets AFTER shutdown.target (bootup(7)), and DefaultDependencies=no
strips the implicit Before=shutdown.target — so on an OS-level reboot/poweroff the
teardown ran concurrently with systemd stopping startd/lxc-net and unmounting the
container rootfs/socket mounts. shutdown_all() then found containers already gone:
'lxc-stop <guid> is not running' and 'Connection refused (os error 111)' on the
per-container Exit RPC.

Add Before=shutdown.target to both hooks so the teardown completes while the whole
system is still up — the same condition that makes manual 'start-cli server restart'
clean. Ordering only (Conflicts= remains the sole stop trigger), so the reboot/poweroff
selection is unchanged.
…ners keep their network through reboot/poweroff teardown

VM validation (beta.10) showed Before=shutdown.target alone was insufficient:
it held off the final reboot but systemd still stopped lxc-net (lxcbr0) mid-teardown,
so containers lost their network and exited before startd's shutdown_all reached them
(the observed 'lxc-stop <guid> is not running' + 'Connection refused (os error 111)').

Serial-console teardown-window measurements (25s instrumented ExecStop):
  OLD  (Before=reboot.target):                 lxc-net stopped mid-window (race)
  Before=shutdown.target only:                 lxc-net STILL stopped mid-window (race)
  After=lxc-net.service + Before=shutdown.target: lxc-net stopped AFTER the window (fixed)

After=lxc-net.service is the load-bearing edge (a unit After=X stops before X on
shutdown); Before=shutdown.target additionally sequences the teardown ahead of the
whole shutdown wave (mounts, etc.) and holds the reboot until it completes. Both hooks
get the same treatment; reboot/poweroff selection stays in Conflicts=.
@dr-bonez dr-bonez force-pushed the fix/graceful-teardown-before-shutdown-target branch from 394648e to f1e10b4 Compare July 8, 2026 17:31
@helix-nine helix-nine marked this pull request as draft July 8, 2026 18:04
@helix-nine

Copy link
Copy Markdown
Contributor Author

⚠️ Marking this draft@dr-bonez tested it and it makes things worse (the shutdown no longer waits; it force-reboots and SIGTERMs everything). I reproduced the regression on a beta.10 VM with the real ExecStop (my earlier validation used a sleep stub, so it never exercised the self-reboot path — my mistake).

Confirmed mechanism. The hook's ExecStop is start-cli server restart, which makes startd run Shutdown::execute() → its own reboot (the unit comment even notes "startd then re-issues the matching poweroff … idempotent since systemd is already powering off"). That's only idempotent when the hook stops late. After=lxc-net.service moves the hook to stop first, so the self-reboot now fires mid-shutdown:

OLD (orderly):
  Stopping startos-restart.service → teardown → Stopping lxc-net.service
  → Stopping systemd-journald → systemd-shutdown: SIGTERM → SIGKILL → Rebooting   (~+3.4s)

NEWPR (this PR, short-circuited):
  Stopping startos-restart.service → TEARDOWN_BEGIN
  → Stopped startos-restart.service (no return mark)
  → Stopping lxc-net.service
  → "Requested transaction contradicts existing jobs: reboot.target has 'start' job
     queued, but 'stop' is included in transaction" → abrupt reboot (~+0.6s)

The tension is fundamental to a unit-file-only fix. To keep lxcbr0 up through the teardown, the teardown must stop before lxc-net (After=lxc-net.service) — but that same early stop fires startd's self-reboot early, which short-circuits systemd. Ordering is symmetric, so there's no unit-only arrangement that keeps lxc-net up without moving the teardown early. The self-reboot has to be decoupled from the teardown.

Proposed direction (in shared-libs/crates/start-core/src/shutdown.rs): when Shutdown::execute() is invoked as part of a systemd-initiated shutdown, do the graceful work (stop journald, export the data volume, sound) but skip the final reboot/shutdown now and let systemd complete the transition — detectable via systemctl is-system-running == stopping (or the presence of the systemd shutdown marker). Then After=lxc-net.service + Before=shutdown.target on the hooks becomes safe. Alternatives: route the host-reboot teardown through startd's SIGTERM/None path (which already tears down without rebooting) by ordering startd.service After=lxc-net.service, or add a teardown-only RPC the hook calls.

Since this now lands in startd's shutdown path, I'd rather align before pushing a third attempt.

… already stopping

With After=lxc-net.service the graceful-teardown hook stops FIRST in the shutdown,
so start-cli server restart -> startd -> Shutdown::execute() now runs early. Its
self-issued 'reboot' then collided with systemd's in-flight transaction and
force-rebooted, skipping the orderly teardown (drbonez: 'not waiting at all to
sigterm all the processes').

Detect the in-progress systemd shutdown via 'systemctl is-system-running' ==
'stopping' (VM-confirmed to hold at the hook ExecStop) and skip the redundant
reboot/shutdown shell-out, leaving the power action to systemd. The manual
'start-cli server restart' path (system 'running') still issues its own reboot.
raspberrypi keeps its existing branch. Completes the After=lxc-net.service +
Before=shutdown.target unit fix.
@helix-nine helix-nine marked this pull request as ready for review July 8, 2026 18:26
@helix-nine

Copy link
Copy Markdown
Contributor Author

Implemented option 1 (thanks @dr-bonez). New commit: Shutdown::execute skips its self-issued reboot/shutdown when systemd is already driving the transition, detected via systemctl is-system-running == stopping. The manual start-cli server restart path (system running) still issues its own reboot; raspberrypi keeps its existing branch.

} else if systemd_is_stopping() {
    // Reached from the startos-{restart,shutdown}.service ExecStop during a
    // systemd-initiated reboot/poweroff: systemd already owns the power action.
    // Re-issuing it here mid-transaction force-reboots and skips the orderly
    // teardown, so leave the final step to systemd.
    tracing::info!("systemd is already shutting down; leaving the final reboot/poweroff to it");
}

Verified on a beta.10 VM (serial console):

  • is-system-running returns stopping at the exact moment the hook's ExecStop runs → the skip fires on the systemd path but not the manual path. ✅
  • Earlier runs confirmed the ordering half: with After=lxc-net.service, lxc-net stays up until after the teardown completes. ✅
  • cargo check -p start-core passes.

One gap I couldn't close here: a full end-to-end reboot with the rebuilt startdstartbox embeds the built web UI, so it won't compile without a full Angular build in this environment. Each half of the fix is verified independently and the logic joining them is straightforward (no self-reboot command → no transaction collision → systemd's orderly shutdown proceeds), but a smoke test on a real box with running services (journalctl -b -1: clean teardown, no 111/is not running, and it waits) is worth doing before merge since you have the hardware.

…on reboot

The earlier fix ordered the teardown hooks only After=lxc-net + Before=shutdown.target,
which mis-diagnosed the race and could not touch the per-guid container binds that
actually throw the 111. shutdown.target and umount.target are parallel siblings, so
Before=shutdown.target never orders against the unmount wave.

Instead:
- startos-{restart,shutdown}.service now order After= the statically-named units the
  teardown depends on (startd, lxc-net, and the /media/startos/data mounts holding
  embassy.db + the volume sources); a unit After=X stops before X on shutdown, so
  those stay up until the ExecStop teardown has run.
- startd registers a transient systemd anchor unit per container (lxc::anchor):
  RequiresMountsFor=<the container's /var/lib/lxc/<guid> binds> gives it After= each
  auto-generated .mount unit, and Before=startos-{restart,shutdown}.service places it
  after the teardown, so systemd won't unmount the per-guid binds until the teardown
  has run. remove_anchor uses systemctl --no-block to avoid a stop-ordering deadlock.
  Subcontainer mounts get their own anchors.

Ordering/anchor behaviour still needs a real-services VM reboot test before merge.
On rpi a poweroff is emulated as a standby reboot (write STANDBY_MODE_PATH +
reboot; start_init parks on the marker on the way back up), so the poweroff
sub-case must keep self-driving the marker+reboot even when systemd initiated
the shutdown — systemd's own poweroff would only halt the still-powered board.
Only the restart sub-case now defers to systemd when it's already stopping,
matching the non-rpi guard and avoiding a redundant mid-transaction reboot.
…op anchors

VM testing on beta.10 with a real service (start9-pages) showed the teardown still
failed with the transient-anchor approach: the container is killed by lxc.service
(ExecStop = Debian's `lxc-containers stop`) and lxc-monitord, which systemd was
stopping concurrently with the graceful hook — so startd's Exit RPC hit an already
-dead container ("ungracefully dropped" / lxc-stop "is not running" / 111).

The real fix is ordering: startos-{restart,shutdown}.service now order
After=lxc.service lxc-monitord.service (in addition to lxc-net.service and the
/media/startos/data mounts), holding the LXC container-stop infrastructure until the
graceful teardown has run. Confirmed on a beta.10 VM: the hook's ExecStop completes
in <1s, lxc.service/lxc-monitord stop after it, and there are no 111 / lxc-stop
errors. The per-guid mounts were being torn down by lxc-containers stop, not systemd's
umount wave, so the transient anchor units targeted the wrong vector — removed.
The systemd_is_stopping guard skips startd's own reboot/poweroff, but startd then
lingered until systemd's SIGTERM and waited out startd.service's ~90s stop timeout
before being SIGKILLed (observed on a beta.10 VM reboot: clean container teardown,
then a 90s stall). The graceful teardown and volume-group export are complete at
that point, so exit(0) and let systemd finish the power action — this is the same
end state a normal 'start-cli server restart' reaches by rebooting.
…ord.service only

VM testing showed lxc.service (ExecStop=lxc-containers stop) + lxc-monitord are what
kill the container mid-teardown; the per-guid mount unmounts were a symptom of that,
not an independent umount.target race. Drop the defensive lxc-net + /media/startos/data
After= edges (neither was the fix — the Exit RPC is a unix socket and the data mounts
unmount late on their own), leaving just the two edges that address the root cause.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants