Skip to content

feat(start-os): offer to wait for a running backup before restarting or shutting down - #3755

Open
helix-nine wants to merge 7 commits into
masterfrom
feat/prompt-shutdown-during-backup
Open

feat(start-os): offer to wait for a running backup before restarting or shutting down#3755
helix-nine wants to merge 7 commits into
masterfrom
feat/prompt-shutdown-during-backup

Conversation

@helix-nine

@helix-nine helix-nine commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Closes #3608

Powering the server off part-way through a backup can corrupt the backup of whichever service is being written at that moment, and nothing stopped it: Restart and Shutdown took effect immediately, and the case power button went straight to systemd-logind's poweroff.

What it does now

Choosing Restart or Shutdown while a backup is running opens a prompt offering to wait for the backup instead — and waiting is what happens if the user walks away, since the prompt counts down and takes that option for them.

The choice is recorded server-side as serverInfo.statusInfo.deferredPowerAction, so it survives the browser: a task started at boot waits for the backup to clear and then carries the action out, an action bar in every connected UI says what is coming, and either that bar's Cancel or an immediate restart/shutdown clears it.

The power button reaches the same place. While a backup runs, startd holds a systemd-logind handle-power-key block inhibitor and reads the key itself from the devices udev tags power-switch — logind's own set — so a press defers rather than powers off, and a beep acknowledges it for whoever is standing at the server. It names handle-power-key and not shutdown deliberately: blocking shutdown would also block the power-off StartOS asks systemd for at the end of its own teardown.

Deferring is opt-in (--after-backup), so startos-shutdown.service and startos-restart.service — which cannot wait — are unchanged. start-cli server cancel-deferred-power calls a pending action off.

Measured, not reasoned

The power-key half is the part that could not be settled by reading, so it was measured on a startos-0.4.0 VM (a VM, not physical hardware — the ACPI power button is synthesised through /dev/uinput):

result
synthetic KEY_POWER press, no inhibitor machine powered off (i.e. this is the bug)
same press with Inhibit(handle-power-key, …, block) held machine stayed up
an independent reader during that press still received type=1 code=116 value=1 — logind does not EVIOCGRAB
/run/udev/tags/power-switch/ present, entries named c13:64, c13:65, matching device_id()
sizeof(struct input_event) 24, and the size-8 tail parse read the press byte-for-byte
Inhibit over D-Bus as root returns an fd (h 5) — the exact ssss signature of the zbus proxy

power_key.rs is Linux-only (libc::input_event, nix::sys::stat::{major,minor} do not exist on darwin — checked by compiling both against aarch64-apple-darwin), so the module and its spawn are #[cfg(target_os = "linux")]; the apple-darwin leg of the CI matrix is unaffected.

Also run: cargo test -p start-core (308 pass, incl. new tests for the event parse and the udev device-id), npm run check, npm run build:ui, npm run check:i18n, cargo fmt --check, prettier, make start-core-ts-bindings-check, make manpages-check.

Review notes

The first commit is the feature; the two that follow are fixes from adversarial review passes over it, kept separate so the reasoning is legible. Worth a look:

  • shutting_down / restarting / backupProgress are now reset when startd restarts within a boot, not only at init. A backup dies with the process that ran it, and a stale flag would have left the power key inhibited with nothing left to release it. This also fixes a pre-existing wart where a startd crash mid-teardown left the header reading "Shutting down" until reboot.
  • Every in-app restart now goes through PowerService — the restart-to-apply action bar and the disk-repair restart previously bypassed the new guard.
  • The three action bars are position: fixed and were stacking. They are now one @if/@else if chain with the deferred-power bar first, because statusInfo.restart is never cleared until reboot and would otherwise shadow it permanently.

Three things review turned up that are adjacent and not fixed here, in case you want them:

  • .g-buttons is inert on a selectorless dialog component's footer. Taiga styles tui-dialog[data-appearance~=taiga] > ng-component > footer at specificity (0,1,3), so all four of the class's declarations lose — gap and margin-top included. It affects prompt.component.ts and action-confirm.component.ts as well as this PR's dialog; I kept the class for consistency with them rather than making this one the odd one out. action-confirm.component.ts's own footer { margin-top: 1.5rem } is overridden the same way.
  • check-i18n.mjs's line regex cannot see (cond ? 'A' : 'B') | i18n, an idiom used at 15 sites across three apps, leaving five keys with no coverage today. The fix belongs in the script (or in typing ENGLISH as a literal object so keyof yields a real union), not in the call sites.
  • assure_backing_up gates only on backupProgress, so a backup can still be started after a power action is committed and be torn down by it. Pre-existing, and closing it means a new rejection path and error string.

