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..2b2519167c 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 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 d80ff90135..058ef4681b 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 first
+.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..93a71bb8e7 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, 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/CHANGELOG.md b/projects/start-os/CHANGELOG.md
index bf9d8f46b9..637203d4b5 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 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).
+
### 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..922627fb4a 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 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.
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..a1bebcc31b 100644
--- a/projects/start-os/docs/src/cli-reference.md
+++ b/projects/start-os/docs/src/cli-reference.md
@@ -54,11 +54,21 @@ 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
+- `--nowait` — Return immediately instead of waiting for graceful shutdown
### `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
+- `--nowait` — Return immediately instead of waiting for graceful shutdown
+
+### `start-cli server cancel-deferred-power`
+
+Cancel a restart or shutdown that is waiting for a backup to finish.
### `start-cli server update`
diff --git a/projects/start-os/docs/src/surge-and-ups.md b/projects/start-os/docs/src/surge-and-ups.md
index 001ae66fc2..296675747f 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. 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/menu.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts
index 9b895140d6..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
@@ -5,8 +5,8 @@ import {
DocsLinkDirective,
i18nPipe,
SafeLinksDirective,
- TaskService,
} from '@start9labs/shared'
+import { T } from '@start9labs/start-core'
import {
TuiButton,
TuiDataList,
@@ -17,6 +17,7 @@ import {
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service'
+import { PowerService } from 'src/app/services/power.service'
import { STATUS } from 'src/app/services/status.service'
import { ABOUT } from './about.component'
@@ -138,8 +139,8 @@ import { ABOUT } from './about.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
@@ -149,7 +150,11 @@ export class HeaderMenuComponent {
this.dialog.openComponent(ABOUT, { label: 'About this server' }).subscribe()
}
- async promptPower(action: 'restart' | 'shutdown') {
+ 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).subscribe()
+
this.dialog
.openConfirm(
action === 'restart'
@@ -175,15 +180,7 @@ export class HeaderMenuComponent {
},
)
.pipe(filter(Boolean))
- .subscribe(() =>
- this.tasks.run(
- async () =>
- await this.api[
- action === 'restart' ? 'restartServer' : 'shutdownServer'
- ]({}),
- `Beginning ${action}`,
- ),
- )
+ .subscribe(() => this.power.power(action).subscribe())
}
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..60c78e3df1
--- /dev/null
+++ b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts
@@ -0,0 +1,64 @@
+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 { take, timer } from 'rxjs'
+
+const COUNTDOWN = 30
+
+@Component({
+ template: `
+
+ {{
+ '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
+ }}
+
+ }
+
+ `,
+ imports: [TuiButton, i18nPipe],
+})
+export class PowerComponent {
+ private readonly context =
+ injectContext>()
+
+ protected readonly action = this.context.data
+ protected readonly seconds = signal(COUNTDOWN)
+
+ constructor() {
+ // 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 => {
+ this.seconds.set(COUNTDOWN - tick)
+ if (tick === COUNTDOWN) 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..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
@@ -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({
@@ -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;
@@ -49,8 +74,7 @@ import { HeaderComponent } from './components/header/header.component'
}
- }
- @if (restartReason(); as reason) {
+ } @else if (restartReason(); as reason) {
@@ -167,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)
+ protected readonly power = inject(PowerService)
readonly mobile = inject(WA_IS_MOBILE)
readonly plugins = inject(PluginsService)
@@ -177,6 +200,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 {
@@ -184,9 +210,10 @@ export class PortalComponent {
}
restart() {
- this.tasks.run(async () => {
- this.bar.set(false)
- await this.api.restartServer({})
- }, 'Beginning 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)
+ })
}
}
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..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
@@ -44,6 +44,7 @@ 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 { 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'
@@ -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').subscribe()
}
}
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..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
@@ -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,14 @@ 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')
+ }
+ this.deferPower(null)
+
const patch = [
{
op: PatchOp.REPLACE,
@@ -414,9 +421,14 @@ 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')
+ }
+ this.deferPower(null)
+
const patch = [
{
op: PatchOp.REPLACE,
@@ -440,6 +452,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 +939,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 +1017,13 @@ export class MockApiService extends ApiService {
},
]
this.mockRevision(lastPatch)
+ this.backingUp = false
+ if (this.deferredPowerAction) {
+ const action = this.deferredPowerAction
+ 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 +2293,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/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..3eb49c87c3
--- /dev/null
+++ b/projects/start-os/web/ui/src/app/services/power.service.ts
@@ -0,0 +1,60 @@
+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'
+
+@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. Emits
+ * 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)
+
+ return this.dialog
+ .openComponent(POWER, {
+ label: action === 'restart' ? 'Restart' : 'Warning',
+ size: 's',
+ data: action,
+ })
+ .pipe(switchMap(now => this.run(action, !now)))
+ }
+
+ cancel() {
+ this.tasks.run(async () => await this.api.cancelDeferredPower({}))
+ }
+
+ 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/locales/i18n.yaml b/shared-libs/crates/start-core/locales/i18n.yaml
index e3c6dbedfc..c3431202cc 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 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"
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..5b9d5f54a4 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;
@@ -63,6 +64,23 @@ async fn inner_main(
)
.await?;
+ // 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)
+ })
+ .await
+ .result?;
+
server.serve_ui_for(ctx.clone());
handle.complete();
@@ -103,6 +121,15 @@ async fn inner_main(
.expect("send shutdown signal");
});
+ let deferred_power_ctx = rpc_ctx.clone();
+ let _deferred_power = NonDetachingJoinHandle::from(tokio::spawn(
+ crate::shutdown::run_deferred_power_actions(deferred_power_ctx),
+ ));
+ #[cfg(target_os = "linux")]
+ let _power_key = NonDetachingJoinHandle::from(tokio::spawn(
+ crate::power_key::watch_power_key(rpc_ctx.clone()),
+ ));
+
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..e27846e6e7 100644
--- a/shared-libs/crates/start-core/src/lib.rs
+++ b/shared-libs/crates/start-core/src/lib.rs
@@ -67,6 +67,8 @@ 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;
pub mod registry;
@@ -338,6 +340,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..7161095b8b
--- /dev/null
+++ b/shared-libs/crates/start-core/src/power_key.rs
@@ -0,0 +1,273 @@
+//! 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.
+//!
+//! 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};
+use std::path::{Path, PathBuf};
+
+use futures::FutureExt;
+use futures::future::select_all;
+use nix::sys::stat;
+use patch_db::TypedDbWatch;
+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::{STATUS_INFO_PTR, defer_until_backup_complete};
+use crate::sound::BEP;
+
+const EV_KEY: u16 = 0x01;
+/// 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::();
+/// 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 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) {
+ let manager = match logind().await {
+ Ok(manager) => manager,
+ Err(e) => {
+ tracing::warn!("cannot reach systemd-logind: {e}");
+ tracing::debug!("{e:?}");
+ return;
+ }
+ };
+ let mut watch = ctx
+ .db
+ .watch(STATUS_INFO_PTR.parse::().unwrap())
+ .await
+ .typed::();
+ 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 guard_backups(
+ ctx: &RpcContext,
+ manager: &Login1ManagerProxy<'_>,
+ watch: &mut TypedDbWatch,
+) -> Result<(), Error> {
+ loop {
+ watch
+ .wait_for(|status: &ServerStatus| status.backup_progress.is_some())
+ .await?;
+ // 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())
+ .await?;
+ }
+}
+
+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.
+async fn read_power_key(devices: Vec, ctx: &RpcContext) {
+ select_all(
+ devices
+ .into_iter()
+ .map(|path| read_device(path, ctx.clone()).boxed()),
+ )
+ .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 whoever pressed it has. Spawned so a contended
+ // sound device cannot stall the reader.
+ tokio::spawn(async { BEP.play().await.log_err() });
+ }
+ 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
+ && 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
+}
+
+#[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[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));
+
+ // 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..3eeab622c8 100644
--- a/shared-libs/crates/start-core/src/shutdown.rs
+++ b/shared-libs/crates/start-core/src/shutdown.rs
@@ -1,9 +1,14 @@
+use std::time::Duration;
+
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::DatabaseModel;
+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 +124,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")]
@@ -129,11 +134,24 @@ 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,
+ /// 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,
}
+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);
+
async fn begin_shutdown(ctx: &RpcContext, restart: bool, wait: bool) {
ctx.shutdown
.send(Some(Shutdown {
@@ -147,43 +165,284 @@ 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. 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,
+) -> Result {
+ ctx.db
+ .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.
+async fn defer_or_begin(
+ ctx: &RpcContext,
+ action: PowerAction,
+ after_backup: bool,
+) -> Result {
+ ctx.db
+ .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