From f02ac768dfdd87d6243855d8e083c3f5ed7db081 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:52:13 +0000 Subject: [PATCH 1/7] feat(start-os): offer to wait for a running backup before restarting or shutting down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../start-cli-server-cancel-deferred-power.1 | 13 + .../start-cli/man/start-cli-server-restart.1 | 5 +- .../start-cli/man/start-cli-server-shutdown.1 | 5 +- projects/start-cli/man/start-cli-server.1 | 3 + projects/start-os/ARCHITECTURE.md | 8 + projects/start-os/CHANGELOG.md | 14 + projects/start-os/docs/src/backup-create.md | 2 + projects/start-os/docs/src/cli-reference.md | 8 + .../components/header/menu.component.ts | 42 ++- .../components/header/power.component.ts | 68 +++++ .../src/app/routes/portal/portal.component.ts | 33 +++ .../ui/src/app/services/api/api.fixures.ts | 1 + .../app/services/api/embassy-api.service.ts | 6 +- .../services/api/embassy-live-api.service.ts | 8 +- .../services/api/embassy-mock-api.service.ts | 40 ++- .../web/ui/src/app/services/api/mock-patch.ts | 1 + .../crates/start-core/locales/i18n.yaml | 14 + .../crates/start-core/src/bins/startd.rs | 12 + .../crates/start-core/src/db/model/public.rs | 13 + shared-libs/crates/start-core/src/init.rs | 1 + shared-libs/crates/start-core/src/lib.rs | 8 + .../crates/start-core/src/power_key.rs | 252 ++++++++++++++++++ shared-libs/crates/start-core/src/shutdown.rs | 128 ++++++++- .../shared/src/i18n/dictionaries/de.ts | 5 + .../shared/src/i18n/dictionaries/en.ts | 5 + .../shared/src/i18n/dictionaries/es.ts | 5 + .../shared/src/i18n/dictionaries/fr.ts | 5 + .../shared/src/i18n/dictionaries/pl.ts | 5 + .../start-core/lib/osBindings/PowerAction.ts | 3 + .../start-core/lib/osBindings/ServerStatus.ts | 6 + .../lib/osBindings/ShutdownParams.ts | 6 + .../start-core/lib/osBindings/index.ts | 1 + 32 files changed, 694 insertions(+), 32 deletions(-) create mode 100644 projects/start-cli/man/start-cli-server-cancel-deferred-power.1 create mode 100644 projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts create mode 100644 shared-libs/crates/start-core/src/power_key.rs create mode 100644 shared-libs/ts-modules/start-core/lib/osBindings/PowerAction.ts diff --git a/projects/start-cli/man/start-cli-server-cancel-deferred-power.1 b/projects/start-cli/man/start-cli-server-cancel-deferred-power.1 new file mode 100644 index 0000000000..9c8505b504 --- /dev/null +++ b/projects/start-cli/man/start-cli-server-cancel-deferred-power.1 @@ -0,0 +1,13 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH start-cli-server-cancel-deferred-power 1 "cancel-deferred-power " +.SH NAME +start\-cli\-server\-cancel\-deferred\-power \- Cancel a restart or shutdown that is waiting for a backup to finish +.SH SYNOPSIS +\fBstart\-cli server cancel\-deferred\-power\fR [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Cancel a restart or shutdown that is waiting for a backup to finish +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/projects/start-cli/man/start-cli-server-restart.1 b/projects/start-cli/man/start-cli-server-restart.1 index d63e8f3749..2a06d058c7 100644 --- a/projects/start-cli/man/start-cli-server-restart.1 +++ b/projects/start-cli/man/start-cli-server-restart.1 @@ -4,7 +4,7 @@ .SH NAME start\-cli\-server\-restart \- Restart the server .SH SYNOPSIS -\fBstart\-cli server restart\fR [\fB\-\-nowait\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBstart\-cli server restart\fR [\fB\-\-nowait\fR] [\fB\-\-after\-backup\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Restart the server .SH OPTIONS @@ -12,5 +12,8 @@ Restart the server \fB\-\-nowait\fR Return immediately instead of waiting for graceful shutdown to complete .TP +\fB\-\-after\-backup\fR +Wait for a running backup to finish before powering off +.TP \fB\-h\fR, \fB\-\-help\fR Print help diff --git a/projects/start-cli/man/start-cli-server-shutdown.1 b/projects/start-cli/man/start-cli-server-shutdown.1 index d80ff90135..37885db4f7 100644 --- a/projects/start-cli/man/start-cli-server-shutdown.1 +++ b/projects/start-cli/man/start-cli-server-shutdown.1 @@ -4,7 +4,7 @@ .SH NAME start\-cli\-server\-shutdown \- Shutdown the server .SH SYNOPSIS -\fBstart\-cli server shutdown\fR [\fB\-\-nowait\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBstart\-cli server shutdown\fR [\fB\-\-nowait\fR] [\fB\-\-after\-backup\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Shutdown the server .SH OPTIONS @@ -12,5 +12,8 @@ Shutdown the server \fB\-\-nowait\fR Return immediately instead of waiting for graceful shutdown to complete .TP +\fB\-\-after\-backup\fR +Wait for a running backup to finish before powering off +.TP \fB\-h\fR, \fB\-\-help\fR Print help diff --git a/projects/start-cli/man/start-cli-server.1 b/projects/start-cli/man/start-cli-server.1 index 695db4709f..49218003df 100644 --- a/projects/start-cli/man/start-cli-server.1 +++ b/projects/start-cli/man/start-cli-server.1 @@ -13,6 +13,9 @@ Commands related to the server i.e. restart, update, and shutdown Print help .SH SUBCOMMANDS .TP +start\-cli\-server\-cancel\-deferred\-power(1) +Cancel a restart or shutdown that is waiting for a backup to finish +.TP start\-cli\-server\-clear\-smtp(1) Remove system smtp server and credentials .TP diff --git a/projects/start-os/ARCHITECTURE.md b/projects/start-os/ARCHITECTURE.md index b000f9a608..e60ca08914 100644 --- a/projects/start-os/ARCHITECTURE.md +++ b/projects/start-os/ARCHITECTURE.md @@ -85,6 +85,14 @@ erasure-coded FUSE filesystem used for StartOS backups. It builds to the `poweroff.target`/`halt.target`, not reboot); its `ExecStop` calls `start-cli server shutdown`. - `startos-restart.service` — restart handling. +- The physical power key is systemd-logind's (`HandlePowerKey=poweroff`), + except while a backup is running: `startd` then holds a logind + `handle-power-key` block inhibitor and reads the key itself + (`start-core/src/power_key.rs`), turning a press into a shutdown that waits + for the backup rather than one that interrupts it. The inhibitor 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 + graceful teardown. ## OS image packaging diff --git a/projects/start-os/CHANGELOG.md b/projects/start-os/CHANGELOG.md index bf9d8f46b9..abafb186c6 100644 --- a/projects/start-os/CHANGELOG.md +++ b/projects/start-os/CHANGELOG.md @@ -36,6 +36,20 @@ file tracks notable changes since the move to the monorepo. plaintext address, including the passwords typed into it. See [Gateways](https://docs.start9.com/start-os/gateways.html). +- **Restarting or shutting down while a backup is running now asks first, and + can wait for the backup to finish.** Powering the server off part-way through + a backup can corrupt the backup of whichever service is being written at that + moment. Choosing `Restart` or `Shutdown` during a backup now offers to wait + for the backup instead, and waiting is what happens if you walk away — the + prompt counts down and takes that option for you. StartOS then carries out + the restart or shutdown as soon as the backup completes, and until then a bar + along the bottom of the screen says what is coming and lets you cancel it. + Pressing the server's physical power button during a backup does the same, + rather than powering off immediately. Over the CLI, + `start-cli server restart` and `server shutdown` take `--after-backup` for + the same behavior and `start-cli server cancel-deferred-power` calls it off. + See [Creating Backups](https://docs.start9.com/start-os/backup-create.html). + ### Changed - **The NVIDIA images now use NVIDIA's open kernel modules, which support GeForce diff --git a/projects/start-os/docs/src/backup-create.md b/projects/start-os/docs/src/backup-create.md index fa6aaa70da..cf9a0dd7b9 100644 --- a/projects/start-os/docs/src/backup-create.md +++ b/projects/start-os/docs/src/backup-create.md @@ -21,6 +21,8 @@ Back up your server's data to a physical drive or a network folder. 1. To back up a service, StartOS first stops it (if it was running), performs the backup, then restarts it — but only if it was running beforehand. A service that was already stopped stays stopped. Consequently a service cannot be used while it is backing up, though you may continue to use your server and other services in the meantime. +1. Restarting or shutting down mid-backup can corrupt the backup of whichever service is being written at that moment, so StartOS asks first. Choosing `Restart` or `Shutdown` while a backup is running offers to wait for the backup to finish instead, and the prompt takes that option for you if you do not choose within 30 seconds. Pressing the server's physical power button during a backup does the same. StartOS then performs the restart or shutdown as soon as the backup completes; until then a bar along the bottom of the screen says what is coming and lets you cancel it. To power down immediately anyway, choose the "now" option in that prompt. + 1. Upon completion, StartOS issues a backup report, indicating which services were backed up, as well as any errors. 1. Backups are differential — each new backup to the same target overwrites the previous one. To maintain multiple backup points, use multiple backup targets. diff --git a/projects/start-os/docs/src/cli-reference.md b/projects/start-os/docs/src/cli-reference.md index f26f53b285..5d76797a33 100644 --- a/projects/start-os/docs/src/cli-reference.md +++ b/projects/start-os/docs/src/cli-reference.md @@ -56,10 +56,18 @@ Restart, shut down, update, and configure the server. Restart the server. +- `--after-backup` — Wait for a running backup to finish first + ### `start-cli server shutdown` Shut down the server. +- `--after-backup` — Wait for a running backup to finish first + +### `start-cli server cancel-deferred-power` + +Cancel a restart or shutdown that is waiting for a backup to finish. + ### `start-cli server update` Check the configured registry for OS updates and apply if available. diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts index 9b895140d6..3e0310eac6 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts @@ -1,4 +1,5 @@ import { Component, inject } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' import { RouterLink } from '@angular/router' import { DialogService, @@ -7,6 +8,7 @@ import { SafeLinksDirective, TaskService, } from '@start9labs/shared' +import { T } from '@start9labs/start-core' import { TuiButton, TuiDataList, @@ -17,8 +19,10 @@ import { import { filter } from 'rxjs' import { ApiService } from 'src/app/services/api/embassy-api.service' import { AuthService } from 'src/app/services/auth.service' +import { OSService } from 'src/app/services/os.service' import { STATUS } from 'src/app/services/status.service' import { ABOUT } from './about.component' +import { POWER } from './power.component' @Component({ selector: 'header-menu', @@ -144,12 +148,28 @@ export class HeaderMenuComponent { open = false readonly status = inject(STATUS) + readonly backingUp = toSignal(inject(OSService).backingUp$, { + initialValue: false, + }) about() { this.dialog.openComponent(ABOUT, { label: 'About this server' }).subscribe() } - async promptPower(action: 'restart' | 'shutdown') { + async promptPower(action: T.PowerAction) { + // Interrupting a backup can corrupt the service being written, so offer to + // wait for it instead of asking the usual "are you sure". + if (this.backingUp()) { + this.dialog + .openComponent(POWER, { + label: action === 'restart' ? 'Restart' : 'Warning', + size: 's', + data: action, + }) + .subscribe(now => this.power(action, !now)) + return + } + this.dialog .openConfirm( action === 'restart' @@ -175,15 +195,17 @@ export class HeaderMenuComponent { }, ) .pipe(filter(Boolean)) - .subscribe(() => - this.tasks.run( - async () => - await this.api[ - action === 'restart' ? 'restartServer' : 'shutdownServer' - ]({}), - `Beginning ${action}`, - ), - ) + .subscribe(() => this.power(action, false)) + } + + private power(action: T.PowerAction, afterBackup: boolean) { + this.tasks.run( + async () => + action === 'restart' + ? await this.api.restartServer({ afterBackup }) + : await this.api.shutdownServer({ afterBackup }), + `Beginning ${action}`, + ) } logout() { diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts new file mode 100644 index 0000000000..679cc59b48 --- /dev/null +++ b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts @@ -0,0 +1,68 @@ +import { Component } from '@angular/core' +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop' +import { i18nPipe } from '@start9labs/shared' +import { T } from '@start9labs/start-core' +import { TuiButton, TuiDialogContext } from '@taiga-ui/core' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { map, take, timer } from 'rxjs' + +// Long enough to read the warning, short enough to not strand someone who +// walked away mid-decision. +const COUNTDOWN = 30 + +@Component({ + template: ` +

+ {{ + 'A backup is currently running. Powering down now can corrupt the backup of the service being written.' + | i18n + }} +

+ + `, + imports: [TuiButton, i18nPipe], +}) +export class PowerComponent { + private readonly context = + injectContext>() + + protected readonly action = this.context.data + + protected readonly seconds = toSignal( + timer(0, 1000).pipe( + map(tick => COUNTDOWN - tick), + take(COUNTDOWN + 1), + ), + { initialValue: COUNTDOWN }, + ) + + constructor() { + // Choosing neither is choosing to wait. + timer(COUNTDOWN * 1000) + .pipe(takeUntilDestroyed()) + .subscribe(() => this.wait()) + } + + protected now() { + this.context.completeWith(true) + } + + protected wait() { + this.context.completeWith(false) + } +} + +export const POWER = new PolymorpheusComponent(PowerComponent) diff --git a/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts b/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts index 719622dac9..2b51246959 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts @@ -80,6 +80,32 @@ import { HeaderComponent } from './components/header/header.component' } + @if (deferredPower(); as action) { + + + + @if (action === 'restart') { + {{ + 'A backup is running. Your server will restart when it finishes.' + | i18n + }} + } @else { + {{ + 'A backup is running. Your server will shut down when it finishes.' + | i18n + }} + } + + + + } `, styles: ` @use '@taiga-ui/styles/utils' as taiga; @@ -177,6 +203,9 @@ export class PortalComponent { readonly restartReason = toSignal( this.patch.watch$('serverInfo', 'statusInfo', 'restart'), ) + readonly deferredPower = toSignal( + this.patch.watch$('serverInfo', 'statusInfo', 'deferredPowerAction'), + ) readonly bar = signal(true) getProgress(size: number, downloaded: number): number { @@ -189,4 +218,8 @@ export class PortalComponent { await this.api.restartServer({}) }, 'Beginning restart') } + + cancelDeferredPower() { + this.tasks.run(async () => await this.api.cancelDeferredPower({})) + } } diff --git a/projects/start-os/web/ui/src/app/services/api/api.fixures.ts b/projects/start-os/web/ui/src/app/services/api/api.fixures.ts index d572b674ab..921dafddde 100644 --- a/projects/start-os/web/ui/src/app/services/api/api.fixures.ts +++ b/projects/start-os/web/ui/src/app/services/api/api.fixures.ts @@ -27,6 +27,7 @@ export namespace Mock { restarting: false, shuttingDown: false, restart: null, + deferredPowerAction: null, } export const RegistryOSUpdate: T.OsVersionInfoMap = { diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts index 7833d4d13c..8f1e6ea951 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts @@ -115,9 +115,11 @@ export abstract class ApiService { targetVersion: string }): Promise<'updating' | 'no-updates'> - abstract restartServer(params: {}): Promise + abstract restartServer(params: Partial): Promise - abstract shutdownServer(params: {}): Promise + abstract shutdownServer(params: Partial): Promise + + abstract cancelDeferredPower(params: {}): Promise abstract repairDisk(params: {}): Promise diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts index 6b9b08e55b..5c131f623a 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts @@ -240,14 +240,18 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'server.update', params }) } - async restartServer(params: {}): Promise { + async restartServer(params: Partial): Promise { return this.rpcRequest({ method: 'server.restart', params }) } - async shutdownServer(params: {}): Promise { + async shutdownServer(params: Partial): Promise { return this.rpcRequest({ method: 'server.shutdown', params }) } + async cancelDeferredPower(params: {}): Promise { + return this.rpcRequest({ method: 'server.cancel-deferred-power', params }) + } + async repairDisk(params: {}): Promise { return this.rpcRequest({ method: 'disk.repair', params }) } diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts index bf3d4f6c72..f1d991fe0e 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts @@ -101,6 +101,8 @@ const INIT_PROGRESS: T.FullProgress = { export class MockApiService extends ApiService { readonly mockWsSource$ = new Subject() private readonly revertTime = 1800 + private backingUp = false + private deferredPowerAction: T.PowerAction | null = null sequence = 0 constructor() { @@ -388,9 +390,13 @@ export class MockApiService extends ApiService { return 'updating' } - async restartServer(params: {}): Promise { + async restartServer(params: Partial): Promise { await pauseFor(2000) + if (params.afterBackup && this.backingUp) { + return this.deferPower('restart') + } + const patch = [ { op: PatchOp.REPLACE, @@ -414,9 +420,13 @@ export class MockApiService extends ApiService { return null } - async shutdownServer(params: {}): Promise { + async shutdownServer(params: Partial): Promise { await pauseFor(2000) + if (params.afterBackup && this.backingUp) { + return this.deferPower('shutdown') + } + const patch = [ { op: PatchOp.REPLACE, @@ -440,6 +450,11 @@ export class MockApiService extends ApiService { return null } + async cancelDeferredPower(params: {}): Promise { + await pauseFor(1000) + return this.deferPower(null) + } + async repairDisk(params: {}): Promise { await pauseFor(2000) return null @@ -922,6 +937,7 @@ export class MockApiService extends ApiService { async createBackup(params: T.BackupParams): Promise { await pauseFor(2000) + this.backingUp = true const serverPath = '/serverInfo/statusInfo/backupProgress' const ids = params.packageIds || [] // One phase per package plus a trailing "OS Data" phase (the host @@ -999,6 +1015,14 @@ export class MockApiService extends ApiService { }, ] this.mockRevision(lastPatch) + this.backingUp = false + if (this.deferredPowerAction) { + const action = this.deferredPowerAction + await this.deferPower(null) + await this[action === 'restart' ? 'restartServer' : 'shutdownServer']( + {}, + ) + } // Feature 1: a completed backup whose target still holds a legacy (V1) // folder raises a warning notification — bumps the unread badge and @@ -2268,6 +2292,18 @@ export class MockApiService extends ApiService { this.mockRevision(patch) } + private deferPower(action: T.PowerAction | null): null { + this.deferredPowerAction = action + this.mockRevision([ + { + op: PatchOp.REPLACE, + path: '/serverInfo/statusInfo/deferredPowerAction', + value: action, + }, + ]) + return null + } + private mockData(path: string): any { const parts = path.split('/').filter(Boolean) let obj: any = mockPatchData diff --git a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts index 8dbe0483d9..2437c37d27 100644 --- a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts +++ b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts @@ -261,6 +261,7 @@ export const mockPatchData: DataModel = { shuttingDown: false, backupProgress: null, restart: null, + deferredPowerAction: null, }, name: 'Random Words', hostname: 'random-words', diff --git a/shared-libs/crates/start-core/locales/i18n.yaml b/shared-libs/crates/start-core/locales/i18n.yaml index e3c6dbedfc..4bf9c0985e 100644 --- a/shared-libs/crates/start-core/locales/i18n.yaml +++ b/shared-libs/crates/start-core/locales/i18n.yaml @@ -3157,6 +3157,13 @@ help.arg.gua-wan: fr_FR: "Exposer la GUA IPv6 au WAN (false = LAN uniquement)" pl_PL: "Udostępnij GUA IPv6 w sieci WAN (false = tylko LAN)" +help.arg.after-backup: + en_US: "Wait for a running backup to finish before powering off" + de_DE: "Auf den Abschluss einer laufenden Sicherung warten, bevor ausgeschaltet wird" + es_ES: "Esperar a que termine una copia de seguridad en curso antes de apagar" + fr_FR: "Attendre la fin d'une sauvegarde en cours avant d'éteindre" + pl_PL: "Poczekaj na zakończenie trwającej kopii zapasowej przed wyłączeniem" + help.arg.allow-model-mismatch: en_US: "Allow database model mismatch" de_DE: "Datenbankmodell-Abweichung erlauben" @@ -4750,6 +4757,13 @@ about.calculate-blake3-hash-for-file: fr_FR: "Calculer le hachage blake3 d'un fichier" pl_PL: "Oblicz hash blake3 dla pliku" +about.cancel-deferred-power: + en_US: "Cancel a restart or shutdown that is waiting for a backup to finish" + de_DE: "Einen Neustart oder ein Herunterfahren abbrechen, der bzw. das auf den Abschluss einer Sicherung wartet" + es_ES: "Cancelar un reinicio o apagado que está esperando a que termine una copia de seguridad" + fr_FR: "Annuler un redémarrage ou un arrêt en attente de la fin d'une sauvegarde" + pl_PL: "Anuluj ponowne uruchomienie lub wyłączenie oczekujące na zakończenie kopii zapasowej" + about.cancel-install-package: en_US: "Cancel an install of a package" de_DE: "Eine Paketinstallation abbrechen" diff --git a/shared-libs/crates/start-core/src/bins/startd.rs b/shared-libs/crates/start-core/src/bins/startd.rs index ffa16b0ffb..73b84ee0c0 100644 --- a/shared-libs/crates/start-core/src/bins/startd.rs +++ b/shared-libs/crates/start-core/src/bins/startd.rs @@ -18,6 +18,7 @@ use crate::net::web_server::{Acceptor, WebServer}; use crate::prelude::*; use crate::shutdown::Shutdown; use crate::system::launch_metrics_task; +use crate::util::future::NonDetachingJoinHandle; use crate::util::io::append_file; use crate::util::logger::LOGGER; @@ -103,6 +104,17 @@ async fn inner_main( .expect("send shutdown signal"); }); + // Both run until this block returns with the shutdown message, at which + // point their handles drop and abort them. + let deferred_power_ctx = rpc_ctx.clone(); + let _deferred_power = NonDetachingJoinHandle::from(tokio::spawn( + crate::shutdown::run_deferred_power_actions(deferred_power_ctx), + )); + let power_key_ctx = rpc_ctx.clone(); + let _power_key = NonDetachingJoinHandle::from(tokio::spawn( + crate::power_key::watch_power_key(power_key_ctx), + )); + let metrics_ctx = rpc_ctx.clone(); let metrics_task = tokio::spawn(async move { launch_metrics_task(&metrics_ctx.metrics_cache, || { diff --git a/shared-libs/crates/start-core/src/db/model/public.rs b/shared-libs/crates/start-core/src/db/model/public.rs index ccd4635926..2f2d9b3d06 100644 --- a/shared-libs/crates/start-core/src/db/model/public.rs +++ b/shared-libs/crates/start-core/src/db/model/public.rs @@ -131,6 +131,7 @@ impl Public { shutting_down: false, restarting: false, restart: None, + deferred_power_action: None, }, unread_notification_count: 0, pubkey: ssh_key::PublicKey::from(&account.ssh_key) @@ -218,6 +219,14 @@ pub enum RestartReason { Update, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum PowerAction { + Restart, + Shutdown, +} + #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] #[serde(rename_all = "camelCase")] #[model = "Model"] @@ -451,6 +460,10 @@ pub struct ServerStatus { pub restarting: bool, #[serde(default)] pub restart: Option, + /// A restart or shutdown that was asked for while a backup was running, and + /// which StartOS carries out once the backup finishes. + #[serde(default)] + pub deferred_power_action: Option, } #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] diff --git a/shared-libs/crates/start-core/src/init.rs b/shared-libs/crates/start-core/src/init.rs index bc91ee0b43..03d3707b17 100644 --- a/shared-libs/crates/start-core/src/init.rs +++ b/shared-libs/crates/start-core/src/init.rs @@ -396,6 +396,7 @@ pub async fn init( shutting_down: false, restarting: false, restart: None, + deferred_power_action: None, }; db.mutate(|v| { let server_info = v.as_public_mut().as_server_info_mut(); diff --git a/shared-libs/crates/start-core/src/lib.rs b/shared-libs/crates/start-core/src/lib.rs index 4726ac6908..cc72cbc8a5 100644 --- a/shared-libs/crates/start-core/src/lib.rs +++ b/shared-libs/crates/start-core/src/lib.rs @@ -67,6 +67,7 @@ pub mod middleware; pub mod net; pub mod notifications; pub mod os_install; +pub mod power_key; pub mod prelude; pub mod progress; pub mod registry; @@ -338,6 +339,13 @@ pub fn server() -> ParentHandler { .with_about("about.restart-server") .with_call_remote::(), ) + .subcommand( + "cancel-deferred-power", + from_fn_async(shutdown::cancel_deferred_power) + .no_display() + .with_about("about.cancel-deferred-power") + .with_call_remote::(), + ) .subcommand( "rebuild", from_fn_async(shutdown::rebuild) diff --git a/shared-libs/crates/start-core/src/power_key.rs b/shared-libs/crates/start-core/src/power_key.rs new file mode 100644 index 0000000000..0021baa973 --- /dev/null +++ b/shared-libs/crates/start-core/src/power_key.rs @@ -0,0 +1,252 @@ +//! The case power button, while a backup is running. +//! +//! systemd-logind powers the server off the moment the power key is pressed, +//! which cuts a running backup off mid-write and can corrupt the service being +//! written. For as long as a backup is underway startd therefore holds a logind +//! `block` inhibitor on `handle-power-key`, and reads the key itself so the +//! press still means something: it records a deferred shutdown, which the web +//! UI surfaces and which StartOS carries out once the backup finishes. +//! +//! The inhibitor names `handle-power-key` and not `shutdown` on purpose — +//! blocking `shutdown` would also block the power-off StartOS itself asks +//! systemd for at the end of a graceful teardown. Everything here degrades to +//! today's behavior if it fails: no inhibitor means logind powers off as it +//! always has, and no readable key device means the press is simply not seen. + +use std::io::Read; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; + +use futures::future::join_all; +use nix::sys::stat; +use patch_db::json_ptr::JsonPointer; +use tokio::io::unix::AsyncFd; +use zbus::proxy; +use zbus::zvariant::OwnedFd; + +use crate::context::RpcContext; +use crate::db::model::public::{PowerAction, ServerStatus}; +use crate::prelude::*; +use crate::shutdown::defer_until_backup_complete; +use crate::sound::BEP; + +const EV_KEY: u16 = 0x01; +const KEY_POWER: usize = 116; +const KEY_PRESSED: i32 = 1; + +const EVENT_SIZE: usize = std::mem::size_of::(); +/// Offset of `struct input_event`'s trailing `type`, `code` and `value`. The +/// leading timestamp's width varies by architecture; those three fields are +/// always the last 8 bytes. +const EVENT_TAIL: usize = EVENT_SIZE - 8; + +const STATUS_INFO_PTR: &str = "/public/serverInfo/statusInfo"; +const POWER_SWITCH_TAG_DIR: &str = "/run/udev/tags/power-switch"; + +#[proxy( + interface = "org.freedesktop.login1.Manager", + default_service = "org.freedesktop.login1", + default_path = "/org/freedesktop/login1" +)] +trait Login1Manager { + /// The returned file descriptor *is* the lock — it is released when dropped. + fn inhibit(&self, what: &str, who: &str, why: &str, mode: &str) -> Result; +} + +pub async fn watch_power_key(ctx: RpcContext) { + tokio::join!(inhibit_while_backing_up(ctx.clone()), read_power_key(ctx)); +} + +/// Holds a logind `handle-power-key` lock for exactly as long as a backup is +/// running, so that a press during one reaches [`read_power_key`] instead of +/// powering the server off. +async fn inhibit_while_backing_up(ctx: RpcContext) { + let manager = match logind().await { + Ok(manager) => manager, + Err(e) => { + tracing::warn!( + "cannot reach systemd-logind, so the power button will keep powering the server off during a backup: {e}" + ); + tracing::debug!("{e:?}"); + return; + } + }; + let mut watch = ctx + .db + .watch(STATUS_INFO_PTR.parse::().unwrap()) + .await + .typed::(); + loop { + let held = async { + watch + .wait_for(|status: &ServerStatus| status.backup_progress.is_some()) + .await?; + let lock = manager + .inhibit( + "handle-power-key", + "StartOS", + "A backup is running", + "block", + ) + .await?; + watch + .wait_for(|status: &ServerStatus| status.backup_progress.is_none()) + .await?; + drop(lock); + Ok::<_, Error>(()) + } + .await; + if let Err(e) = held { + tracing::error!("stopped inhibiting the power button during backups: {e}"); + tracing::debug!("{e:?}"); + return; + } + } +} + +async fn read_power_key(ctx: RpcContext) { + let devices = match power_key_devices().await { + Ok(devices) => devices, + Err(e) => { + tracing::error!("could not enumerate input devices: {e}"); + tracing::debug!("{e:?}"); + return; + } + }; + if devices.is_empty() { + tracing::info!("no input device reports a power key"); + return; + } + join_all( + devices + .into_iter() + .map(|path| read_device(path, ctx.clone())), + ) + .await; +} + +async fn read_device(path: PathBuf, ctx: RpcContext) { + let device = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(&path) + .and_then(AsyncFd::new); + let device = match device { + Ok(device) => device, + Err(e) => { + tracing::warn!("could not read the power key from {}: {e}", path.display()); + return; + } + }; + // evdev only ever hands back whole events, so a batch never splits one. + let mut buf = [0u8; EVENT_SIZE * 16]; + loop { + let read = match device.readable().await { + Ok(mut guard) => guard.try_io(|fd| { + let mut file = fd.get_ref(); + file.read(&mut buf) + }), + Err(e) => { + tracing::warn!("stopped reading {}: {e}", path.display()); + return; + } + }; + match read { + Err(_would_block) => continue, + Ok(Err(e)) => { + tracing::warn!("stopped reading {}: {e}", path.display()); + return; + } + Ok(Ok(len)) => { + for event in buf[..len].chunks_exact(EVENT_SIZE) { + if is_power_key_press(event) { + on_power_key(&ctx).await; + } + } + } + } + } +} + +async fn on_power_key(ctx: &RpcContext) { + match defer_until_backup_complete(ctx, PowerAction::Shutdown).await { + Ok(true) => { + tracing::info!("power key pressed during a backup; shutting down once it finishes"); + // The only feedback available to whoever is standing at the server, + // whose press otherwise appears to have done nothing. Spawned so a + // contended sound device cannot stall the reader. + tokio::spawn(async { BEP.play().await.log_err() }); + } + // logind is not inhibited and is already powering the server off. + Ok(false) => (), + Err(e) => { + tracing::error!("could not defer the shutdown for the running backup: {e}"); + tracing::debug!("{e:?}"); + } + } +} + +async fn logind() -> Result, Error> { + Ok(Login1ManagerProxy::new(&zbus::Connection::system().await?).await?) +} + +/// The devices systemd-logind itself treats as power switches: udev tags them +/// `power-switch`, and names each one by device number in that tag's directory. +/// Reading logind's own device set rather than picking devices by capability is +/// what keeps a press meaning here exactly what it would have meant to logind. +async fn power_key_devices() -> Result, Error> { + let mut devices = Vec::new(); + let mut dir = tokio::fs::read_dir("/dev/input").await?; + while let Some(entry) = dir.next_entry().await? { + if !entry.file_name().as_encoded_bytes().starts_with(b"event") { + continue; + } + let id = device_id(entry.metadata().await?.rdev()); + if Path::new(POWER_SWITCH_TAG_DIR).join(id).exists() { + devices.push(entry.path()); + } + } + Ok(devices) +} + +fn device_id(rdev: u64) -> String { + format!("c{}:{}", stat::major(rdev), stat::minor(rdev)) +} + +fn is_power_key_press(event: &[u8]) -> bool { + let tail = &event[EVENT_TAIL..]; + u16::from_ne_bytes([tail[0], tail[1]]) == EV_KEY + && u16::from_ne_bytes([tail[2], tail[3]]) as usize == KEY_POWER + && i32::from_ne_bytes([tail[4], tail[5], tail[6], tail[7]]) == KEY_PRESSED +} + +#[cfg(test)] +mod test { + use super::*; + + /// `/dev/input/event0` is char device 13:64, and udev names its tag entry + /// after exactly that. + #[test] + fn names_a_device_the_way_udev_tags_it() { + assert_eq!(device_id(stat::makedev(13, 64)), "c13:64"); + assert_eq!(device_id(stat::makedev(13, 71)), "c13:71"); + } + + #[test] + fn recognizes_a_power_key_press() { + let mut event = [0u8; EVENT_SIZE]; + event[EVENT_TAIL..EVENT_TAIL + 2].copy_from_slice(&EV_KEY.to_ne_bytes()); + event[EVENT_TAIL + 2..EVENT_TAIL + 4].copy_from_slice(&(KEY_POWER as u16).to_ne_bytes()); + event[EVENT_TAIL + 4..].copy_from_slice(&KEY_PRESSED.to_ne_bytes()); + assert!(is_power_key_press(&event)); + + // A release, which must not power anything off. + event[EVENT_TAIL + 4..].copy_from_slice(&0i32.to_ne_bytes()); + assert!(!is_power_key_press(&event)); + + // Some other key. + event[EVENT_TAIL + 2..EVENT_TAIL + 4].copy_from_slice(&30u16.to_ne_bytes()); + event[EVENT_TAIL + 4..].copy_from_slice(&KEY_PRESSED.to_ne_bytes()); + assert!(!is_power_key_press(&event)); + } +} diff --git a/shared-libs/crates/start-core/src/shutdown.rs b/shared-libs/crates/start-core/src/shutdown.rs index 50ae71f855..56976f10f6 100644 --- a/shared-libs/crates/start-core/src/shutdown.rs +++ b/shared-libs/crates/start-core/src/shutdown.rs @@ -1,9 +1,11 @@ use clap::Parser; +use patch_db::json_ptr::JsonPointer; use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::PLATFORM; use crate::context::RpcContext; +use crate::db::model::public::{PowerAction, ServerStatus}; use crate::disk::main::export; use crate::init::{STANDBY_MODE_PATH, SYSTEM_REBUILD_PATH}; use crate::prelude::*; @@ -119,7 +121,7 @@ fn systemd_is_stopping() -> bool { .unwrap_or(false) } -#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, Parser, TS)] #[group(skip)] #[ts(export)] #[serde(rename_all = "camelCase")] @@ -132,8 +134,16 @@ pub struct ShutdownParams { #[arg(long = "nowait", action = clap::ArgAction::SetFalse, help = "help.arg.nowait")] #[serde(default)] wait: bool, + /// Let a running backup finish first, rather than interrupting it. Off by + /// default, so the systemd units that drive a real power-off — which cannot + /// wait — keep their existing behavior. + #[arg(long = "after-backup", help = "help.arg.after-backup")] + #[serde(default)] + after_backup: bool, } +const STATUS_INFO_PTR: &str = "/public/serverInfo/statusInfo"; + async fn begin_shutdown(ctx: &RpcContext, restart: bool, wait: bool) { ctx.shutdown .send(Some(Shutdown { @@ -147,17 +157,93 @@ async fn begin_shutdown(ctx: &RpcContext, restart: bool, wait: bool) { } } +/// Records `action` as the deferred power action if a backup is underway, and +/// reports whether it did. The backup check and the write share one mutation, so +/// a backup that finishes while the request is in flight can never strand the +/// action — either it is recorded with the backup still running (and +/// [`run_deferred_power_actions`] picks it up), or the caller powers off now. +pub async fn defer_until_backup_complete( + ctx: &RpcContext, + action: PowerAction, +) -> Result { + ctx.db + .mutate(|db| { + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + if status.as_backup_progress().transpose_ref().is_none() { + return Ok(false); + } + status.as_deferred_power_action_mut().ser(&Some(action))?; + Ok(true) + }) + .await + .result +} + +/// Carries out a deferred power action once the backup it was waiting on +/// finishes. Runs for the lifetime of startd, because the action can be recorded +/// at any point during a backup — from the web UI, the CLI, or the power button. +pub async fn run_deferred_power_actions(ctx: RpcContext) { + let mut watch = ctx + .db + .watch(STATUS_INFO_PTR.parse::().unwrap()) + .await + .typed::(); + loop { + if let Err(e) = watch + .wait_for(|status| { + status.deferred_power_action.is_some() && status.backup_progress.is_none() + }) + .await + { + tracing::error!("stopped watching for deferred power actions: {e}"); + tracing::debug!("{e:?}"); + return; + } + // Taking the action clears it in the same mutation, so a cancellation + // that lands first wins and this run does nothing. + let action = ctx + .db + .mutate(|db| { + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + let action = status.as_deferred_power_action().de()?; + status.as_deferred_power_action_mut().ser(&None)?; + Ok(action) + }) + .await + .result + .log_err() + .flatten(); + let res = match action { + Some(PowerAction::Restart) => { + tracing::info!("backup finished; carrying out the deferred restart"); + restart(ctx.clone(), ShutdownParams::default()).await + } + Some(PowerAction::Shutdown) => { + tracing::info!("backup finished; carrying out the deferred shutdown"); + shutdown(ctx.clone(), ShutdownParams::default()).await + } + None => continue, + }; + if let Err(e) = res { + tracing::error!("deferred power action failed: {e}"); + tracing::debug!("{e:?}"); + } + return; + } +} + pub async fn shutdown( ctx: RpcContext, - ShutdownParams { wait }: ShutdownParams, + ShutdownParams { wait, after_backup }: ShutdownParams, ) -> Result<(), Error> { + if after_backup && defer_until_backup_complete(&ctx, PowerAction::Shutdown).await? { + return Ok(()); + } ctx.db .mutate(|db| { - db.as_public_mut() - .as_server_info_mut() - .as_status_info_mut() - .as_shutting_down_mut() - .ser(&true) + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + status.as_deferred_power_action_mut().ser(&None)?; + status.as_shutting_down_mut().ser(&true) }) .await .result?; @@ -167,15 +253,16 @@ pub async fn shutdown( pub async fn restart( ctx: RpcContext, - ShutdownParams { wait }: ShutdownParams, + ShutdownParams { wait, after_backup }: ShutdownParams, ) -> Result<(), Error> { + if after_backup && defer_until_backup_complete(&ctx, PowerAction::Restart).await? { + return Ok(()); + } ctx.db .mutate(|db| { - db.as_public_mut() - .as_server_info_mut() - .as_status_info_mut() - .as_restarting_mut() - .ser(&true) + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + status.as_deferred_power_action_mut().ser(&None)?; + status.as_restarting_mut().ser(&true) }) .await .result?; @@ -183,7 +270,20 @@ pub async fn restart( Ok(()) } +pub async fn cancel_deferred_power(ctx: RpcContext) -> Result<(), Error> { + ctx.db + .mutate(|db| { + db.as_public_mut() + .as_server_info_mut() + .as_status_info_mut() + .as_deferred_power_action_mut() + .ser(&None) + }) + .await + .result +} + pub async fn rebuild(ctx: RpcContext) -> Result<(), Error> { tokio::fs::write(SYSTEM_REBUILD_PATH, b"").await?; - restart(ctx, ShutdownParams { wait: false }).await + restart(ctx, ShutdownParams::default()).await } diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts index e841e281f0..a7df4426d3 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts @@ -805,4 +805,9 @@ export default { 912: 'Die StartOS-Daten auf dem ausgewählten Datenlaufwerk befinden sich auf einer Partition neben einer älteren OS-Installation und können auf diesem Gerät nicht beibehalten werden. Um das Laufwerk zu löschen und neu zu beginnen, wählen Sie "Überschreiben".', 913: 'Die StartOS-Daten auf dem ausgewählten Datenlaufwerk erstrecken sich über das gesamte Laufwerk, sodass das OS nicht auf demselben Laufwerk installiert werden kann, ohne sie zu löschen. Um Ihre Daten zu behalten, wählen Sie ein anderes OS-Laufwerk. Um sie zu löschen, wählen Sie "Überschreiben".', 914: 'Anmeldung erfolgreich, aber der Server hat den neuen Geräteschlüssel abgelehnt. Versuchen Sie es erneut.', + 915: 'Derzeit läuft eine Sicherung. Ein Ausschalten kann jetzt die Sicherung des gerade geschriebenen Dienstes beschädigen.', + 916: 'Auf Abschluss der Sicherung warten', + 917: 'Jetzt herunterfahren', + 918: 'Eine Sicherung läuft. Ihr Server wird nach deren Abschluss neu gestartet.', + 919: 'Eine Sicherung läuft. Ihr Server wird nach deren Abschluss heruntergefahren.', } satisfies i18n diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts index ef80bbe69d..530779c709 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts @@ -806,4 +806,9 @@ export const ENGLISH: Record = { 'The StartOS data on the selected data drive is stored on a partition alongside an older OS installation, and cannot be preserved on this device. To erase the drive and start fresh, choose "Overwrite".': 912, 'The StartOS data on the selected data drive spans the entire drive, so the OS cannot be installed to the same drive without erasing it. To preserve your data, select a different OS drive. To erase it, choose "Overwrite".': 913, 'Login succeeded, but the server rejected the new device key. Try again.': 914, + 'A backup is currently running. Powering down now can corrupt the backup of the service being written.': 915, + 'Wait for backup to complete': 916, + 'Shut down now': 917, + 'A backup is running. Your server will restart when it finishes.': 918, + 'A backup is running. Your server will shut down when it finishes.': 919, } diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts index 1a206e4410..69fad94770 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts @@ -805,4 +805,9 @@ export default { 912: 'Los datos de StartOS en la unidad de datos seleccionada están en una partición junto a una instalación de SO anterior, y no pueden conservarse en este dispositivo. Para borrar la unidad y empezar de nuevo, elija "Sobrescribir".', 913: 'Los datos de StartOS en la unidad de datos seleccionada ocupan toda la unidad, por lo que el SO no puede instalarse en la misma unidad sin borrarlos. Para conservar sus datos, seleccione otra unidad para el SO. Para borrarlos, elija "Sobrescribir".', 914: 'Inicio de sesión correcto, pero el servidor rechazó la nueva clave del dispositivo. Inténtelo de nuevo.', + 915: 'Hay una copia de seguridad en curso. Apagar ahora puede dañar la copia de seguridad del servicio que se está escribiendo.', + 916: 'Esperar a que termine la copia de seguridad', + 917: 'Apagar ahora', + 918: 'Hay una copia de seguridad en curso. Su servidor se reiniciará cuando termine.', + 919: 'Hay una copia de seguridad en curso. Su servidor se apagará cuando termine.', } satisfies i18n diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts index 957b12fe0e..b104e750c7 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts @@ -805,4 +805,9 @@ export default { 912: 'Les données StartOS du disque de données sélectionné se trouvent sur une partition aux côtés d’une ancienne installation de l’OS et ne peuvent pas être conservées sur cet appareil. Pour effacer le disque et repartir à zéro, choisissez « Écraser ».', 913: 'Les données StartOS du disque de données sélectionné occupent l’intégralité du disque : l’OS ne peut donc pas être installé sur le même disque sans les effacer. Pour conserver vos données, sélectionnez un autre disque pour l’OS. Pour les effacer, choisissez « Écraser ».', 914: 'Connexion réussie, mais le serveur a rejeté la nouvelle clé de l’appareil. Réessayez.', + 915: 'Une sauvegarde est en cours. Éteindre maintenant peut corrompre la sauvegarde du service en cours d’écriture.', + 916: 'Attendre la fin de la sauvegarde', + 917: 'Éteindre maintenant', + 918: 'Une sauvegarde est en cours. Votre serveur redémarrera une fois celle-ci terminée.', + 919: 'Une sauvegarde est en cours. Votre serveur s’éteindra une fois celle-ci terminée.', } satisfies i18n diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts index 73db1061e3..d6205a6a59 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts @@ -805,4 +805,9 @@ export default { 912: 'Dane StartOS na wybranym dysku danych znajdują się na partycji obok starszej instalacji systemu i nie można ich zachować na tym urządzeniu. Aby wymazać dysk i zacząć od nowa, wybierz „Nadpisz”.', 913: 'Dane StartOS na wybranym dysku danych zajmują cały dysk, więc systemu nie można zainstalować na tym samym dysku bez ich wymazania. Aby zachować dane, wybierz inny dysk systemowy. Aby je wymazać, wybierz „Nadpisz”.', 914: 'Logowanie powiodło się, ale serwer odrzucił nowy klucz urządzenia. Spróbuj ponownie.', + 915: 'Trwa tworzenie kopii zapasowej. Wyłączenie teraz może uszkodzić kopię zapasową aktualnie zapisywanej usługi.', + 916: 'Poczekaj na zakończenie kopii zapasowej', + 917: 'Wyłącz teraz', + 918: 'Trwa tworzenie kopii zapasowej. Serwer zostanie ponownie uruchomiony po jej zakończeniu.', + 919: 'Trwa tworzenie kopii zapasowej. Serwer zostanie wyłączony po jej zakończeniu.', } satisfies i18n diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/PowerAction.ts b/shared-libs/ts-modules/start-core/lib/osBindings/PowerAction.ts new file mode 100644 index 0000000000..008c8992db --- /dev/null +++ b/shared-libs/ts-modules/start-core/lib/osBindings/PowerAction.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PowerAction = 'restart' | 'shutdown' diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/ServerStatus.ts b/shared-libs/ts-modules/start-core/lib/osBindings/ServerStatus.ts index 94cf76c898..783ce3296a 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/ServerStatus.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/ServerStatus.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { FullProgress } from './FullProgress' +import type { PowerAction } from './PowerAction' import type { RestartReason } from './RestartReason' export type ServerStatus = { @@ -8,4 +9,9 @@ export type ServerStatus = { shuttingDown: boolean restarting: boolean restart: RestartReason | null + /** + * A restart or shutdown that was asked for while a backup was running, and + * which StartOS carries out once the backup finishes. + */ + deferredPowerAction: PowerAction | null } diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts b/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts index 21a7d7c0d9..62367dbd3f 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts @@ -8,4 +8,10 @@ export type ShutdownParams = { * container shutdown, so the connection drops once services are stopped. */ wait: boolean + /** + * Let a running backup finish first, rather than interrupting it. Off by + * default, so the systemd units that drive a real power-off — which cannot + * wait — keep their existing behavior. + */ + afterBackup: boolean } diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/index.ts b/shared-libs/ts-modules/start-core/lib/osBindings/index.ts index 99c95a435f..566038c294 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/index.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/index.ts @@ -228,6 +228,7 @@ export { Percentage } from './Percentage' export { PluginHostnameInfo } from './PluginHostnameInfo' export { PluginId } from './PluginId' export { PortForward } from './PortForward' +export { PowerAction } from './PowerAction' export { Progress } from './Progress' export { ProgressUnits } from './ProgressUnits' export { ProxyAuth } from './ProxyAuth' From 5efc8024af9f03a0dbeedd0e7ac4e40edc4b3076 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:30:57 +0000 Subject: [PATCH 2/7] fix(start-os): review follow-ups on the backup-aware power flow - 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". --- .../start-cli/man/start-cli-server-restart.1 | 2 +- .../start-cli/man/start-cli-server-shutdown.1 | 2 +- projects/start-os/CHANGELOG.md | 4 +- projects/start-os/docs/src/backup-create.md | 2 +- projects/start-os/docs/src/cli-reference.md | 4 +- projects/start-os/docs/src/surge-and-ups.md | 3 + .../components/header/menu.component.ts | 37 ++----- .../components/header/power.component.ts | 26 +++-- .../src/app/routes/portal/portal.component.ts | 23 ++--- .../routes/general/general.component.ts | 7 +- .../services/api/embassy-mock-api.service.ts | 2 + .../web/ui/src/app/services/power.service.ts | 48 +++++++++ .../crates/start-core/locales/i18n.yaml | 10 +- .../crates/start-core/src/bins/startd.rs | 19 +++- shared-libs/crates/start-core/src/lib.rs | 1 + .../crates/start-core/src/power_key.rs | 98 ++++++++++--------- shared-libs/crates/start-core/src/shutdown.rs | 92 ++++++++++------- .../lib/osBindings/ShutdownParams.ts | 2 + 18 files changed, 228 insertions(+), 154 deletions(-) create mode 100644 projects/start-os/web/ui/src/app/services/power.service.ts diff --git a/projects/start-cli/man/start-cli-server-restart.1 b/projects/start-cli/man/start-cli-server-restart.1 index 2a06d058c7..2b2519167c 100644 --- a/projects/start-cli/man/start-cli-server-restart.1 +++ b/projects/start-cli/man/start-cli-server-restart.1 @@ -13,7 +13,7 @@ Restart the server Return immediately instead of waiting for graceful shutdown to complete .TP \fB\-\-after\-backup\fR -Wait for a running backup to finish before powering off +Wait for a running backup to finish first .TP \fB\-h\fR, \fB\-\-help\fR Print help diff --git a/projects/start-cli/man/start-cli-server-shutdown.1 b/projects/start-cli/man/start-cli-server-shutdown.1 index 37885db4f7..058ef4681b 100644 --- a/projects/start-cli/man/start-cli-server-shutdown.1 +++ b/projects/start-cli/man/start-cli-server-shutdown.1 @@ -13,7 +13,7 @@ Shutdown the server Return immediately instead of waiting for graceful shutdown to complete .TP \fB\-\-after\-backup\fR -Wait for a running backup to finish before powering off +Wait for a running backup to finish first .TP \fB\-h\fR, \fB\-\-help\fR Print help diff --git a/projects/start-os/CHANGELOG.md b/projects/start-os/CHANGELOG.md index abafb186c6..637203d4b5 100644 --- a/projects/start-os/CHANGELOG.md +++ b/projects/start-os/CHANGELOG.md @@ -44,8 +44,8 @@ file tracks notable changes since the move to the monorepo. prompt counts down and takes that option for you. StartOS then carries out the restart or shutdown as soon as the backup completes, and until then a bar along the bottom of the screen says what is coming and lets you cancel it. - Pressing the server's physical power button during a backup does the same, - rather than powering off immediately. Over the CLI, + Pressing the server's physical power button during a backup waits for the + backup too, rather than powering off immediately. Over the CLI, `start-cli server restart` and `server shutdown` take `--after-backup` for the same behavior and `start-cli server cancel-deferred-power` calls it off. See [Creating Backups](https://docs.start9.com/start-os/backup-create.html). diff --git a/projects/start-os/docs/src/backup-create.md b/projects/start-os/docs/src/backup-create.md index cf9a0dd7b9..922627fb4a 100644 --- a/projects/start-os/docs/src/backup-create.md +++ b/projects/start-os/docs/src/backup-create.md @@ -21,7 +21,7 @@ Back up your server's data to a physical drive or a network folder. 1. To back up a service, StartOS first stops it (if it was running), performs the backup, then restarts it — but only if it was running beforehand. A service that was already stopped stays stopped. Consequently a service cannot be used while it is backing up, though you may continue to use your server and other services in the meantime. -1. Restarting or shutting down mid-backup can corrupt the backup of whichever service is being written at that moment, so StartOS asks first. Choosing `Restart` or `Shutdown` while a backup is running offers to wait for the backup to finish instead, and the prompt takes that option for you if you do not choose within 30 seconds. Pressing the server's physical power button during a backup does the same. StartOS then performs the restart or shutdown as soon as the backup completes; until then a bar along the bottom of the screen says what is coming and lets you cancel it. To power down immediately anyway, choose the "now" option in that prompt. +1. Restarting or shutting down mid-backup can corrupt the backup of whichever service is being written at that moment, so StartOS asks first. Choosing `Restart` or `Shutdown` while a backup is running offers to wait for the backup to finish instead, and takes that option for you if you do not choose within 30 seconds — to power down regardless, choose the "now" option in that prompt. Pressing the server's physical power button during a backup waits for the backup rather than powering off, without asking. Either way StartOS performs the restart or shutdown as soon as the backup completes, and until then a bar along the bottom of the screen says what is coming and lets you cancel it. 1. Upon completion, StartOS issues a backup report, indicating which services were backed up, as well as any errors. diff --git a/projects/start-os/docs/src/cli-reference.md b/projects/start-os/docs/src/cli-reference.md index 5d76797a33..3c30114884 100644 --- a/projects/start-os/docs/src/cli-reference.md +++ b/projects/start-os/docs/src/cli-reference.md @@ -54,13 +54,13 @@ Restart, shut down, update, and configure the server. ### `start-cli server restart` -Restart the server. +Restart the server. Without `--after-backup` this interrupts a running backup, unlike the web UI, which offers to wait. - `--after-backup` — Wait for a running backup to finish first ### `start-cli server shutdown` -Shut down the server. +Shut down the server. Without `--after-backup` this interrupts a running backup, unlike the web UI, which offers to wait. - `--after-backup` — Wait for a running backup to finish first diff --git a/projects/start-os/docs/src/surge-and-ups.md b/projects/start-os/docs/src/surge-and-ups.md index 001ae66fc2..9587e0f98b 100644 --- a/projects/start-os/docs/src/surge-and-ups.md +++ b/projects/start-os/docs/src/surge-and-ups.md @@ -47,3 +47,6 @@ There are three common topologies. For a home server, **line-interactive** is th StartOS does not currently include built-in support for UPS monitoring (USB or network), so it cannot automatically shut down when the battery is low during an extended outage. The server will run until battery exhaustion and then power off uncleanly. This still carries some risk of data corruption, but it is dramatically less risky than facing the original surge, brownout, or sudden outage with no UPS at all. If your area has frequent or long outages, size your UPS to give yourself time to shut down manually from the StartOS UI before the battery runs out. + +> [!NOTE] +> If a backup is running when you do, StartOS offers to wait for the backup to finish and takes that option if you do not choose within 30 seconds — which on battery is rarely what you want. Choose `Shut down now` instead. diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts index 3e0310eac6..8f6bbdee83 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts @@ -1,12 +1,10 @@ import { Component, inject } from '@angular/core' -import { toSignal } from '@angular/core/rxjs-interop' import { RouterLink } from '@angular/router' import { DialogService, DocsLinkDirective, i18nPipe, SafeLinksDirective, - TaskService, } from '@start9labs/shared' import { T } from '@start9labs/start-core' import { @@ -19,10 +17,9 @@ import { import { filter } from 'rxjs' import { ApiService } from 'src/app/services/api/embassy-api.service' import { AuthService } from 'src/app/services/auth.service' -import { OSService } from 'src/app/services/os.service' +import { PowerService } from 'src/app/services/power.service' import { STATUS } from 'src/app/services/status.service' import { ABOUT } from './about.component' -import { POWER } from './power.component' @Component({ selector: 'header-menu', @@ -142,33 +139,21 @@ import { POWER } from './power.component' export class HeaderMenuComponent { private readonly api = inject(ApiService) private readonly auth = inject(AuthService) - private readonly tasks = inject(TaskService) private readonly dialog = inject(DialogService) + private readonly power = inject(PowerService) open = false readonly status = inject(STATUS) - readonly backingUp = toSignal(inject(OSService).backingUp$, { - initialValue: false, - }) about() { this.dialog.openComponent(ABOUT, { label: 'About this server' }).subscribe() } async promptPower(action: T.PowerAction) { - // Interrupting a backup can corrupt the service being written, so offer to - // wait for it instead of asking the usual "are you sure". - if (this.backingUp()) { - this.dialog - .openComponent(POWER, { - label: action === 'restart' ? 'Restart' : 'Warning', - size: 's', - data: action, - }) - .subscribe(now => this.power(action, !now)) - return - } + // During a backup the choice on offer is a different one, and asking it is + // confirmation enough. + if (this.power.backingUp()) return this.power.power(action) this.dialog .openConfirm( @@ -195,17 +180,7 @@ export class HeaderMenuComponent { }, ) .pipe(filter(Boolean)) - .subscribe(() => this.power(action, false)) - } - - private power(action: T.PowerAction, afterBackup: boolean) { - this.tasks.run( - async () => - action === 'restart' - ? await this.api.restartServer({ afterBackup }) - : await this.api.shutdownServer({ afterBackup }), - `Beginning ${action}`, - ) + .subscribe(() => this.power.power(action)) } logout() { diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts index 679cc59b48..12d5b62385 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts @@ -1,10 +1,10 @@ -import { Component } from '@angular/core' -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop' +import { Component, signal } from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { i18nPipe } from '@start9labs/shared' import { T } from '@start9labs/start-core' import { TuiButton, TuiDialogContext } from '@taiga-ui/core' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' -import { map, take, timer } from 'rxjs' +import { take, timer } from 'rxjs' // Long enough to read the warning, short enough to not strand someone who // walked away mid-decision. @@ -41,19 +41,17 @@ export class PowerComponent { protected readonly action = this.context.data - protected readonly seconds = toSignal( - timer(0, 1000).pipe( - map(tick => COUNTDOWN - tick), - take(COUNTDOWN + 1), - ), - { initialValue: COUNTDOWN }, - ) + protected readonly seconds = signal(COUNTDOWN) constructor() { - // Choosing neither is choosing to wait. - timer(COUNTDOWN * 1000) - .pipe(takeUntilDestroyed()) - .subscribe(() => this.wait()) + // One timer drives both the label and the default, so the choice is made + // exactly when the label says it will be. + timer(0, 1000) + .pipe(take(COUNTDOWN + 1), takeUntilDestroyed()) + .subscribe(tick => { + this.seconds.set(COUNTDOWN - tick) + if (tick === COUNTDOWN) this.wait() + }) } protected now() { diff --git a/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts b/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts index 2b51246959..ea894dd3e0 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts @@ -2,7 +2,7 @@ import { Component, inject, signal } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' import { RouterOutlet } from '@angular/router' import { WA_IS_MOBILE } from '@ng-web-apis/platform' -import { i18nPipe, LeafProgressPipe, TaskService } from '@start9labs/shared' +import { i18nPipe, LeafProgressPipe } from '@start9labs/shared' import { TuiButton, TuiCell, @@ -14,10 +14,10 @@ import { import { TuiActionBar, TuiProgress } from '@taiga-ui/kit' import { PatchDB } from 'patch-db-client' import { TabsComponent } from 'src/app/routes/portal/components/tabs.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' import { OSService } from 'src/app/services/os.service' import { DataModel } from 'src/app/services/patch-db/data-model' import { PluginsService } from 'src/app/services/plugins.service' +import { PowerService } from 'src/app/services/power.service' import { HeaderComponent } from './components/header/header.component' @Component({ @@ -49,8 +49,7 @@ import { HeaderComponent } from './components/header/header.component' } - } - @if (restartReason(); as reason) { + } @else if (restartReason(); as reason) { @@ -79,9 +78,8 @@ import { HeaderComponent } from './components/header/header.component' {{ 'Restart' | i18n }} - } - @if (deferredPower(); as action) { - + } @else if (deferredPower(); as action) { + @if (action === 'restart') { @@ -193,9 +191,8 @@ import { HeaderComponent } from './components/header/header.component' ], }) export class PortalComponent { - private readonly tasks = inject(TaskService) private readonly patch = inject>(PatchDB) - private readonly api = inject(ApiService) + private readonly power = inject(PowerService) readonly mobile = inject(WA_IS_MOBILE) readonly plugins = inject(PluginsService) @@ -213,13 +210,11 @@ export class PortalComponent { } restart() { - this.tasks.run(async () => { - this.bar.set(false) - await this.api.restartServer({}) - }, 'Beginning restart') + this.bar.set(false) + this.power.power('restart') } cancelDeferredPower() { - this.tasks.run(async () => await this.api.cancelDeferredPower({})) + this.power.cancel() } } diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts index 836e12ac40..5f0ed39cec 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts @@ -43,6 +43,7 @@ import { ABOUT } from 'src/app/routes/portal/components/header/about.component' import { ApiService } from 'src/app/services/api/embassy-api.service' import { ConfigService } from 'src/app/services/config.service' import { OSService } from 'src/app/services/os.service' +import { PowerService } from 'src/app/services/power.service' import { DataModel } from 'src/app/services/patch-db/data-model' import { TitleDirective } from 'src/app/services/title.service' import { KeyboardSelectComponent } from './keyboard-select.component' @@ -281,6 +282,7 @@ export default class SystemGeneralComponent { private readonly injector = inject(INJECTOR) private readonly win = inject(WA_WINDOW) private readonly config = inject(ConfigService) + private readonly power = inject(PowerService) count = 0 @@ -525,9 +527,6 @@ export default class SystemGeneralComponent { } private async restart() { - this.tasks.run( - async () => await this.api.restartServer({}), - 'Beginning restart', - ) + this.power.power('restart') } } diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts index f1d991fe0e..ff73fa9540 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts @@ -396,6 +396,7 @@ export class MockApiService extends ApiService { if (params.afterBackup && this.backingUp) { return this.deferPower('restart') } + this.deferPower(null) const patch = [ { @@ -426,6 +427,7 @@ export class MockApiService extends ApiService { if (params.afterBackup && this.backingUp) { return this.deferPower('shutdown') } + this.deferPower(null) const patch = [ { diff --git a/projects/start-os/web/ui/src/app/services/power.service.ts b/projects/start-os/web/ui/src/app/services/power.service.ts new file mode 100644 index 0000000000..fe9f3db05b --- /dev/null +++ b/projects/start-os/web/ui/src/app/services/power.service.ts @@ -0,0 +1,48 @@ +import { inject, Injectable } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { DialogService, TaskService } from '@start9labs/shared' +import { T } from '@start9labs/start-core' +import { POWER } from 'src/app/routes/portal/components/header/power.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { OSService } from 'src/app/services/os.service' + +@Injectable({ providedIn: 'root' }) +export class PowerService { + private readonly api = inject(ApiService) + private readonly dialog = inject(DialogService) + private readonly tasks = inject(TaskService) + readonly backingUp = toSignal(inject(OSService).backingUp$, { + initialValue: false, + }) + + /** + * Every in-app route to a restart or shutdown goes through here, so that none + * of them can interrupt a backup: during one the user is offered the choice + * of waiting for it, and the server keeps whichever choice is made. + */ + power(action: T.PowerAction) { + if (!this.backingUp()) return this.run(action, false) + + this.dialog + .openComponent(POWER, { + label: action === 'restart' ? 'Restart' : 'Warning', + size: 's', + data: action, + }) + .subscribe(now => this.run(action, !now)) + } + + cancel() { + this.tasks.run(async () => await this.api.cancelDeferredPower({})) + } + + private run(action: T.PowerAction, afterBackup: boolean) { + this.tasks.run( + async () => + action === 'restart' + ? await this.api.restartServer({ afterBackup }) + : await this.api.shutdownServer({ afterBackup }), + afterBackup ? 'Wait for backup to complete' : `Beginning ${action}`, + ) + } +} diff --git a/shared-libs/crates/start-core/locales/i18n.yaml b/shared-libs/crates/start-core/locales/i18n.yaml index 4bf9c0985e..c3431202cc 100644 --- a/shared-libs/crates/start-core/locales/i18n.yaml +++ b/shared-libs/crates/start-core/locales/i18n.yaml @@ -3158,11 +3158,11 @@ help.arg.gua-wan: pl_PL: "Udostępnij GUA IPv6 w sieci WAN (false = tylko LAN)" help.arg.after-backup: - en_US: "Wait for a running backup to finish before powering off" - de_DE: "Auf den Abschluss einer laufenden Sicherung warten, bevor ausgeschaltet wird" - es_ES: "Esperar a que termine una copia de seguridad en curso antes de apagar" - fr_FR: "Attendre la fin d'une sauvegarde en cours avant d'éteindre" - pl_PL: "Poczekaj na zakończenie trwającej kopii zapasowej przed wyłączeniem" + en_US: "Wait for a running backup to finish first" + de_DE: "Zuerst auf den Abschluss einer laufenden Sicherung warten" + es_ES: "Esperar primero a que termine una copia de seguridad en curso" + fr_FR: "Attendre d'abord la fin d'une sauvegarde en cours" + pl_PL: "Najpierw poczekaj na zakończenie trwającej kopii zapasowej" help.arg.allow-model-mismatch: en_US: "Allow database model mismatch" diff --git a/shared-libs/crates/start-core/src/bins/startd.rs b/shared-libs/crates/start-core/src/bins/startd.rs index 73b84ee0c0..ff731a0b1d 100644 --- a/shared-libs/crates/start-core/src/bins/startd.rs +++ b/shared-libs/crates/start-core/src/bins/startd.rs @@ -71,6 +71,21 @@ async fn inner_main( }; let (rpc_ctx, shutdown) = async { + // A backup and a deferred power action both die with the process that + // was running them, and only `init` — which a startd restart within a + // boot skips — would otherwise clear them. A stale backup in particular + // would leave the power button inhibited with nothing left to release + // it. + rpc_ctx + .db + .mutate(|db| { + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + status.as_backup_progress_mut().ser(&None)?; + status.as_deferred_power_action_mut().ser(&None) + }) + .await + .result?; + crate::hostname::sync_hostname(&rpc_ctx.account.peek(|a| a.hostname.hostname.clone())) .await?; @@ -110,9 +125,9 @@ async fn inner_main( let _deferred_power = NonDetachingJoinHandle::from(tokio::spawn( crate::shutdown::run_deferred_power_actions(deferred_power_ctx), )); - let power_key_ctx = rpc_ctx.clone(); + #[cfg(target_os = "linux")] let _power_key = NonDetachingJoinHandle::from(tokio::spawn( - crate::power_key::watch_power_key(power_key_ctx), + crate::power_key::watch_power_key(rpc_ctx.clone()), )); let metrics_ctx = rpc_ctx.clone(); diff --git a/shared-libs/crates/start-core/src/lib.rs b/shared-libs/crates/start-core/src/lib.rs index cc72cbc8a5..e27846e6e7 100644 --- a/shared-libs/crates/start-core/src/lib.rs +++ b/shared-libs/crates/start-core/src/lib.rs @@ -67,6 +67,7 @@ pub mod middleware; pub mod net; pub mod notifications; pub mod os_install; +#[cfg(target_os = "linux")] pub mod power_key; pub mod prelude; pub mod progress; diff --git a/shared-libs/crates/start-core/src/power_key.rs b/shared-libs/crates/start-core/src/power_key.rs index 0021baa973..3d4eea17c4 100644 --- a/shared-libs/crates/start-core/src/power_key.rs +++ b/shared-libs/crates/start-core/src/power_key.rs @@ -9,9 +9,11 @@ //! //! The inhibitor names `handle-power-key` and not `shutdown` on purpose — //! blocking `shutdown` would also block the power-off StartOS itself asks -//! systemd for at the end of a graceful teardown. Everything here degrades to -//! today's behavior if it fails: no inhibitor means logind powers off as it -//! always has, and no readable key device means the press is simply not seen. +//! systemd for at the end of a graceful teardown. +//! +//! Failure gives the button back to logind rather than taking it away: the +//! inhibitor is only ever held while the key is also being read, so a server +//! whose key StartOS cannot see keeps powering off exactly as it does today. use std::io::Read; use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; @@ -19,6 +21,7 @@ use std::path::{Path, PathBuf}; use futures::future::join_all; use nix::sys::stat; +use patch_db::TypedDbWatch; use patch_db::json_ptr::JsonPointer; use tokio::io::unix::AsyncFd; use zbus::proxy; @@ -54,7 +57,27 @@ trait Login1Manager { } pub async fn watch_power_key(ctx: RpcContext) { - tokio::join!(inhibit_while_backing_up(ctx.clone()), read_power_key(ctx)); + let devices = match power_key_devices().await { + Ok(devices) => devices, + Err(e) => { + tracing::error!("could not enumerate input devices: {e}"); + tracing::debug!("{e:?}"); + return; + } + }; + if devices.is_empty() { + tracing::info!( + "no power-switch input device, so the power button stays systemd-logind's during a backup" + ); + return; + } + // Whichever half stops, both do: an inhibitor outliving the reader would be + // a power button that does nothing at all during a backup. + tokio::select! { + _ = inhibit_while_backing_up(ctx.clone()) => {} + _ = read_power_key(devices, ctx) => {} + } + tracing::warn!("no longer handling the power button; systemd-logind has it back"); } /// Holds a logind `handle-power-key` lock for exactly as long as a backup is @@ -64,9 +87,7 @@ async fn inhibit_while_backing_up(ctx: RpcContext) { let manager = match logind().await { Ok(manager) => manager, Err(e) => { - tracing::warn!( - "cannot reach systemd-logind, so the power button will keep powering the server off during a backup: {e}" - ); + tracing::warn!("cannot reach systemd-logind: {e}"); tracing::debug!("{e:?}"); return; } @@ -76,47 +97,36 @@ async fn inhibit_while_backing_up(ctx: RpcContext) { .watch(STATUS_INFO_PTR.parse::().unwrap()) .await .typed::(); - loop { - let held = async { - watch - .wait_for(|status: &ServerStatus| status.backup_progress.is_some()) - .await?; - let lock = manager - .inhibit( - "handle-power-key", - "StartOS", - "A backup is running", - "block", - ) - .await?; - watch - .wait_for(|status: &ServerStatus| status.backup_progress.is_none()) - .await?; - drop(lock); - Ok::<_, Error>(()) - } - .await; - if let Err(e) = held { - tracing::error!("stopped inhibiting the power button during backups: {e}"); - tracing::debug!("{e:?}"); - return; - } + if let Err(e) = inhibit_across_backups(&manager, &mut watch).await { + tracing::error!("stopped inhibiting the power button during backups: {e}"); + tracing::debug!("{e:?}"); } } -async fn read_power_key(ctx: RpcContext) { - let devices = match power_key_devices().await { - Ok(devices) => devices, - Err(e) => { - tracing::error!("could not enumerate input devices: {e}"); - tracing::debug!("{e:?}"); - return; - } - }; - if devices.is_empty() { - tracing::info!("no input device reports a power key"); - return; +async fn inhibit_across_backups( + manager: &Login1ManagerProxy<'_>, + watch: &mut TypedDbWatch, +) -> Result<(), Error> { + loop { + watch + .wait_for(|status: &ServerStatus| status.backup_progress.is_some()) + .await?; + let lock = manager + .inhibit( + "handle-power-key", + "StartOS", + "A backup is running", + "block", + ) + .await?; + watch + .wait_for(|status: &ServerStatus| status.backup_progress.is_none()) + .await?; + drop(lock); } +} + +async fn read_power_key(devices: Vec, ctx: RpcContext) { join_all( devices .into_iter() diff --git a/shared-libs/crates/start-core/src/shutdown.rs b/shared-libs/crates/start-core/src/shutdown.rs index 56976f10f6..5932bbe6d2 100644 --- a/shared-libs/crates/start-core/src/shutdown.rs +++ b/shared-libs/crates/start-core/src/shutdown.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use clap::Parser; use patch_db::json_ptr::JsonPointer; use serde::{Deserialize, Serialize}; @@ -131,6 +133,8 @@ pub struct ShutdownParams { /// frontend omits this and gets an immediate reply). Cleared with /// `--nowait`. The wait can't outlive the webserver teardown that follows /// container shutdown, so the connection drops once services are stopped. + /// Nothing is waited for when `--after-backup` defers the action, since + /// there is no teardown yet to wait on. #[arg(long = "nowait", action = clap::ArgAction::SetFalse, help = "help.arg.nowait")] #[serde(default)] wait: bool, @@ -143,6 +147,9 @@ pub struct ShutdownParams { } const STATUS_INFO_PTR: &str = "/public/serverInfo/statusInfo"; +/// How long to leave a failing patch-db alone before trying to take the +/// deferred action again. +const TAKE_RETRY: Duration = Duration::from_secs(30); async fn begin_shutdown(ctx: &RpcContext, restart: bool, wait: bool) { ctx.shutdown @@ -158,10 +165,9 @@ async fn begin_shutdown(ctx: &RpcContext, restart: bool, wait: bool) { } /// Records `action` as the deferred power action if a backup is underway, and -/// reports whether it did. The backup check and the write share one mutation, so -/// a backup that finishes while the request is in flight can never strand the -/// action — either it is recorded with the backup still running (and -/// [`run_deferred_power_actions`] picks it up), or the caller powers off now. +/// reports whether it did. Used where there is nothing to fall back to — the +/// power key, where logind is the one powering the server off when no backup is +/// running. pub async fn defer_until_backup_complete( ctx: &RpcContext, action: PowerAction, @@ -179,9 +185,36 @@ pub async fn defer_until_backup_complete( .result } -/// Carries out a deferred power action once the backup it was waiting on -/// finishes. Runs for the lifetime of startd, because the action can be recorded -/// at any point during a backup — from the web UI, the CLI, or the power button. +/// Either records `action` for after the backup, or commits to performing it +/// now — in one mutation, so a backup cannot start in the window between +/// deciding and acting. Returns whether it was deferred. +async fn defer_or_begin( + ctx: &RpcContext, + action: PowerAction, + after_backup: bool, +) -> Result { + ctx.db + .mutate(|db| { + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + if after_backup && status.as_backup_progress().transpose_ref().is_some() { + status.as_deferred_power_action_mut().ser(&Some(action))?; + return Ok(true); + } + status.as_deferred_power_action_mut().ser(&None)?; + match action { + PowerAction::Restart => status.as_restarting_mut().ser(&true)?, + PowerAction::Shutdown => status.as_shutting_down_mut().ser(&true)?, + } + Ok(false) + }) + .await + .result +} + +/// Carries out each deferred power action once the backup it was waiting on +/// finishes. Runs for the lifetime of startd: an action can be recorded at any +/// point during any backup — from the web UI, the CLI, or the power button — so +/// this must survive one having failed. pub async fn run_deferred_power_actions(ctx: RpcContext) { let mut watch = ctx .db @@ -195,13 +228,14 @@ pub async fn run_deferred_power_actions(ctx: RpcContext) { }) .await { + // The db is gone, so there is nothing left to retry against. tracing::error!("stopped watching for deferred power actions: {e}"); tracing::debug!("{e:?}"); return; } // Taking the action clears it in the same mutation, so a cancellation - // that lands first wins and this run does nothing. - let action = ctx + // that lands first wins and this pass does nothing. + let taken = ctx .db .mutate(|db| { let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); @@ -210,10 +244,19 @@ pub async fn run_deferred_power_actions(ctx: RpcContext) { Ok(action) }) .await - .result - .log_err() - .flatten(); - let res = match action { + .result; + let action = match taken { + Ok(action) => action, + Err(e) => { + // A failed mutation leaves the db untouched, so retrying + // immediately would spin against whatever is failing. + tracing::error!("could not take the deferred power action: {e}"); + tracing::debug!("{e:?}"); + tokio::time::sleep(TAKE_RETRY).await; + continue; + } + }; + let performed = match action { Some(PowerAction::Restart) => { tracing::info!("backup finished; carrying out the deferred restart"); restart(ctx.clone(), ShutdownParams::default()).await @@ -224,11 +267,10 @@ pub async fn run_deferred_power_actions(ctx: RpcContext) { } None => continue, }; - if let Err(e) = res { + if let Err(e) = performed { tracing::error!("deferred power action failed: {e}"); tracing::debug!("{e:?}"); } - return; } } @@ -236,17 +278,9 @@ pub async fn shutdown( ctx: RpcContext, ShutdownParams { wait, after_backup }: ShutdownParams, ) -> Result<(), Error> { - if after_backup && defer_until_backup_complete(&ctx, PowerAction::Shutdown).await? { + if defer_or_begin(&ctx, PowerAction::Shutdown, after_backup).await? { return Ok(()); } - ctx.db - .mutate(|db| { - let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); - status.as_deferred_power_action_mut().ser(&None)?; - status.as_shutting_down_mut().ser(&true) - }) - .await - .result?; begin_shutdown(&ctx, false, wait).await; Ok(()) } @@ -255,17 +289,9 @@ pub async fn restart( ctx: RpcContext, ShutdownParams { wait, after_backup }: ShutdownParams, ) -> Result<(), Error> { - if after_backup && defer_until_backup_complete(&ctx, PowerAction::Restart).await? { + if defer_or_begin(&ctx, PowerAction::Restart, after_backup).await? { return Ok(()); } - ctx.db - .mutate(|db| { - let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); - status.as_deferred_power_action_mut().ser(&None)?; - status.as_restarting_mut().ser(&true) - }) - .await - .result?; begin_shutdown(&ctx, true, wait).await; Ok(()) } diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts b/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts index 62367dbd3f..259fc202fd 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts @@ -6,6 +6,8 @@ export type ShutdownParams = { * frontend omits this and gets an immediate reply). Cleared with * `--nowait`. The wait can't outlive the webserver teardown that follows * container shutdown, so the connection drops once services are stopped. + * Nothing is waited for when `--after-backup` defers the action, since + * there is no teardown yet to wait on. */ wait: boolean /** From 50224d09adf319e49fb2dd24f81176bca50840e3 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:55:52 +0000 Subject: [PATCH 3/7] fix(start-os): the deferred-power bar must outrank the restart-reason bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- projects/start-os/ARCHITECTURE.md | 4 +- projects/start-os/docs/src/cli-reference.md | 2 + .../components/header/menu.component.ts | 4 +- .../src/app/routes/portal/portal.component.ts | 59 ++++++++++--------- .../routes/general/general.component.ts | 2 +- .../services/api/embassy-mock-api.service.ts | 1 - .../web/ui/src/app/services/power.service.ts | 33 +++++++---- .../crates/start-core/src/bins/startd.rs | 31 +++++----- .../crates/start-core/src/power_key.rs | 12 ++-- shared-libs/crates/start-core/src/shutdown.rs | 16 +++-- 10 files changed, 96 insertions(+), 68 deletions(-) diff --git a/projects/start-os/ARCHITECTURE.md b/projects/start-os/ARCHITECTURE.md index e60ca08914..bc9b112ed6 100644 --- a/projects/start-os/ARCHITECTURE.md +++ b/projects/start-os/ARCHITECTURE.md @@ -92,7 +92,9 @@ erasure-coded FUSE filesystem used for StartOS backups. It builds to the for the backup rather than one that interrupts it. The inhibitor 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 - graceful teardown. + graceful teardown. Best-effort in one direction only: if logind is + unreachable, or no udev `power-switch` device can be read, `startd` takes no + inhibitor and the key keeps working exactly as it does today. ## OS image packaging diff --git a/projects/start-os/docs/src/cli-reference.md b/projects/start-os/docs/src/cli-reference.md index 3c30114884..a1bebcc31b 100644 --- a/projects/start-os/docs/src/cli-reference.md +++ b/projects/start-os/docs/src/cli-reference.md @@ -57,12 +57,14 @@ Restart, shut down, update, and configure the server. Restart the server. Without `--after-backup` this interrupts a running backup, unlike the web UI, which offers to wait. - `--after-backup` — Wait for a running backup to finish first +- `--nowait` — Return immediately instead of waiting for graceful shutdown ### `start-cli server shutdown` Shut down the server. Without `--after-backup` this interrupts a running backup, unlike the web UI, which offers to wait. - `--after-backup` — Wait for a running backup to finish first +- `--nowait` — Return immediately instead of waiting for graceful shutdown ### `start-cli server cancel-deferred-power` diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts index 8f6bbdee83..ab74981378 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts @@ -153,7 +153,7 @@ export class HeaderMenuComponent { async promptPower(action: T.PowerAction) { // During a backup the choice on offer is a different one, and asking it is // confirmation enough. - if (this.power.backingUp()) return this.power.power(action) + if (this.power.backingUp()) return this.power.power(action).subscribe() this.dialog .openConfirm( @@ -180,7 +180,7 @@ export class HeaderMenuComponent { }, ) .pipe(filter(Boolean)) - .subscribe(() => this.power.power(action)) + .subscribe(() => this.power.power(action).subscribe()) } logout() { diff --git a/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts b/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts index ea894dd3e0..ee9ec1f489 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts @@ -30,7 +30,32 @@ import { HeaderComponent } from './components/header/header.component' - @if (update(); as update) { + @if (deferredPower(); as action) { + + + + @if (action === 'restart') { + {{ + 'A backup is running. Your server will restart when it finishes.' + | i18n + }} + } @else { + {{ + 'A backup is running. Your server will shut down when it finishes.' + | i18n + }} + } + + + + } @else if (update(); as update) { @let leaf = update.overall | leafProgress; @@ -78,31 +103,6 @@ import { HeaderComponent } from './components/header/header.component' {{ 'Restart' | i18n }} - } @else if (deferredPower(); as action) { - - - - @if (action === 'restart') { - {{ - 'A backup is running. Your server will restart when it finishes.' - | i18n - }} - } @else { - {{ - 'A backup is running. Your server will shut down when it finishes.' - | i18n - }} - } - - - } `, styles: ` @@ -210,8 +210,11 @@ export class PortalComponent { } restart() { - this.bar.set(false) - this.power.power('restart') + // Only stop offering the restart once one is actually under way — a + // deferred or dismissed one leaves the reason for this bar in place. + this.power.power('restart').subscribe(deferred => { + if (!deferred) this.bar.set(false) + }) } cancelDeferredPower() { diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts index 5f0ed39cec..e163223eb2 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts @@ -527,6 +527,6 @@ export default class SystemGeneralComponent { } private async restart() { - this.power.power('restart') + this.power.power('restart').subscribe() } } diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts index ff73fa9540..3e05ce4c91 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts @@ -1020,7 +1020,6 @@ export class MockApiService extends ApiService { this.backingUp = false if (this.deferredPowerAction) { const action = this.deferredPowerAction - await this.deferPower(null) await this[action === 'restart' ? 'restartServer' : 'shutdownServer']( {}, ) diff --git a/projects/start-os/web/ui/src/app/services/power.service.ts b/projects/start-os/web/ui/src/app/services/power.service.ts index fe9f3db05b..8849ac8536 100644 --- a/projects/start-os/web/ui/src/app/services/power.service.ts +++ b/projects/start-os/web/ui/src/app/services/power.service.ts @@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' import { DialogService, TaskService } from '@start9labs/shared' import { T } from '@start9labs/start-core' +import { defer, filter, map, Observable, switchMap } from 'rxjs' import { POWER } from 'src/app/routes/portal/components/header/power.component' import { ApiService } from 'src/app/services/api/embassy-api.service' import { OSService } from 'src/app/services/os.service' @@ -18,31 +19,41 @@ export class PowerService { /** * Every in-app route to a restart or shutdown goes through here, so that none * of them can interrupt a backup: during one the user is offered the choice - * of waiting for it, and the server keeps whichever choice is made. + * of waiting for it, and the server keeps whichever choice is made. Emits + * once the server has been asked, `true` if the action was deferred; a + * dismissed prompt asks for nothing and so emits nothing. */ - power(action: T.PowerAction) { + power(action: T.PowerAction): Observable { if (!this.backingUp()) return this.run(action, false) - this.dialog + return this.dialog .openComponent(POWER, { label: action === 'restart' ? 'Restart' : 'Warning', size: 's', data: action, }) - .subscribe(now => this.run(action, !now)) + .pipe(switchMap(now => this.run(action, !now))) } cancel() { this.tasks.run(async () => await this.api.cancelDeferredPower({})) } - private run(action: T.PowerAction, afterBackup: boolean) { - this.tasks.run( - async () => - action === 'restart' - ? await this.api.restartServer({ afterBackup }) - : await this.api.shutdownServer({ afterBackup }), - afterBackup ? 'Wait for backup to complete' : `Beginning ${action}`, + private run( + action: T.PowerAction, + afterBackup: boolean, + ): Observable { + return defer(() => + this.tasks.run( + async () => + action === 'restart' + ? await this.api.restartServer({ afterBackup }) + : await this.api.shutdownServer({ afterBackup }), + afterBackup ? 'Wait for backup to complete' : `Beginning ${action}`, + ), + ).pipe( + filter(Boolean), + map(() => afterBackup), ) } } diff --git a/shared-libs/crates/start-core/src/bins/startd.rs b/shared-libs/crates/start-core/src/bins/startd.rs index ff731a0b1d..aeabf4f0ae 100644 --- a/shared-libs/crates/start-core/src/bins/startd.rs +++ b/shared-libs/crates/start-core/src/bins/startd.rs @@ -64,28 +64,29 @@ async fn inner_main( ) .await?; - server.serve_ui_for(ctx.clone()); - handle.complete(); - - ctx - }; - - let (rpc_ctx, shutdown) = async { - // A backup and a deferred power action both die with the process that - // was running them, and only `init` — which a startd restart within a - // boot skips — would otherwise clear them. A stale backup in particular - // would leave the power button inhibited with nothing left to release - // it. - rpc_ctx - .db + // A backup, a deferred power action and the shutting-down flags all die + // with the process that set them, and only `init` — which this branch + // skips — would otherwise clear them. A stale backup in particular would + // leave the power key inhibited with nothing left to release it. Before + // the RPC surface goes live, so nothing races the reset. + ctx.db .mutate(|db| { let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); status.as_backup_progress_mut().ser(&None)?; - status.as_deferred_power_action_mut().ser(&None) + status.as_deferred_power_action_mut().ser(&None)?; + status.as_shutting_down_mut().ser(&false)?; + status.as_restarting_mut().ser(&false) }) .await .result?; + server.serve_ui_for(ctx.clone()); + handle.complete(); + + ctx + }; + + let (rpc_ctx, shutdown) = async { crate::hostname::sync_hostname(&rpc_ctx.account.peek(|a| a.hostname.hostname.clone())) .await?; diff --git a/shared-libs/crates/start-core/src/power_key.rs b/shared-libs/crates/start-core/src/power_key.rs index 3d4eea17c4..3c715ac9f3 100644 --- a/shared-libs/crates/start-core/src/power_key.rs +++ b/shared-libs/crates/start-core/src/power_key.rs @@ -19,7 +19,8 @@ use std::io::Read; use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; -use futures::future::join_all; +use futures::FutureExt; +use futures::future::select_all; use nix::sys::stat; use patch_db::TypedDbWatch; use patch_db::json_ptr::JsonPointer; @@ -126,11 +127,14 @@ async fn inhibit_across_backups( } } +/// Returns as soon as any one device stops being readable: there is no telling +/// which of them the firmware reports presses on, so a partial failure has to +/// count as a failure. async fn read_power_key(devices: Vec, ctx: RpcContext) { - join_all( + select_all( devices .into_iter() - .map(|path| read_device(path, ctx.clone())), + .map(|path| read_device(path, ctx.clone()).boxed()), ) .await; } @@ -187,7 +191,7 @@ async fn on_power_key(ctx: &RpcContext) { // contended sound device cannot stall the reader. tokio::spawn(async { BEP.play().await.log_err() }); } - // logind is not inhibited and is already powering the server off. + // No backup left to protect. Ok(false) => (), Err(e) => { tracing::error!("could not defer the shutdown for the running backup: {e}"); diff --git a/shared-libs/crates/start-core/src/shutdown.rs b/shared-libs/crates/start-core/src/shutdown.rs index 5932bbe6d2..6e10c4c875 100644 --- a/shared-libs/crates/start-core/src/shutdown.rs +++ b/shared-libs/crates/start-core/src/shutdown.rs @@ -165,9 +165,9 @@ async fn begin_shutdown(ctx: &RpcContext, restart: bool, wait: bool) { } /// Records `action` as the deferred power action if a backup is underway, and -/// reports whether it did. Used where there is nothing to fall back to — the -/// power key, where logind is the one powering the server off when no backup is -/// running. +/// reports whether it did. Unlike [`defer_or_begin`] it never performs the +/// action, which is what the power key needs: with no backup to wait for, the +/// press is logind's to act on. pub async fn defer_until_backup_complete( ctx: &RpcContext, action: PowerAction, @@ -256,14 +256,20 @@ pub async fn run_deferred_power_actions(ctx: RpcContext) { continue; } }; + // Still `after_backup`, so a backup that started since the take is + // waited for in turn rather than interrupted. + let params = ShutdownParams { + wait: false, + after_backup: true, + }; let performed = match action { Some(PowerAction::Restart) => { tracing::info!("backup finished; carrying out the deferred restart"); - restart(ctx.clone(), ShutdownParams::default()).await + restart(ctx.clone(), params).await } Some(PowerAction::Shutdown) => { tracing::info!("backup finished; carrying out the deferred shutdown"); - shutdown(ctx.clone(), ShutdownParams::default()).await + shutdown(ctx.clone(), params).await } None => continue, }; From 7b74acf2b243de617eef4ff2f13872c0d37ef31c Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:30:03 +0000 Subject: [PATCH 4/7] fix(start-os): re-arm the power-key guard per backup, and match logind's key codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- projects/start-os/ARCHITECTURE.md | 14 +-- projects/start-os/docs/src/surge-and-ups.md | 2 +- .../components/header/power.component.ts | 28 ++--- .../src/app/routes/portal/portal.component.ts | 8 +- .../routes/general/general.component.ts | 2 +- .../web/ui/src/app/services/power.service.ts | 5 +- .../crates/start-core/src/bins/startd.rs | 2 - .../crates/start-core/src/power_key.rs | 112 +++++++++--------- shared-libs/crates/start-core/src/shutdown.rs | 8 +- .../shared/src/i18n/dictionaries/de.ts | 2 +- .../shared/src/i18n/dictionaries/en.ts | 2 +- .../shared/src/i18n/dictionaries/es.ts | 2 +- .../shared/src/i18n/dictionaries/fr.ts | 2 +- .../shared/src/i18n/dictionaries/pl.ts | 2 +- 14 files changed, 94 insertions(+), 97 deletions(-) diff --git a/projects/start-os/ARCHITECTURE.md b/projects/start-os/ARCHITECTURE.md index bc9b112ed6..93a71bb8e7 100644 --- a/projects/start-os/ARCHITECTURE.md +++ b/projects/start-os/ARCHITECTURE.md @@ -87,14 +87,12 @@ erasure-coded FUSE filesystem used for StartOS backups. It builds to the - `startos-restart.service` — restart handling. - The physical power key is systemd-logind's (`HandlePowerKey=poweroff`), except while a backup is running: `startd` then holds a logind - `handle-power-key` block inhibitor and reads the key itself - (`start-core/src/power_key.rs`), turning a press into a shutdown that waits - for the backup rather than one that interrupts it. The inhibitor 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 - graceful teardown. Best-effort in one direction only: if logind is - unreachable, or no udev `power-switch` device can be read, `startd` takes no - inhibitor and the key keeps working exactly as it does today. + `handle-power-key` block inhibitor and reads the key itself, turning a press + into a shutdown that waits for the backup rather than one that interrupts it. + It is best-effort — when the inhibitor cannot be taken or no `power-switch` + device can be read, the key stays logind's — so treat it as one defence and + not a guarantee. See `start-core/src/power_key.rs` for why it inhibits + `handle-power-key` rather than `shutdown`. ## OS image packaging diff --git a/projects/start-os/docs/src/surge-and-ups.md b/projects/start-os/docs/src/surge-and-ups.md index 9587e0f98b..296675747f 100644 --- a/projects/start-os/docs/src/surge-and-ups.md +++ b/projects/start-os/docs/src/surge-and-ups.md @@ -49,4 +49,4 @@ StartOS does not currently include built-in support for UPS monitoring (USB or n If your area has frequent or long outages, size your UPS to give yourself time to shut down manually from the StartOS UI before the battery runs out. > [!NOTE] -> If a backup is running when you do, StartOS offers to wait for the backup to finish and takes that option if you do not choose within 30 seconds — which on battery is rarely what you want. Choose `Shut down now` instead. +> If a backup is running when you do, StartOS offers to wait for the backup to finish and takes that option if you do not choose within 30 seconds — which on battery is rarely what you want. Choose `Shut down now` instead. The server's physical power button always waits for the backup, so on battery use the web UI rather than the button. diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts index 12d5b62385..60c78e3df1 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts @@ -6,28 +6,28 @@ import { TuiButton, TuiDialogContext } from '@taiga-ui/core' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { take, timer } from 'rxjs' -// Long enough to read the warning, short enough to not strand someone who -// walked away mid-decision. const COUNTDOWN = 30 @Component({ template: `

{{ - 'A backup is currently running. Powering down now can corrupt the backup of the service being written.' + 'A backup is currently running. Interrupting it now can corrupt the backup of the service being written.' | i18n }}

+ @if (action === 'shutdown') { +

+ {{ + 'Are you sure you want to power down your server? This can take several minutes, and your server will not come back online automatically. To power on again, You will need to physically unplug your server and plug it back in.' + | i18n + }} +

+ }
- @if (action === 'restart') { - - } @else { - - } + @@ -40,12 +40,10 @@ export class PowerComponent { injectContext>() protected readonly action = this.context.data - protected readonly seconds = signal(COUNTDOWN) constructor() { - // One timer drives both the label and the default, so the choice is made - // exactly when the label says it will be. + // One timer, so the choice is made exactly when the label says it will be. timer(0, 1000) .pipe(take(COUNTDOWN + 1), takeUntilDestroyed()) .subscribe(tick => { diff --git a/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts b/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts index ee9ec1f489..e039e0ebd9 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts @@ -50,7 +50,7 @@ import { HeaderComponent } from './components/header/header.component' tuiButton size="s" appearance="secondary" - (click)="cancelDeferredPower()" + (click)="power.cancel()" > {{ 'Cancel' | i18n }} @@ -192,7 +192,7 @@ import { HeaderComponent } from './components/header/header.component' }) export class PortalComponent { private readonly patch = inject>(PatchDB) - private readonly power = inject(PowerService) + protected readonly power = inject(PowerService) readonly mobile = inject(WA_IS_MOBILE) readonly plugins = inject(PluginsService) @@ -216,8 +216,4 @@ export class PortalComponent { if (!deferred) this.bar.set(false) }) } - - cancelDeferredPower() { - this.power.cancel() - } } diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts index e163223eb2..75f39de4e8 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts @@ -43,8 +43,8 @@ import { ABOUT } from 'src/app/routes/portal/components/header/about.component' import { ApiService } from 'src/app/services/api/embassy-api.service' import { ConfigService } from 'src/app/services/config.service' import { OSService } from 'src/app/services/os.service' -import { PowerService } from 'src/app/services/power.service' import { DataModel } from 'src/app/services/patch-db/data-model' +import { PowerService } from 'src/app/services/power.service' import { TitleDirective } from 'src/app/services/title.service' import { KeyboardSelectComponent } from './keyboard-select.component' import { ServerNameDialog } from './server-name.dialog' diff --git a/projects/start-os/web/ui/src/app/services/power.service.ts b/projects/start-os/web/ui/src/app/services/power.service.ts index 8849ac8536..3eb49c87c3 100644 --- a/projects/start-os/web/ui/src/app/services/power.service.ts +++ b/projects/start-os/web/ui/src/app/services/power.service.ts @@ -20,8 +20,9 @@ export class PowerService { * Every in-app route to a restart or shutdown goes through here, so that none * of them can interrupt a backup: during one the user is offered the choice * of waiting for it, and the server keeps whichever choice is made. Emits - * once the server has been asked, `true` if the action was deferred; a - * dismissed prompt asks for nothing and so emits nothing. + * once the server has been asked, `true` if the user chose to wait — not what + * the server then did, which the caller cannot see. A dismissed prompt asks + * for nothing and so emits nothing. */ power(action: T.PowerAction): Observable { if (!this.backingUp()) return this.run(action, false) diff --git a/shared-libs/crates/start-core/src/bins/startd.rs b/shared-libs/crates/start-core/src/bins/startd.rs index aeabf4f0ae..9d2412cc52 100644 --- a/shared-libs/crates/start-core/src/bins/startd.rs +++ b/shared-libs/crates/start-core/src/bins/startd.rs @@ -120,8 +120,6 @@ async fn inner_main( .expect("send shutdown signal"); }); - // Both run until this block returns with the shutdown message, at which - // point their handles drop and abort them. let deferred_power_ctx = rpc_ctx.clone(); let _deferred_power = NonDetachingJoinHandle::from(tokio::spawn( crate::shutdown::run_deferred_power_actions(deferred_power_ctx), diff --git a/shared-libs/crates/start-core/src/power_key.rs b/shared-libs/crates/start-core/src/power_key.rs index 3c715ac9f3..9b07fac3f7 100644 --- a/shared-libs/crates/start-core/src/power_key.rs +++ b/shared-libs/crates/start-core/src/power_key.rs @@ -7,13 +7,13 @@ //! press still means something: it records a deferred shutdown, which the web //! UI surfaces and which StartOS carries out once the backup finishes. //! -//! The inhibitor names `handle-power-key` and not `shutdown` on purpose — -//! blocking `shutdown` would also block the power-off StartOS itself asks -//! systemd for at the end of a graceful teardown. -//! -//! Failure gives the button back to logind rather than taking it away: the -//! inhibitor is only ever held while the key is also being read, so a server -//! whose key StartOS cannot see keeps powering off exactly as it does today. +//! It names `handle-power-key` and not `shutdown` on purpose — blocking +//! `shutdown` would also block the power-off StartOS itself asks systemd for at +//! the end of a graceful teardown. Both the inhibitor and the readers live for +//! one backup and are set up again for the next, so a device that came or went +//! in between is picked up and one that failed does not stand the feature down +//! for good. Failure gives the button back to logind rather than taking it +//! away. use std::io::Read; use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; @@ -31,11 +31,13 @@ use zbus::zvariant::OwnedFd; use crate::context::RpcContext; use crate::db::model::public::{PowerAction, ServerStatus}; use crate::prelude::*; -use crate::shutdown::defer_until_backup_complete; +use crate::shutdown::{STATUS_INFO_PTR, defer_until_backup_complete}; use crate::sound::BEP; const EV_KEY: u16 = 0x01; -const KEY_POWER: usize = 116; +/// Both codes logind acts on, handled by one arm of its own `case` — matching +/// that is what keeps a press meaning here what it would have meant to logind. +const KEY_POWER: [u16; 2] = [116, 356]; const KEY_PRESSED: i32 = 1; const EVENT_SIZE: usize = std::mem::size_of::(); @@ -44,7 +46,6 @@ const EVENT_SIZE: usize = std::mem::size_of::(); /// always the last 8 bytes. const EVENT_TAIL: usize = EVENT_SIZE - 8; -const STATUS_INFO_PTR: &str = "/public/serverInfo/statusInfo"; const POWER_SWITCH_TAG_DIR: &str = "/run/udev/tags/power-switch"; #[proxy( @@ -58,33 +59,6 @@ trait Login1Manager { } pub async fn watch_power_key(ctx: RpcContext) { - let devices = match power_key_devices().await { - Ok(devices) => devices, - Err(e) => { - tracing::error!("could not enumerate input devices: {e}"); - tracing::debug!("{e:?}"); - return; - } - }; - if devices.is_empty() { - tracing::info!( - "no power-switch input device, so the power button stays systemd-logind's during a backup" - ); - return; - } - // Whichever half stops, both do: an inhibitor outliving the reader would be - // a power button that does nothing at all during a backup. - tokio::select! { - _ = inhibit_while_backing_up(ctx.clone()) => {} - _ = read_power_key(devices, ctx) => {} - } - tracing::warn!("no longer handling the power button; systemd-logind has it back"); -} - -/// Holds a logind `handle-power-key` lock for exactly as long as a backup is -/// running, so that a press during one reaches [`read_power_key`] instead of -/// powering the server off. -async fn inhibit_while_backing_up(ctx: RpcContext) { let manager = match logind().await { Ok(manager) => manager, Err(e) => { @@ -98,13 +72,14 @@ async fn inhibit_while_backing_up(ctx: RpcContext) { .watch(STATUS_INFO_PTR.parse::().unwrap()) .await .typed::(); - if let Err(e) = inhibit_across_backups(&manager, &mut watch).await { - tracing::error!("stopped inhibiting the power button during backups: {e}"); + if let Err(e) = guard_backups(&ctx, &manager, &mut watch).await { + tracing::error!("stopped guarding backups from the power button: {e}"); tracing::debug!("{e:?}"); } } -async fn inhibit_across_backups( +async fn guard_backups( + ctx: &RpcContext, manager: &Login1ManagerProxy<'_>, watch: &mut TypedDbWatch, ) -> Result<(), Error> { @@ -112,25 +87,48 @@ async fn inhibit_across_backups( watch .wait_for(|status: &ServerStatus| status.backup_progress.is_some()) .await?; - let lock = manager - .inhibit( - "handle-power-key", - "StartOS", - "A backup is running", - "block", - ) - .await?; + // Enumerated per backup rather than once: udev tags every key-capable + // device, so the set changes whenever a keyboard is plugged in. + match power_key_devices().await { + Ok(devices) if !devices.is_empty() => { + let lock = manager + .inhibit( + "handle-power-key", + "StartOS", + "A backup is running", + "block", + ) + .await?; + { + let backup_over = + watch.wait_for(|status: &ServerStatus| status.backup_progress.is_none()); + tokio::pin!(backup_over); + tokio::select! { + over = &mut backup_over => { over?; } + // Never inhibit a key nobody is reading. + _ = read_power_key(devices, ctx) => tracing::warn!( + "stopped reading the power key for this backup; systemd-logind has it back" + ), + } + } + drop(lock); + } + Ok(_) => tracing::info!("no power-switch input device to read the power key from"), + Err(e) => { + tracing::error!("could not enumerate input devices: {e}"); + tracing::debug!("{e:?}"); + } + } watch .wait_for(|status: &ServerStatus| status.backup_progress.is_none()) .await?; - drop(lock); } } /// Returns as soon as any one device stops being readable: there is no telling /// which of them the firmware reports presses on, so a partial failure has to /// count as a failure. -async fn read_power_key(devices: Vec, ctx: RpcContext) { +async fn read_power_key(devices: Vec, ctx: &RpcContext) { select_all( devices .into_iter() @@ -186,12 +184,10 @@ async fn on_power_key(ctx: &RpcContext) { match defer_until_backup_complete(ctx, PowerAction::Shutdown).await { Ok(true) => { tracing::info!("power key pressed during a backup; shutting down once it finishes"); - // The only feedback available to whoever is standing at the server, - // whose press otherwise appears to have done nothing. Spawned so a - // contended sound device cannot stall the reader. + // The only feedback whoever pressed it has. Spawned so a contended + // sound device cannot stall the reader. tokio::spawn(async { BEP.play().await.log_err() }); } - // No backup left to protect. Ok(false) => (), Err(e) => { tracing::error!("could not defer the shutdown for the running backup: {e}"); @@ -230,7 +226,7 @@ fn device_id(rdev: u64) -> String { fn is_power_key_press(event: &[u8]) -> bool { let tail = &event[EVENT_TAIL..]; u16::from_ne_bytes([tail[0], tail[1]]) == EV_KEY - && u16::from_ne_bytes([tail[2], tail[3]]) as usize == KEY_POWER + && KEY_POWER.contains(&u16::from_ne_bytes([tail[2], tail[3]])) && i32::from_ne_bytes([tail[4], tail[5], tail[6], tail[7]]) == KEY_PRESSED } @@ -250,10 +246,14 @@ mod test { fn recognizes_a_power_key_press() { let mut event = [0u8; EVENT_SIZE]; event[EVENT_TAIL..EVENT_TAIL + 2].copy_from_slice(&EV_KEY.to_ne_bytes()); - event[EVENT_TAIL + 2..EVENT_TAIL + 4].copy_from_slice(&(KEY_POWER as u16).to_ne_bytes()); + event[EVENT_TAIL + 2..EVENT_TAIL + 4].copy_from_slice(&KEY_POWER[0].to_ne_bytes()); event[EVENT_TAIL + 4..].copy_from_slice(&KEY_PRESSED.to_ne_bytes()); assert!(is_power_key_press(&event)); + // KEY_POWER2, which logind acts on identically. + event[EVENT_TAIL + 2..EVENT_TAIL + 4].copy_from_slice(&KEY_POWER[1].to_ne_bytes()); + assert!(is_power_key_press(&event)); + // A release, which must not power anything off. event[EVENT_TAIL + 4..].copy_from_slice(&0i32.to_ne_bytes()); assert!(!is_power_key_press(&event)); diff --git a/shared-libs/crates/start-core/src/shutdown.rs b/shared-libs/crates/start-core/src/shutdown.rs index 6e10c4c875..ccd46e11dc 100644 --- a/shared-libs/crates/start-core/src/shutdown.rs +++ b/shared-libs/crates/start-core/src/shutdown.rs @@ -146,7 +146,7 @@ pub struct ShutdownParams { after_backup: bool, } -const STATUS_INFO_PTR: &str = "/public/serverInfo/statusInfo"; +pub(crate) const STATUS_INFO_PTR: &str = "/public/serverInfo/statusInfo"; /// How long to leave a failing patch-db alone before trying to take the /// deferred action again. const TAKE_RETRY: Duration = Duration::from_secs(30); @@ -276,6 +276,12 @@ pub async fn run_deferred_power_actions(ctx: RpcContext) { if let Err(e) = performed { tracing::error!("deferred power action failed: {e}"); tracing::debug!("{e:?}"); + // Put it back rather than losing it, and give whatever failed room + // to recover before trying again. + if let Some(action) = action { + defer_or_begin(&ctx, action, true).await.log_err(); + } + tokio::time::sleep(TAKE_RETRY).await; } } } diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts index a7df4426d3..045aee4063 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts @@ -805,7 +805,7 @@ export default { 912: 'Die StartOS-Daten auf dem ausgewählten Datenlaufwerk befinden sich auf einer Partition neben einer älteren OS-Installation und können auf diesem Gerät nicht beibehalten werden. Um das Laufwerk zu löschen und neu zu beginnen, wählen Sie "Überschreiben".', 913: 'Die StartOS-Daten auf dem ausgewählten Datenlaufwerk erstrecken sich über das gesamte Laufwerk, sodass das OS nicht auf demselben Laufwerk installiert werden kann, ohne sie zu löschen. Um Ihre Daten zu behalten, wählen Sie ein anderes OS-Laufwerk. Um sie zu löschen, wählen Sie "Überschreiben".', 914: 'Anmeldung erfolgreich, aber der Server hat den neuen Geräteschlüssel abgelehnt. Versuchen Sie es erneut.', - 915: 'Derzeit läuft eine Sicherung. Ein Ausschalten kann jetzt die Sicherung des gerade geschriebenen Dienstes beschädigen.', + 915: 'Derzeit läuft eine Sicherung. Eine Unterbrechung kann jetzt die Sicherung des gerade geschriebenen Dienstes beschädigen.', 916: 'Auf Abschluss der Sicherung warten', 917: 'Jetzt herunterfahren', 918: 'Eine Sicherung läuft. Ihr Server wird nach deren Abschluss neu gestartet.', diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts index 530779c709..fd8adfb721 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts @@ -806,7 +806,7 @@ export const ENGLISH: Record = { 'The StartOS data on the selected data drive is stored on a partition alongside an older OS installation, and cannot be preserved on this device. To erase the drive and start fresh, choose "Overwrite".': 912, 'The StartOS data on the selected data drive spans the entire drive, so the OS cannot be installed to the same drive without erasing it. To preserve your data, select a different OS drive. To erase it, choose "Overwrite".': 913, 'Login succeeded, but the server rejected the new device key. Try again.': 914, - 'A backup is currently running. Powering down now can corrupt the backup of the service being written.': 915, + 'A backup is currently running. Interrupting it now can corrupt the backup of the service being written.': 915, 'Wait for backup to complete': 916, 'Shut down now': 917, 'A backup is running. Your server will restart when it finishes.': 918, diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts index 69fad94770..5dbcb2fbe2 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts @@ -805,7 +805,7 @@ export default { 912: 'Los datos de StartOS en la unidad de datos seleccionada están en una partición junto a una instalación de SO anterior, y no pueden conservarse en este dispositivo. Para borrar la unidad y empezar de nuevo, elija "Sobrescribir".', 913: 'Los datos de StartOS en la unidad de datos seleccionada ocupan toda la unidad, por lo que el SO no puede instalarse en la misma unidad sin borrarlos. Para conservar sus datos, seleccione otra unidad para el SO. Para borrarlos, elija "Sobrescribir".', 914: 'Inicio de sesión correcto, pero el servidor rechazó la nueva clave del dispositivo. Inténtelo de nuevo.', - 915: 'Hay una copia de seguridad en curso. Apagar ahora puede dañar la copia de seguridad del servicio que se está escribiendo.', + 915: 'Hay una copia de seguridad en curso. Interrumpirla ahora puede dañar la copia de seguridad del servicio que se está escribiendo.', 916: 'Esperar a que termine la copia de seguridad', 917: 'Apagar ahora', 918: 'Hay una copia de seguridad en curso. Su servidor se reiniciará cuando termine.', diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts index b104e750c7..2bd6a20364 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts @@ -805,7 +805,7 @@ export default { 912: 'Les données StartOS du disque de données sélectionné se trouvent sur une partition aux côtés d’une ancienne installation de l’OS et ne peuvent pas être conservées sur cet appareil. Pour effacer le disque et repartir à zéro, choisissez « Écraser ».', 913: 'Les données StartOS du disque de données sélectionné occupent l’intégralité du disque : l’OS ne peut donc pas être installé sur le même disque sans les effacer. Pour conserver vos données, sélectionnez un autre disque pour l’OS. Pour les effacer, choisissez « Écraser ».', 914: 'Connexion réussie, mais le serveur a rejeté la nouvelle clé de l’appareil. Réessayez.', - 915: 'Une sauvegarde est en cours. Éteindre maintenant peut corrompre la sauvegarde du service en cours d’écriture.', + 915: 'Une sauvegarde est en cours. L’interrompre maintenant peut corrompre la sauvegarde du service en cours d’écriture.', 916: 'Attendre la fin de la sauvegarde', 917: 'Éteindre maintenant', 918: 'Une sauvegarde est en cours. Votre serveur redémarrera une fois celle-ci terminée.', diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts index d6205a6a59..f24dc46d50 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts @@ -805,7 +805,7 @@ export default { 912: 'Dane StartOS na wybranym dysku danych znajdują się na partycji obok starszej instalacji systemu i nie można ich zachować na tym urządzeniu. Aby wymazać dysk i zacząć od nowa, wybierz „Nadpisz”.', 913: 'Dane StartOS na wybranym dysku danych zajmują cały dysk, więc systemu nie można zainstalować na tym samym dysku bez ich wymazania. Aby zachować dane, wybierz inny dysk systemowy. Aby je wymazać, wybierz „Nadpisz”.', 914: 'Logowanie powiodło się, ale serwer odrzucił nowy klucz urządzenia. Spróbuj ponownie.', - 915: 'Trwa tworzenie kopii zapasowej. Wyłączenie teraz może uszkodzić kopię zapasową aktualnie zapisywanej usługi.', + 915: 'Trwa tworzenie kopii zapasowej. Przerwanie jej teraz może uszkodzić kopię zapasową aktualnie zapisywanej usługi.', 916: 'Poczekaj na zakończenie kopii zapasowej', 917: 'Wyłącz teraz', 918: 'Trwa tworzenie kopii zapasowej. Serwer zostanie ponownie uruchomiony po jej zakończeniu.', From b8dd11d06fb0a87b451030a481323d0b195f45cc Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:00:11 +0000 Subject: [PATCH 5/7] fix(start-os): keep a failed backup guard from standing the feature down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/header/power.component.ts | 3 + .../crates/start-core/src/power_key.rs | 69 ++++++++++--------- shared-libs/crates/start-core/src/shutdown.rs | 16 ++++- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts index 60c78e3df1..d7e022d860 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts @@ -33,6 +33,9 @@ const COUNTDOWN = 30
`, + // Both labels are sentences, and neither shrinks: 25rem cannot hold them on + // one row in any locale. + styles: 'footer { flex-wrap: wrap }', imports: [TuiButton, i18nPipe], }) export class PowerComponent { diff --git a/shared-libs/crates/start-core/src/power_key.rs b/shared-libs/crates/start-core/src/power_key.rs index 9b07fac3f7..7161095b8b 100644 --- a/shared-libs/crates/start-core/src/power_key.rs +++ b/shared-libs/crates/start-core/src/power_key.rs @@ -87,37 +87,11 @@ async fn guard_backups( watch .wait_for(|status: &ServerStatus| status.backup_progress.is_some()) .await?; - // Enumerated per backup rather than once: udev tags every key-capable - // device, so the set changes whenever a keyboard is plugged in. - match power_key_devices().await { - Ok(devices) if !devices.is_empty() => { - let lock = manager - .inhibit( - "handle-power-key", - "StartOS", - "A backup is running", - "block", - ) - .await?; - { - let backup_over = - watch.wait_for(|status: &ServerStatus| status.backup_progress.is_none()); - tokio::pin!(backup_over); - tokio::select! { - over = &mut backup_over => { over?; } - // Never inhibit a key nobody is reading. - _ = read_power_key(devices, ctx) => tracing::warn!( - "stopped reading the power key for this backup; systemd-logind has it back" - ), - } - } - drop(lock); - } - Ok(_) => tracing::info!("no power-switch input device to read the power key from"), - Err(e) => { - tracing::error!("could not enumerate input devices: {e}"); - tracing::debug!("{e:?}"); - } + // Every way of failing to guard one backup leaves the key to logind for + // that backup only; the next one sets up from scratch. + if let Err(e) = guard_backup(ctx, manager, watch).await { + tracing::error!("not guarding this backup from the power button: {e}"); + tracing::debug!("{e:?}"); } watch .wait_for(|status: &ServerStatus| status.backup_progress.is_none()) @@ -125,6 +99,39 @@ async fn guard_backups( } } +async fn guard_backup( + ctx: &RpcContext, + manager: &Login1ManagerProxy<'_>, + watch: &mut TypedDbWatch, +) -> Result<(), Error> { + // Enumerated per backup rather than once: udev tags every key-capable + // device, so the set changes whenever a keyboard is plugged in. + let devices = power_key_devices().await?; + if devices.is_empty() { + tracing::info!("no power-switch input device to read the power key from"); + return Ok(()); + } + let lock = manager + .inhibit( + "handle-power-key", + "StartOS", + "A backup is running", + "block", + ) + .await?; + let backup_over = watch.wait_for(|status: &ServerStatus| status.backup_progress.is_none()); + tokio::pin!(backup_over); + tokio::select! { + over = &mut backup_over => { over?; } + // Never inhibit a key nobody is reading. + _ = read_power_key(devices, ctx) => tracing::warn!( + "stopped reading the power key for this backup; systemd-logind has it back" + ), + } + drop(lock); + Ok(()) +} + /// Returns as soon as any one device stops being readable: there is no telling /// which of them the firmware reports presses on, so a partial failure has to /// count as a failure. diff --git a/shared-libs/crates/start-core/src/shutdown.rs b/shared-libs/crates/start-core/src/shutdown.rs index ccd46e11dc..f0ab36a909 100644 --- a/shared-libs/crates/start-core/src/shutdown.rs +++ b/shared-libs/crates/start-core/src/shutdown.rs @@ -277,9 +277,21 @@ pub async fn run_deferred_power_actions(ctx: RpcContext) { tracing::error!("deferred power action failed: {e}"); tracing::debug!("{e:?}"); // Put it back rather than losing it, and give whatever failed room - // to recover before trying again. + // to recover before trying again. Not via `defer_or_begin`: with the + // backup already over it would commit to performing the action + // instead of recording it. if let Some(action) = action { - defer_or_begin(&ctx, action, true).await.log_err(); + ctx.db + .mutate(|db| { + db.as_public_mut() + .as_server_info_mut() + .as_status_info_mut() + .as_deferred_power_action_mut() + .ser(&Some(action)) + }) + .await + .result + .log_err(); } tokio::time::sleep(TAKE_RETRY).await; } From 14725f28971c07fe0e83d9084726eaf9cbbb270b Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:27:39 +0000 Subject: [PATCH 6/7] fix(start-os): drop an inert footer style, and clear updateProgress on restart too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../portal/components/header/power.component.ts | 3 --- shared-libs/crates/start-core/src/bins/startd.rs | 11 ++++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts index d7e022d860..60c78e3df1 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts @@ -33,9 +33,6 @@ const COUNTDOWN = 30 `, - // Both labels are sentences, and neither shrinks: 25rem cannot hold them on - // one row in any locale. - styles: 'footer { flex-wrap: wrap }', imports: [TuiButton, i18nPipe], }) export class PowerComponent { diff --git a/shared-libs/crates/start-core/src/bins/startd.rs b/shared-libs/crates/start-core/src/bins/startd.rs index 9d2412cc52..5b9d5f54a4 100644 --- a/shared-libs/crates/start-core/src/bins/startd.rs +++ b/shared-libs/crates/start-core/src/bins/startd.rs @@ -64,15 +64,16 @@ async fn inner_main( ) .await?; - // A backup, a deferred power action and the shutting-down flags all die - // with the process that set them, and only `init` — which this branch - // skips — would otherwise clear them. A stale backup in particular would - // leave the power key inhibited with nothing left to release it. Before - // the RPC surface goes live, so nothing races the reset. + // Every status here is written by a task that died with the previous + // process, and only `init` — which this branch skips — would otherwise + // clear them. `restart` is deliberately left: it is a reboot-needed + // marker meant to outlive one. Before the RPC surface goes live, so + // nothing races the reset. ctx.db .mutate(|db| { let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); status.as_backup_progress_mut().ser(&None)?; + status.as_update_progress_mut().ser(&None)?; status.as_deferred_power_action_mut().ser(&None)?; status.as_shutting_down_mut().ser(&false)?; status.as_restarting_mut().ser(&false) From e6c2ba4e2883f94d6ed09cab2466332d26891226 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:50:46 +0000 Subject: [PATCH 7/7] test(start-core): cover the deferred power-action state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- shared-libs/crates/start-core/src/shutdown.rs | 175 ++++++++++++++---- 1 file changed, 142 insertions(+), 33 deletions(-) diff --git a/shared-libs/crates/start-core/src/shutdown.rs b/shared-libs/crates/start-core/src/shutdown.rs index f0ab36a909..3eeab622c8 100644 --- a/shared-libs/crates/start-core/src/shutdown.rs +++ b/shared-libs/crates/start-core/src/shutdown.rs @@ -7,6 +7,7 @@ use ts_rs::TS; use crate::PLATFORM; use crate::context::RpcContext; +use crate::db::model::DatabaseModel; use crate::db::model::public::{PowerAction, ServerStatus}; use crate::disk::main::export; use crate::init::{STANDBY_MODE_PATH, SYSTEM_REBUILD_PATH}; @@ -173,18 +174,20 @@ pub async fn defer_until_backup_complete( action: PowerAction, ) -> Result { ctx.db - .mutate(|db| { - let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); - if status.as_backup_progress().transpose_ref().is_none() { - return Ok(false); - } - status.as_deferred_power_action_mut().ser(&Some(action))?; - Ok(true) - }) + .mutate(|db| defer_if_backing_up(db, action)) .await .result } +fn defer_if_backing_up(db: &mut DatabaseModel, action: PowerAction) -> Result { + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + if status.as_backup_progress().transpose_ref().is_none() { + return Ok(false); + } + status.as_deferred_power_action_mut().ser(&Some(action))?; + Ok(true) +} + /// Either records `action` for after the backup, or commits to performing it /// now — in one mutation, so a backup cannot start in the window between /// deciding and acting. Returns whether it was deferred. @@ -194,23 +197,38 @@ async fn defer_or_begin( after_backup: bool, ) -> Result { ctx.db - .mutate(|db| { - let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); - if after_backup && status.as_backup_progress().transpose_ref().is_some() { - status.as_deferred_power_action_mut().ser(&Some(action))?; - return Ok(true); - } - status.as_deferred_power_action_mut().ser(&None)?; - match action { - PowerAction::Restart => status.as_restarting_mut().ser(&true)?, - PowerAction::Shutdown => status.as_shutting_down_mut().ser(&true)?, - } - Ok(false) - }) + .mutate(|db| defer_or_begin_in(db, action, after_backup)) .await .result } +fn defer_or_begin_in( + db: &mut DatabaseModel, + action: PowerAction, + after_backup: bool, +) -> Result { + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + if after_backup && status.as_backup_progress().transpose_ref().is_some() { + status.as_deferred_power_action_mut().ser(&Some(action))?; + return Ok(true); + } + status.as_deferred_power_action_mut().ser(&None)?; + match action { + PowerAction::Restart => status.as_restarting_mut().ser(&true)?, + PowerAction::Shutdown => status.as_shutting_down_mut().ser(&true)?, + } + Ok(false) +} + +/// Reads the deferred action and clears it in one breath, so a cancellation that +/// lands first wins and the caller performs nothing. +fn take_deferred(db: &mut DatabaseModel) -> Result, Error> { + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + let action = status.as_deferred_power_action().de()?; + status.as_deferred_power_action_mut().ser(&None)?; + Ok(action) +} + /// Carries out each deferred power action once the backup it was waiting on /// finishes. Runs for the lifetime of startd: an action can be recorded at any /// point during any backup — from the web UI, the CLI, or the power button — so @@ -233,18 +251,7 @@ pub async fn run_deferred_power_actions(ctx: RpcContext) { tracing::debug!("{e:?}"); return; } - // Taking the action clears it in the same mutation, so a cancellation - // that lands first wins and this pass does nothing. - let taken = ctx - .db - .mutate(|db| { - let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); - let action = status.as_deferred_power_action().de()?; - status.as_deferred_power_action_mut().ser(&None)?; - Ok(action) - }) - .await - .result; + let taken = ctx.db.mutate(take_deferred).await.result; let action = match taken { Ok(action) => action, Err(e) => { @@ -337,3 +344,105 @@ pub async fn rebuild(ctx: RpcContext) -> Result<(), Error> { tokio::fs::write(SYSTEM_REBUILD_PATH, b"").await?; restart(ctx, ShutdownParams::default()).await } + +#[cfg(test)] +mod test { + use imbl_value::json; + use patch_db::ModelExt; + + use super::*; + + fn db_with(backup_progress: Value, deferred: Value) -> DatabaseModel { + DatabaseModel::from_value(json!({ + "public": { "serverInfo": { "statusInfo": { + "backupProgress": backup_progress, + "updateProgress": null, + "shuttingDown": false, + "restarting": false, + "restart": null, + "deferredPowerAction": deferred, + } } } + })) + } + + fn backing_up() -> Value { + json!({ "overall": { "done": 0, "total": 2, "units": null }, "phases": [] }) + } + + /// `(deferred action, shutting down, restarting)`. + fn status(db: &DatabaseModel) -> (Option, bool, bool) { + let status = db.as_public().as_server_info().as_status_info(); + ( + status.as_deferred_power_action().de().unwrap(), + status.as_shutting_down().de().unwrap(), + status.as_restarting().de().unwrap(), + ) + } + + #[test] + fn records_the_action_instead_of_beginning_it_during_a_backup() { + let mut db = db_with(backing_up(), json!(null)); + assert!(defer_or_begin_in(&mut db, PowerAction::Shutdown, true).unwrap()); + assert_eq!( + status(&db), + (Some(PowerAction::Shutdown), false, false), + "recorded, and nothing has begun" + ); + } + + #[test] + fn begins_the_action_when_no_backup_is_running() { + let mut db = db_with(json!(null), json!(null)); + assert!(!defer_or_begin_in(&mut db, PowerAction::Restart, true).unwrap()); + assert_eq!(status(&db), (None, false, true)); + } + + /// The systemd units drive a power-off that cannot wait, so they pass + /// `after_backup: false` and must interrupt the backup. + #[test] + fn begins_the_action_without_after_backup_even_during_a_backup() { + let mut db = db_with(backing_up(), json!(null)); + assert!(!defer_or_begin_in(&mut db, PowerAction::Shutdown, false).unwrap()); + assert_eq!(status(&db), (None, true, false)); + } + + /// Why [`run_deferred_power_actions`] cannot re-arm through this function: + /// with the backup over it takes the other branch and commits to the action, + /// which as a re-arm would leave the server flagged as powering down with + /// nothing left to do it. + #[test] + fn beginning_an_action_clears_any_pending_one() { + let mut db = db_with(json!(null), json!("restart")); + assert!(!defer_or_begin_in(&mut db, PowerAction::Shutdown, true).unwrap()); + assert_eq!(status(&db), (None, true, false)); + } + + #[test] + fn the_power_key_records_but_never_begins() { + let mut db = db_with(backing_up(), json!(null)); + assert!(defer_if_backing_up(&mut db, PowerAction::Shutdown).unwrap()); + assert_eq!(status(&db), (Some(PowerAction::Shutdown), false, false)); + + let mut db = db_with(json!(null), json!(null)); + assert!(!defer_if_backing_up(&mut db, PowerAction::Shutdown).unwrap()); + assert_eq!( + status(&db), + (None, false, false), + "no backup to protect, so the press is logind's to act on" + ); + } + + #[test] + fn taking_the_action_clears_it_so_only_one_pass_performs_it() { + let mut db = db_with(json!(null), json!("restart")); + assert_eq!(take_deferred(&mut db).unwrap(), Some(PowerAction::Restart)); + assert_eq!(take_deferred(&mut db).unwrap(), None); + } + + /// A cancellation that lands before the take wins outright. + #[test] + fn taking_a_cancelled_action_yields_nothing() { + let mut db = db_with(json!(null), json!(null)); + assert_eq!(take_deferred(&mut db).unwrap(), None); + } +}