Two things I deliberately did not do, in case you disagree:

  • assure_backing_up still accepts a new backup while the server is tearing down (it gates only on backupProgress). That window is pre-existing and closing it means a new rejection path and error string, so it felt like a separate change.
  • The power button defers without raising the countdown prompt — there is no way to answer a prompt from the machine, and the safe default is the one it takes anyway. The docs say so explicitly rather than implying the prompt appears.

Docs, changelog (0.4.0.2, untagged) and all five locale dictionaries ship with the code. The UI was verified by typecheck and a production build, not clicked through in a browser.

…or shutting down

Powering the server off part-way through a backup can corrupt the backup
of whichever service is being written at that moment, and nothing stopped
it: Restart and Shutdown took effect immediately, and the case power
button went straight to systemd-logind's poweroff.

Restart or Shutdown during a backup now opens a prompt offering to wait
for the backup to finish, which is also what happens if the user walks
away — it counts down and takes that option for them. The choice is
recorded server-side as serverInfo.statusInfo.deferredPowerAction, so it
survives the browser: a task started at boot watches for the backup to
clear and then carries the action out, an action bar in every connected
UI says what is coming, and either the bar's Cancel or an immediate
restart/shutdown clears it.

The power button reaches the same place. While a backup runs, startd
holds a systemd-logind 'handle-power-key' block inhibitor and reads the
key itself from the devices udev tags 'power-switch' — logind's own set —
so a press defers rather than powers off. It names 'handle-power-key' and
not 'shutdown' deliberately: blocking 'shutdown' would also block the
power-off StartOS asks systemd for at the end of its own teardown. Every
step degrades to today's behavior on failure, and a beep acknowledges the
press for whoever is standing at the server.

Deferring is opt-in (--after-backup) so startos-shutdown.service and
startos-restart.service, which cannot wait, are unchanged.
- power_key is Linux-only (libc::input_event, nix major/minor), so gate the
  module and its spawn on target_os = "linux"; it broke the apple-darwin
  build.
- run_deferred_power_actions no longer returns after one action or one
  failure, so a later deferral is still honoured, and it backs off instead of
  spinning when the take mutation keeps failing.
- Fold the backup check and the power action into one mutation, so a backup
  starting in between can no longer be interrupted.
- Clear a stale backupProgress (and any deferred action) when startd starts
  without a reboot. A backup dies with the process that ran it, and a stale
  flag would otherwise leave the power key inhibited with nothing left to
  release it.
- Hold the logind inhibitor only while the key is actually being read, so a
  server whose key StartOS cannot see keeps powering off as it does today
  rather than getting an inert button.
- Route every in-app restart/shutdown through PowerService, so the
  restart-to-apply bar and the disk-repair restart stop bypassing the prompt.
- Show one action bar at a time; they are position:fixed and were stacking.
  The deferred-power bar keeps its own gate, since the shared one is switched
  off for good on the first restart.
- One timer drives the countdown label and the default it takes.
- Mock clears the deferral on an immediate action, as the backend does.
- Docs: the power button defers without asking, the CLI still interrupts
  unless asked not to, and shutting down on UPS battery wants "now".
… bar

A second review round on the previous commit found that chaining the three
action bars with @else if made the new one unreachable: statusInfo.restart is
set on a hostname, language, kiosk or update change and is never cleared until
the next boot, so it shadowed the deferred-power bar — the only place a queued
restart or shutdown is shown, and the only way to cancel one.

- Put the deferred-power bar first: it is transient and time-limited, the
  restart-reason bar is neither, and it is never gated off.
- PowerService now reports whether the action was deferred, so the shared bar
  is only latched off once a restart is really under way. Dismissing the
  prompt used to hide the restart-to-apply bar for good with nothing having
  happened.
- Give up the power key entirely when any one device stops being readable —
  there is no telling which device the firmware reports presses on, and an
  inhibitor outliving the reader is an inert button.
- Re-defer rather than interrupt if a backup started since the deferred action
  was taken.
- Reset the whole transient status, on the branch that skips init and before
  the RPC surface is serving.
- Docs: ARCHITECTURE says the power-key handling is best-effort, the CLI
  reference lists --nowait alongside --after-backup, and a few comments now
  say what the code does.
…d's key codes

Review round 1 on #3755.

- The power key is now enumerated, inhibited and read once per backup rather
  than once per boot. Unplugging any keyboard used to end `read_power_key` for
  good — udev tags every key-capable device `power-switch`, so that is an
  ordinary event, not an error — leaving later backups unprotected with only a
  log line to say so. Per-backup setup also picks up a device plugged in since
  boot, and stops startd holding input devices open when nothing is at stake.
- Match `KEY_POWER2` as well as `KEY_POWER`. logind handles both in one arm,
  and the inhibitor blocks both, so on hardware reporting 356 the button was
  inhibited and unread — inert for the whole backup.
- A deferred action that fails to perform is put back rather than dropped, so
  it is either carried out or cancelled, never silently lost.
- The backup prompt now carries the "will not come back online automatically"
  warning on the shutdown path. Skipping the usual confirm dropped it, and it
  is the one thing a user powering down needs to have read.
- String 915 said "Powering down" but is shown on the restart path too; it now
  says "Interrupting it", in all five locales.
- `STATUS_INFO_PTR` lived in two modules; `power.service`'s TSDoc claimed the
  emission reflected the server's decision when it reflects the user's choice;
  ARCHITECTURE.md restated the module's own rationale; a few comments narrated
  rather than explained.
- surge-and-ups says the power button always waits, so on battery use the UI.
Review round 2 on #3755, plus one defect of my own found re-reading round 1.

- Round 1's re-arm of a failed deferred action called `defer_or_begin`, which
  with the backup already over takes its other branch: it cleared the action
  and set `shuttingDown`/`restarting` without performing anything, leaving the
  header reading "Shutting down" until startd restarted. It writes the field
  directly now.
- A failed logind `Inhibit` propagated out of `guard_backups` and ended the
  guard for the rest of the boot, while enumeration and read failures beside it
  only skipped one backup. A logind restart or a call timeout under backup load
  therefore left every later backup unguarded, silently. One backup's guard is
  now one fallible unit, so any failure costs that backup and no more — which
  is what the module doc already claimed.
- The prompt's two buttons are sentences that do not shrink, and 25rem cannot
  hold them on one row in any locale — English overflows the dialog by 51px,
  German by 137px. The footer wraps now.
…n restart too

Review round 3 on #3755.

- Round 2 added `footer { flex-wrap: wrap }` to the prompt on the strength of
  a font measurement showing the two buttons overflowing a 25rem dialog. The
  measurement modelled a row that does not exist: Taiga already styles a
  selectorless dialog component's footer via
  `tui-dialog[data-appearance~=taiga]>ng-component>footer`, which sets
  `flex-wrap: wrap-reverse` at a specificity the component's own
  `footer[_ngcontent-*]` cannot reach. The footer always wrapped; the
  declaration never applied. Removed, along with the comment asserting
  otherwise.
- `updateProgress` is written by an in-process task exactly like the four
  statuses beside it, so the boot-path reset now clears it too. A startd
  restart mid-download previously left it set with its writer dead, which
  `server.update` reads as "already updating" and the UI as a frozen progress
  bar — wedged until the box rebooted.
The review pass over this PR flagged its own weakest point: the branch that
decides between recording a power action and performing it, and the take that
clears it, had no test — and that is exactly where two of the defects found
during review lived (a runner that exited after one action, and a re-arm that
set shuttingDown without shutting anything down).

Extracts the three db transitions from their mutate closures into free
functions over DatabaseModel, following the pattern the version migrations
already use, and drives them with json! fixtures. Seven tests, each verified to
fail against a deliberately broken implementation: ignoring after_backup,
taking without clearing, beginning without clearing a pending action, swapping
the restart and shutdown arms, and making the power-key path begin an action
rather than record one.

The async wrappers and run_deferred_power_actions' loop still have no coverage;
they need an RpcContext.
@dr-bonez

Copy link
Copy Markdown
Member

assure_backing_up still accepts a new backup while the server is tearing down (it gates only on backupProgress). That window is pre-existing and closing it means a new rejection path and error string, so it felt like a separate change.

no, we really should prevent this

@dr-bonez dr-bonez added StartOS Deferred Intentionally withheld from the next release labels Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Deferred Intentionally withheld from the next release StartOS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] StartOS: Prompt user who selects Restart/Shutdown while backup is running

2 participants