From 024b876075b82244d30404104d2d148408134827 Mon Sep 17 00:00:00 2001
From: Yann Ramin
Date: Sun, 25 Oct 2026 14:43:14 -0700
Subject: [PATCH 1/6] Run the worker as a Windows service
`psf-guard worker-service install` creates the PSF Guard Worker service,
started with Windows and run as Local System with the worker settings the
desktop app saved (or --config FILE), and starts it. Stop and shutdown let
the worker leave the server, which queues its running task again, while
the service reports a stop pending. A failed run, such as a refused token,
exits nonzero, and the service's failure actions start it again after 30
seconds. `worker-service uninstall` stops and removes it. The service logs
to files starting `worker` in %ProgramData%\PSF Guard\logs.
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 18 ++
Cargo.toml | 3 +
docs/REMOTE_WORKERS.md | 34 ++++
docs/design/remote-workers.md | 7 +
docs/releases/unreleased.md | 6 +
src/cli.rs | 34 +++-
src/cli_main.rs | 22 +++
src/commands/worker.rs | 1 +
src/commands/worker/service.rs | 308 +++++++++++++++++++++++++++++++++
9 files changed, 432 insertions(+), 1 deletion(-)
create mode 100644 src/commands/worker/service.rs
diff --git a/Cargo.lock b/Cargo.lock
index 79da5e12..c5901013 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4519,6 +4519,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"uuid",
+ "windows-service",
"windows-sys 0.61.2",
"zip 9.0.5",
]
@@ -7830,6 +7831,12 @@ dependencies = [
"safe_arch",
]
+[[package]]
+name = "widestring"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -7980,6 +7987,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-service"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2"
+dependencies = [
+ "bitflags 2.13.2",
+ "widestring",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "windows-strings"
version = "0.5.1"
diff --git a/Cargo.toml b/Cargo.toml
index ab4ea367..d66c4ac7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -117,6 +117,9 @@ schemars = "1"
# macOS memory probing, and signalling the process group a WBPP run starts.
libc = "0.2"
[target.'cfg(target_os = "windows")'.dependencies]
+# The worker as a Windows service: a thin wrapper of the Service Control
+# Manager API.
+windows-service = "0.8"
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
diff --git a/docs/REMOTE_WORKERS.md b/docs/REMOTE_WORKERS.md
index 1fd53521..a7264821 100644
--- a/docs/REMOTE_WORKERS.md
+++ b/docs/REMOTE_WORKERS.md
@@ -162,6 +162,40 @@ again every 30 seconds; `journalctl -u psf-guard-worker` shows why.
`PSF_GUARD_WORKER_EXTRA_ARGS` is split on spaces, so it cannot hold a path
that contains one.
+## Run a worker as a Windows service
+
+A Windows PC that runs work while nobody is logged in can run the worker as
+a service instead of in the tray. The service starts with Windows and runs as
+Local System.
+
+1. Save the worker's settings in **Settings › Run a worker**, as for the
+ tray. Leave **Start the worker when I log in** cleared, and stop the tray
+ worker, so that only one worker runs on the computer.
+2. Open PowerShell as administrator, and run:
+
+ ```powershell
+ psf-guard-cli worker-service install
+ ```
+
+ The service uses the settings you saved. To use another settings file,
+ add `--config FILE`.
+
+The service appears as **PSF Guard Worker** in the Services console. It
+writes its log to files starting `worker` in
+`%ProgramData%\PSF Guard\logs`. When it fails, such as when the server
+refuses the token, Windows starts it again after 30 seconds. After you
+change the settings, restart the service from the Services console.
+
+To remove the service, run `psf-guard-cli worker-service uninstall` from
+PowerShell opened as administrator. The worker stops, and the server queues
+its running task again.
+
+Local System reads files on the computer, but not network shares mapped by
+a user. For task files on shared storage, change the service's account in
+the Services console to a user that can read and write the shares. Enter
+each share in the shared folders as a UNC path, such as `\\nas\astro`,
+since a drive letter mapped by a user does not exist for the service.
+
## Choose the work a worker accepts
A worker accepts everything it can run unless you list its work with
diff --git a/docs/design/remote-workers.md b/docs/design/remote-workers.md
index cba2e25c..62eca89d 100644
--- a/docs/design/remote-workers.md
+++ b/docs/design/remote-workers.md
@@ -219,6 +219,13 @@ and registers no plugins.
- **Desktop app.** `src/desktop_worker.rs` has the commands behind
**Settings › Workers › Worker on this computer**. They read and save settings,
start the tray process, and pause, resume and stop it through the control port.
+- **Windows service** (`commands/worker/service.rs`): `psf-guard
+ worker-service install` creates `PSFGuardWorker`, started automatically,
+ as Local System, running `worker-service run --config ` from
+ the installing binary. Stop and shutdown quit the worker, which leaves the
+ server, while the service reports a stop pending for up to 40 seconds. A
+ failed run exits nonzero, and the failure actions start it again after 30
+ seconds. It serves no controls and has no tray.
- **Start worker at login** (`commands/worker/login.rs`): a LaunchAgent with
`RunAtLoad` on macOS, and a value under
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run` on Windows. Both
diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md
index 7f6a4b38..eb43fdc7 100644
--- a/docs/releases/unreleased.md
+++ b/docs/releases/unreleased.md
@@ -38,6 +38,12 @@
you log in. Opening PSF Guard while its window is open brings that window
forward instead of starting a second copy.
+- Run a worker as a Windows service, for a PC that runs work while nobody is
+ logged in. Save the worker's settings under **Settings › Workers › This
+ computer**, then run `psf-guard-cli worker-service install` in PowerShell
+ as administrator. Run it again after you change the settings or update PSF
+ Guard.
+
- Run a worker as a Linux service. The Fedora RPM and the Debian package
install a `psf-guard-worker` systemd service, not enabled. Enter the
server's address and a worker token in `/etc/psf-guard/worker.conf`, then
diff --git a/src/cli.rs b/src/cli.rs
index 77f10ea9..a8c5e58f 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -14,7 +14,26 @@ pub struct Cli {
pub command: Commands,
}
-/// CLI spelling of [`crate::server::stack_preview::snr::StackFrameOrder`].
+/// What `psf-guard worker-service` does.
+#[derive(Subcommand, Debug)]
+pub enum WorkerServiceAction {
+ /// Install the service, and start it.
+ Install {
+ /// The worker's settings file (defaults to the one the desktop app
+ /// saves for this user).
+ #[arg(long, value_name = "FILE")]
+ config: Option,
+ },
+ /// Stop the service and remove it.
+ Uninstall,
+ /// Run as the service. Windows starts this; it is not for a terminal.
+ #[command(hide = true)]
+ Run {
+ #[arg(long, value_name = "FILE")]
+ config: String,
+ },
+}
+
/// How a worker reaches task files.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum WorkerFilesArg {
@@ -25,6 +44,7 @@ pub enum WorkerFilesArg {
Shared,
}
+/// CLI spelling of [`crate::server::stack_preview::snr::StackFrameOrder`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum StackOrderArg {
/// Chronological.
@@ -1388,6 +1408,18 @@ pub enum Commands {
slots: usize,
},
+ /// Run the worker as a Windows service, for a computer that runs work
+ /// while nobody is logged in.
+ ///
+ /// Save the worker's settings in the desktop app's Settings › Run a
+ /// worker first, or write a settings file. Then, from a terminal opened
+ /// as administrator, run `psf-guard-cli worker-service install`. The
+ /// service runs as Local System and starts with Windows.
+ WorkerService {
+ #[command(subcommand)]
+ action: WorkerServiceAction,
+ },
+
/// Start the web server for API access and static file serving
Server {
/// Path to TOML configuration file
diff --git a/src/cli_main.rs b/src/cli_main.rs
index f1abd13b..fa0c06a3 100644
--- a/src/cli_main.rs
+++ b/src/cli_main.rs
@@ -1639,6 +1639,28 @@ pub fn main() -> Result<()> {
))?;
}
}
+ Commands::WorkerService { action } => {
+ use crate::cli::WorkerServiceAction;
+ use crate::commands::worker::{service, WorkerSettings};
+ match action {
+ WorkerServiceAction::Install { config } => {
+ let path = match config {
+ Some(config) => std::path::PathBuf::from(config),
+ None => WorkerSettings::default_path()?,
+ };
+ service::install(&path)?;
+ println!(
+ "Installed and started the PSF Guard Worker service with the settings in {}",
+ path.display()
+ );
+ }
+ WorkerServiceAction::Uninstall => {
+ service::uninstall()?;
+ println!("Stopped and removed the PSF Guard Worker service");
+ }
+ WorkerServiceAction::Run { config } => service::run(config.into())?,
+ }
+ }
Commands::Users { action } => {
crate::commands::users::manage_users(action)?;
}
diff --git a/src/commands/worker.rs b/src/commands/worker.rs
index 7aead73c..081cba6c 100644
--- a/src/commands/worker.rs
+++ b/src/commands/worker.rs
@@ -27,6 +27,7 @@ use crate::remote_work::{file_digest, map_path, LocalTools, WorkChoice};
pub mod control;
pub mod login;
+pub mod service;
pub mod settings;
pub use control::{WorkerControl, WorkerStatus};
diff --git a/src/commands/worker/service.rs b/src/commands/worker/service.rs
new file mode 100644
index 00000000..28c833f1
--- /dev/null
+++ b/src/commands/worker/service.rs
@@ -0,0 +1,308 @@
+//! `psf-guard worker-service`: the worker as a Windows service, for a
+//! computer that runs work while nobody is logged in. The service runs
+//! `psf-guard worker-service run --config FILE` as Local System, with the
+//! settings the desktop app saved, and starts with Windows. It has no tray;
+//! a desktop that someone logs in to can run the tray worker instead.
+
+use std::path::{Path, PathBuf};
+
+use anyhow::Result;
+
+/// The service's name, as `sc.exe` and the Services console identify it.
+#[cfg_attr(not(windows), allow(dead_code))]
+const SERVICE_NAME: &str = "PSFGuardWorker";
+#[cfg_attr(not(windows), allow(dead_code))]
+const DISPLAY_NAME: &str = "PSF Guard Worker";
+#[cfg_attr(not(windows), allow(dead_code))]
+const DESCRIPTION: &str = "Runs stack builds and RC-Astro tools for a PSF Guard server.";
+
+/// Install the service with the settings at `settings`, and start it.
+pub fn install(settings: &Path) -> Result<()> {
+ let loaded = super::WorkerSettings::load(settings)?.ok_or_else(|| {
+ anyhow::anyhow!(
+ "There are no worker settings at {}; save them in PSF Guard's Settings › Run a \
+ worker, or give a settings file with --config",
+ settings.display()
+ )
+ })?;
+ loaded.validate()?;
+ platform::install(settings)
+}
+
+/// Stop the service and remove it.
+pub fn uninstall() -> Result<()> {
+ platform::uninstall()
+}
+
+/// Run as the service. Windows starts this; it is not for a terminal.
+pub fn run(settings: PathBuf) -> Result<()> {
+ platform::run(settings)
+}
+
+/// The arguments the service starts the binary with.
+#[cfg_attr(not(windows), allow(dead_code))]
+fn launch_arguments(settings: &Path) -> Vec {
+ vec![
+ "worker-service".into(),
+ "run".into(),
+ "--config".into(),
+ settings.as_os_str().to_owned(),
+ ]
+}
+
+#[cfg(windows)]
+mod platform {
+ use std::ffi::OsString;
+ use std::sync::OnceLock;
+ use std::time::Duration;
+
+ use anyhow::Context;
+ use windows_service::service::{
+ ServiceAccess, ServiceAction, ServiceActionType, ServiceControl, ServiceControlAccept,
+ ServiceErrorControl, ServiceExitCode, ServiceFailureActions, ServiceFailureResetPeriod,
+ ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
+ };
+ use windows_service::service_control_handler::{
+ self, ServiceControlHandlerResult, ServiceStatusHandle,
+ };
+ use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
+ use windows_service::{define_windows_service, service_dispatcher};
+
+ use super::*;
+ use crate::commands::worker::{run_with, WorkerControl, WorkerLaunch};
+
+ /// How long Windows waits for the worker to leave the server on a stop.
+ const STOP_WAIT: Duration = Duration::from_secs(40);
+ /// How long after a failure Windows starts the service again.
+ const RESTART_AFTER: Duration = Duration::from_secs(30);
+
+ static SETTINGS: OnceLock = OnceLock::new();
+
+ define_windows_service!(ffi_service_main, service_main);
+
+ pub fn install(settings: &Path) -> Result<()> {
+ let manager = ServiceManager::local_computer(
+ None::<&str>,
+ ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
+ )
+ .context("Windows refused to install the service; run this from a terminal opened as administrator")?;
+ let info = ServiceInfo {
+ name: OsString::from(SERVICE_NAME),
+ display_name: OsString::from(DISPLAY_NAME),
+ service_type: ServiceType::OWN_PROCESS,
+ start_type: ServiceStartType::AutoStart,
+ error_control: ServiceErrorControl::Normal,
+ executable_path: std::env::current_exe()?,
+ launch_arguments: launch_arguments(settings),
+ dependencies: Vec::new(),
+ // Local System.
+ account_name: None,
+ account_password: None,
+ };
+ let service = manager
+ .create_service(
+ &info,
+ ServiceAccess::CHANGE_CONFIG | ServiceAccess::START | ServiceAccess::QUERY_STATUS,
+ )
+ .context("The service could not be created; if it exists, uninstall it first")?;
+ service.set_description(DESCRIPTION)?;
+ // After a failure, such as a refused token, start again in 30
+ // seconds, as the Linux service does.
+ service.update_failure_actions(ServiceFailureActions {
+ reset_period: ServiceFailureResetPeriod::After(Duration::from_secs(24 * 60 * 60)),
+ reboot_msg: None,
+ command: None,
+ actions: Some(
+ (0..3)
+ .map(|_| ServiceAction {
+ action_type: ServiceActionType::Restart,
+ delay: RESTART_AFTER,
+ })
+ .collect(),
+ ),
+ })?;
+ service.set_failure_actions_on_non_crash_failures(true)?;
+ service.start::<&str>(&[])?;
+ Ok(())
+ }
+
+ pub fn uninstall() -> Result<()> {
+ let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
+ .context("Windows refused to open the services; run this from a terminal opened as administrator")?;
+ let service = manager
+ .open_service(
+ SERVICE_NAME,
+ ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
+ )
+ .context("The PSF Guard Worker service is not installed")?;
+ if service.query_status()?.current_state != ServiceState::Stopped {
+ service.stop()?;
+ let deadline = std::time::Instant::now() + STOP_WAIT;
+ while service.query_status()?.current_state != ServiceState::Stopped
+ && std::time::Instant::now() < deadline
+ {
+ std::thread::sleep(Duration::from_millis(250));
+ }
+ }
+ service.delete()?;
+ Ok(())
+ }
+
+ pub fn run(settings: PathBuf) -> Result<()> {
+ init_logging();
+ let _ = SETTINGS.set(settings);
+ service_dispatcher::start(SERVICE_NAME, ffi_service_main)
+ .context("This command is for Windows to start the service; use `psf-guard worker` in a terminal")?;
+ Ok(())
+ }
+
+ fn service_main(_arguments: Vec) {
+ if let Err(error) = serve() {
+ tracing::error!("The worker service stopped: {error:#}");
+ }
+ }
+
+ fn status(state: ServiceState, wait: Duration, exit: u32) -> ServiceStatus {
+ ServiceStatus {
+ service_type: ServiceType::OWN_PROCESS,
+ current_state: state,
+ controls_accepted: if state == ServiceState::Running {
+ ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN
+ } else {
+ ServiceControlAccept::empty()
+ },
+ exit_code: ServiceExitCode::Win32(exit),
+ checkpoint: 0,
+ wait_hint: wait,
+ process_id: None,
+ }
+ }
+
+ fn serve() -> Result<()> {
+ let control = WorkerControl::default();
+ let handle: std::sync::Arc> = Default::default();
+ let registered = {
+ let (control, handle) = (control.clone(), handle.clone());
+ service_control_handler::register(SERVICE_NAME, move |event| match event {
+ ServiceControl::Stop | ServiceControl::Shutdown => {
+ if let Some(handle) = handle.get() {
+ let _ = handle.set_service_status(status(
+ ServiceState::StopPending,
+ STOP_WAIT,
+ 0,
+ ));
+ }
+ // Running tasks stop and the worker leaves the server,
+ // which queues them again.
+ control.quit();
+ ServiceControlHandlerResult::NoError
+ }
+ ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
+ _ => ServiceControlHandlerResult::NotImplemented,
+ })?
+ };
+ let _ = handle.set(registered);
+ registered.set_service_status(status(ServiceState::Running, Duration::ZERO, 0))?;
+ let settings = SETTINGS
+ .get()
+ .cloned()
+ .context("The service started without its settings file")?;
+ let ran = tokio::runtime::Runtime::new()
+ .map_err(anyhow::Error::from)
+ .and_then(|runtime| {
+ runtime.block_on(run_with(WorkerLaunch::Settings(settings), control))
+ });
+ // A nonzero exit tells Windows the service failed, so it starts it
+ // again.
+ registered.set_service_status(status(
+ ServiceState::Stopped,
+ Duration::ZERO,
+ u32::from(ran.is_err()),
+ ))?;
+ ran
+ }
+
+ /// Log to files starting `psf-guard-worker` in
+ /// `%ProgramData%\PSF Guard\logs`: a service has no console, and Local
+ /// System's own folders are hard to find.
+ fn init_logging() {
+ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer};
+ let dir = std::env::var_os("ProgramData")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
+ .join("PSF Guard")
+ .join("logs");
+ let file = crate::desktop_log::worker_file_appender(&dir)
+ .ok()
+ .map(|appender| {
+ tracing_subscriber::fmt::layer()
+ .with_ansi(false)
+ .with_target(false)
+ .with_writer(appender)
+ .with_filter(
+ tracing_subscriber::filter::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| tracing_subscriber::filter::EnvFilter::new("info")),
+ )
+ });
+ let _ = tracing_subscriber::registry().with(file).try_init();
+ }
+}
+
+#[cfg(not(windows))]
+mod platform {
+ use super::*;
+
+ const ELSEWHERE: &str = "The worker runs as a service this way only on Windows. On Linux, use \
+ the psf-guard-worker systemd service; on a Mac, open PSF Guard's Settings › Run a worker";
+
+ pub fn install(_settings: &Path) -> Result<()> {
+ anyhow::bail!(ELSEWHERE)
+ }
+
+ pub fn uninstall() -> Result<()> {
+ anyhow::bail!(ELSEWHERE)
+ }
+
+ pub fn run(_settings: PathBuf) -> Result<()> {
+ anyhow::bail!(ELSEWHERE)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_service_starts_the_worker_with_its_settings_file() {
+ let settings = Path::new(r"C:\Users\ann\AppData\Roaming\psf-guard\worker.json");
+ assert_eq!(
+ launch_arguments(settings),
+ [
+ "worker-service",
+ "run",
+ "--config",
+ r"C:\Users\ann\AppData\Roaming\psf-guard\worker.json"
+ ]
+ .map(std::ffi::OsString::from)
+ );
+ }
+
+ #[test]
+ fn installing_needs_settings_that_can_run() {
+ let dir = tempfile::tempdir().unwrap();
+ let missing = dir.path().join("worker.json");
+ assert!(install(&missing)
+ .unwrap_err()
+ .to_string()
+ .contains("Settings › Run a worker"));
+ std::fs::write(
+ &missing,
+ r#"{"server": "observatory:3000", "token": "psfw_secret"}"#,
+ )
+ .unwrap();
+ assert!(install(&missing)
+ .unwrap_err()
+ .to_string()
+ .contains("http://"));
+ }
+}
From f774239d79d6c02470ccc90f73f77c48de550866 Mon Sep 17 00:00:00 2001
From: Yann Ramin
Date: Sun, 25 Oct 2026 15:05:41 -0700
Subject: [PATCH 2/6] Install the worker service from copies only
administrators can change
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The app installs per user, so a service that ran its binary or read its
settings in place would let any program running as that user gain the
service's rights. `worker-service install` now copies the binary to
Program Files and the settings to ProgramData, limits that folder to
SYSTEM, Administrators and the service, and runs the service as its own
virtual account, NT SERVICE\PSFGuardWorker, not Local System. Running it
again updates the copies. The settings path is made absolute; shutdown
uses preshutdown with time for the worker to leave the server; a stop
reports rising checkpoints; a failure logs its reason before the service
reports it stopped, with a service-specific code; uninstall reports
access refused and a stop still in progress, and removes the copies.
Settings › Run a worker shows a running service and offers no second
worker then.
Co-Authored-By: Claude Opus 5.5
---
docs/REMOTE_WORKERS.md | 58 ++-
docs/design/remote-workers.md | 25 +-
src/cli.rs | 4 +-
src/cli_main.rs | 3 +-
src/commands/worker/service.rs | 424 +++++++++++++-----
src/desktop_worker.rs | 5 +
static/e2e/desktop-worker-settings.spec.ts | 1 +
.../src/components/DesktopWorkerSettings.tsx | 22 +-
.../__tests__/DesktopWorkerSettings.test.tsx | 16 +
static/src/utils/tauri.ts | 2 +
10 files changed, 425 insertions(+), 135 deletions(-)
diff --git a/docs/REMOTE_WORKERS.md b/docs/REMOTE_WORKERS.md
index a7264821..59573ceb 100644
--- a/docs/REMOTE_WORKERS.md
+++ b/docs/REMOTE_WORKERS.md
@@ -165,11 +165,10 @@ that contains one.
## Run a worker as a Windows service
A Windows PC that runs work while nobody is logged in can run the worker as
-a service instead of in the tray. The service starts with Windows and runs as
-Local System.
+a service instead of in the tray. The service starts with Windows.
-1. Save the worker's settings in **Settings › Run a worker**, as for the
- tray. Leave **Start the worker when I log in** cleared, and stop the tray
+1. Save the worker's settings under **Settings › Workers › This
+ computer**, as for the tray. Clear **Start at login**, and stop the tray
worker, so that only one worker runs on the computer.
2. Open PowerShell as administrator, and run:
@@ -177,24 +176,43 @@ Local System.
psf-guard-cli worker-service install
```
- The service uses the settings you saved. To use another settings file,
- add `--config FILE`.
-
-The service appears as **PSF Guard Worker** in the Services console. It
-writes its log to files starting `worker` in
-`%ProgramData%\PSF Guard\logs`. When it fails, such as when the server
-refuses the token, Windows starts it again after 30 seconds. After you
-change the settings, restart the service from the Services console.
+ To use another settings file, add `--config FILE`.
+
+The install copies PSF Guard to `C:\Program Files\PSF Guard Worker`, and
+the settings to `C:\ProgramData\PSF Guard Worker`, where only
+administrators and the service can read them; they contain the token. The
+service runs as its own account, `NT SERVICE\PSFGuardWorker`, and keeps its
+task files in that folder's `tasks` folder, whatever work folder the
+settings give.
+
+- **Change the settings, or update PSF Guard.** The service keeps using its
+ copies. Save the settings, or install the update, then run
+ `psf-guard-cli worker-service install` again; it stops the service,
+ updates the copies and starts it. The page's **Restart** button does not
+ restart the service.
+- **Logs.** The service writes its log to files starting `worker` in
+ `C:\ProgramData\PSF Guard Worker\logs`.
+- **Failures.** When the server refuses the token, the service stops, and
+ Windows starts it again after 30 seconds.
+- **Stopping.** Stopping the service, or shutting Windows down, lets the
+ worker leave the server, which queues its running task again.
+- **Sleep.** Windows may sleep while nobody is logged in. Set **Sleep** to
+ **Never** in the power settings of a computer that runs work overnight.
+- **RC-Astro tools.** The service runs the rc-astro on the computer's
+ `PATH`. rc-astro installed or licensed for one user may not run for the
+ service; then the service takes stack builds only. Run the tray worker on
+ such a computer instead.
+- **Shared storage.** The service's account can use network shares only
+ through the computer's own network identity. To read and write shared
+ task files, change the service's account in the Services console to a
+ user who can use the shares, give that user modify access to
+ `C:\ProgramData\PSF Guard Worker`, and enter each share in the shared
+ folders as a UNC path, such as `\\nas\astro`.
To remove the service, run `psf-guard-cli worker-service uninstall` from
-PowerShell opened as administrator. The worker stops, and the server queues
-its running task again.
-
-Local System reads files on the computer, but not network shares mapped by
-a user. For task files on shared storage, change the service's account in
-the Services console to a user that can read and write the shares. Enter
-each share in the shared folders as a UNC path, such as `\\nas\astro`,
-since a drive letter mapped by a user does not exist for the service.
+PowerShell opened as administrator. The worker stops and the server queues
+its running task again; the copies, with the token, are deleted. Remove the
+service before you uninstall PSF Guard.
## Choose the work a worker accepts
diff --git a/docs/design/remote-workers.md b/docs/design/remote-workers.md
index 62eca89d..5ce981dc 100644
--- a/docs/design/remote-workers.md
+++ b/docs/design/remote-workers.md
@@ -219,13 +219,24 @@ and registers no plugins.
- **Desktop app.** `src/desktop_worker.rs` has the commands behind
**Settings › Workers › Worker on this computer**. They read and save settings,
start the tray process, and pause, resume and stop it through the control port.
-- **Windows service** (`commands/worker/service.rs`): `psf-guard
- worker-service install` creates `PSFGuardWorker`, started automatically,
- as Local System, running `worker-service run --config ` from
- the installing binary. Stop and shutdown quit the worker, which leaves the
- server, while the service reports a stop pending for up to 40 seconds. A
- failed run exits nonzero, and the failure actions start it again after 30
- seconds. It serves no controls and has no tray.
+- **Windows service** (`commands/worker/service.rs`). The app installs per
+ user, in a folder its user can change, so the service never runs the
+ app's files: a user-writable binary or settings file run by a service is
+ a way past UAC. `worker-service install` copies the running binary to
+ `%ProgramFiles%\PSF Guard Worker\psf-guard-worker.exe` and the settings,
+ with `work_dir` set to the service's `tasks` folder, to
+ `%ProgramData%\PSF Guard Worker\worker.json`. `icacls` limits that folder
+ to SYSTEM, Administrators and the service's virtual account,
+ `NT SERVICE\PSFGuardWorker`, under which the service runs. Running
+ install again stops the service, updates the copies and its
+ configuration, and starts it. The service starts automatically, delayed;
+ stop and preshutdown quit the worker, which leaves the server, while the
+ service reports a stop pending with a rising checkpoint every five
+ seconds, for up to 60 seconds of preshutdown. A failed run logs its
+ reason, then exits with a service-specific code, and the failure actions
+ start it again after 30 seconds. `worker-service uninstall` stops it,
+ deletes it and removes the copies. The desktop app reads the service's
+ state and offers no tray worker while the service runs one.
- **Start worker at login** (`commands/worker/login.rs`): a LaunchAgent with
`RunAtLoad` on macOS, and a value under
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run` on Windows. Both
diff --git a/src/cli.rs b/src/cli.rs
index a8c5e58f..665f71d6 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1411,8 +1411,8 @@ pub enum Commands {
/// Run the worker as a Windows service, for a computer that runs work
/// while nobody is logged in.
///
- /// Save the worker's settings in the desktop app's Settings › Run a
- /// worker first, or write a settings file. Then, from a terminal opened
+ /// Save the worker's settings in the desktop app's Settings › Workers ›
+ /// This computer first, or write a settings file. Then, from a terminal opened
/// as administrator, run `psf-guard-cli worker-service install`. The
/// service runs as Local System and starts with Windows.
WorkerService {
diff --git a/src/cli_main.rs b/src/cli_main.rs
index fa0c06a3..4d3c8f5c 100644
--- a/src/cli_main.rs
+++ b/src/cli_main.rs
@@ -1650,7 +1650,8 @@ pub fn main() -> Result<()> {
};
service::install(&path)?;
println!(
- "Installed and started the PSF Guard Worker service with the settings in {}",
+ "The PSF Guard Worker service runs with a copy of the settings in {}. \
+ After you change them, or update PSF Guard, run this command again.",
path.display()
);
}
diff --git a/src/commands/worker/service.rs b/src/commands/worker/service.rs
index 28c833f1..b1b424f4 100644
--- a/src/commands/worker/service.rs
+++ b/src/commands/worker/service.rs
@@ -1,12 +1,17 @@
//! `psf-guard worker-service`: the worker as a Windows service, for a
-//! computer that runs work while nobody is logged in. The service runs
-//! `psf-guard worker-service run --config FILE` as Local System, with the
-//! settings the desktop app saved, and starts with Windows. It has no tray;
-//! a desktop that someone logs in to can run the tray worker instead.
+//! computer that runs work while nobody is logged in.
+//!
+//! The app installs per user, in a folder its user can change, so the
+//! service never runs the app's own files. `install` copies the binary to
+//! `%ProgramFiles%\PSF Guard Worker` and the settings to
+//! `%ProgramData%\PSF Guard Worker`, which only administrators and the
+//! service's account can reach, and runs the service as its own virtual
+//! account, `NT SERVICE\PSFGuardWorker`, rather than Local System. Running
+//! `install` again updates the copies, such as after the app is updated.
use std::path::{Path, PathBuf};
-use anyhow::Result;
+use anyhow::{Context, Result};
/// The service's name, as `sc.exe` and the Services console identify it.
#[cfg_attr(not(windows), allow(dead_code))]
@@ -15,21 +20,29 @@ const SERVICE_NAME: &str = "PSFGuardWorker";
const DISPLAY_NAME: &str = "PSF Guard Worker";
#[cfg_attr(not(windows), allow(dead_code))]
const DESCRIPTION: &str = "Runs stack builds and RC-Astro tools for a PSF Guard server.";
+/// The folder under Program Files for the binary, and under ProgramData for
+/// the settings, the task files and the logs.
+#[cfg_attr(not(windows), allow(dead_code))]
+const FOLDER: &str = "PSF Guard Worker";
-/// Install the service with the settings at `settings`, and start it.
+/// Install the service with the settings at `settings`, or update it, and
+/// start it.
pub fn install(settings: &Path) -> Result<()> {
- let loaded = super::WorkerSettings::load(settings)?.ok_or_else(|| {
+ let settings =
+ std::path::absolute(settings).with_context(|| format!("finding {}", settings.display()))?;
+ let loaded = super::WorkerSettings::load(&settings)?.ok_or_else(|| {
anyhow::anyhow!(
- "There are no worker settings at {}; save them in PSF Guard's Settings › Run a \
- worker, or give a settings file with --config",
+ "There are no worker settings at {}; save them in PSF Guard's Settings › Workers › This \
+ computer, or give a settings file with --config",
settings.display()
)
})?;
loaded.validate()?;
- platform::install(settings)
+ platform::install(loaded)
}
-/// Stop the service and remove it.
+/// Stop the service and remove it, with its copies of the binary and the
+/// settings.
pub fn uninstall() -> Result<()> {
platform::uninstall()
}
@@ -39,6 +52,30 @@ pub fn run(settings: PathBuf) -> Result<()> {
platform::run(settings)
}
+/// Whether the service is installed, and whether it runs: for the desktop
+/// app, which shows a service worker in place of its own.
+pub fn state() -> Option {
+ platform::state()
+}
+
+/// What the desktop app shows about the service.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ServiceState {
+ Running,
+ Stopped,
+ /// Starting, stopping, paused or resuming.
+ Changing,
+}
+
+/// The settings the service runs with: the saved ones, with the service's
+/// own task folder.
+#[cfg_attr(not(windows), allow(dead_code))]
+fn service_settings(mut settings: super::WorkerSettings, tasks: &Path) -> super::WorkerSettings {
+ settings.work_dir = Some(tasks.display().to_string());
+ settings
+}
+
/// The arguments the service starts the binary with.
#[cfg_attr(not(windows), allow(dead_code))]
fn launch_arguments(settings: &Path) -> Vec {
@@ -50,17 +87,37 @@ fn launch_arguments(settings: &Path) -> Vec {
]
}
+/// The `icacls` arguments that give the service's data folder to SYSTEM,
+/// Administrators and the service's account alone. SIDs, not names, for
+/// the two built-in groups, which other languages name differently.
+#[cfg_attr(not(windows), allow(dead_code))]
+fn lock_down_arguments(folder: &Path) -> Vec {
+ vec![
+ folder.as_os_str().to_owned(),
+ "/inheritance:r".into(),
+ "/grant:r".into(),
+ "*S-1-5-18:(OI)(CI)F".into(),
+ "/grant:r".into(),
+ "*S-1-5-32-544:(OI)(CI)F".into(),
+ "/grant:r".into(),
+ format!(r"NT SERVICE\{SERVICE_NAME}:(OI)(CI)M").into(),
+ "/T".into(),
+ "/Q".into(),
+ ]
+}
+
#[cfg(windows)]
mod platform {
use std::ffi::OsString;
- use std::sync::OnceLock;
+ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
+ use std::sync::{Arc, OnceLock};
use std::time::Duration;
- use anyhow::Context;
use windows_service::service::{
- ServiceAccess, ServiceAction, ServiceActionType, ServiceControl, ServiceControlAccept,
- ServiceErrorControl, ServiceExitCode, ServiceFailureActions, ServiceFailureResetPeriod,
- ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
+ Service, ServiceAccess, ServiceAction, ServiceActionType, ServiceControl,
+ ServiceControlAccept, ServiceErrorControl, ServiceExitCode, ServiceFailureActions,
+ ServiceFailureResetPeriod, ServiceInfo, ServiceStartType, ServiceState as State,
+ ServiceStatus, ServiceType,
};
use windows_service::service_control_handler::{
self, ServiceControlHandlerResult, ServiceStatusHandle,
@@ -71,41 +128,150 @@ mod platform {
use super::*;
use crate::commands::worker::{run_with, WorkerControl, WorkerLaunch};
- /// How long Windows waits for the worker to leave the server on a stop.
- const STOP_WAIT: Duration = Duration::from_secs(40);
+ /// How long the worker may take to stop: its tool stops within 30
+ /// seconds, and it then leaves the server.
+ const STOP_WAIT: Duration = Duration::from_secs(60);
/// How long after a failure Windows starts the service again.
const RESTART_AFTER: Duration = Duration::from_secs(30);
+ /// How often a stop reports that it is still going.
+ const STOP_PROGRESS: Duration = Duration::from_secs(5);
+
+ const ERROR_ACCESS_DENIED: i32 = 5;
+ const ERROR_SERVICE_DOES_NOT_EXIST: i32 = 1060;
+ const REFUSED: &str = "Windows refused; run this from PowerShell opened as administrator";
static SETTINGS: OnceLock = OnceLock::new();
define_windows_service!(ffi_service_main, service_main);
- pub fn install(settings: &Path) -> Result<()> {
- let manager = ServiceManager::local_computer(
- None::<&str>,
- ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
- )
- .context("Windows refused to install the service; run this from a terminal opened as administrator")?;
+ /// Where the service's binary goes: only administrators can change it.
+ fn binary() -> Result {
+ let folder = std::env::var_os("ProgramFiles")
+ .map(PathBuf::from)
+ .context("Windows did not give the Program Files folder")?;
+ Ok(folder.join(FOLDER).join("psf-guard-worker.exe"))
+ }
+
+ /// The service's data folder: its settings, task files and logs.
+ fn data() -> PathBuf {
+ std::env::var_os("ProgramData")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
+ .join(FOLDER)
+ }
+
+ fn os_error(error: &windows_service::Error) -> Option {
+ match error {
+ windows_service::Error::Winapi(error) => error.raw_os_error(),
+ _ => None,
+ }
+ }
+
+ fn manager(access: ServiceManagerAccess) -> Result {
+ ServiceManager::local_computer(None::<&str>, access).map_err(|error| {
+ if os_error(&error) == Some(ERROR_ACCESS_DENIED) {
+ anyhow::anyhow!(REFUSED)
+ } else {
+ anyhow::Error::new(error).context("opening the Windows services")
+ }
+ })
+ }
+
+ /// The installed service, or `None` when there is none.
+ fn open(manager: &ServiceManager, access: ServiceAccess) -> Result
-
{status ? statusText(status) : 'Not running.'}
+
+ {status
+ ? statusText(status)
+ : serviceRuns
+ ? 'The PSF Guard Worker service runs a worker on this computer.'
+ : 'Not running.'}
+
{status?.tasks.map((task) => (
{task.label}: {Math.round(task.fraction * 100)}%
))}
+ {serviceRuns && !status && (
+
+ To change its settings, save them here, then run{' '}
+ psf-guard-cli worker-service install in PowerShell as
+ administrator.
+
Save a server address and a worker token before starting the worker.
)}
{!status && loaded.settings && loaded.has_token && dirty && (
diff --git a/static/src/components/__tests__/DesktopWorkerSettings.test.tsx b/static/src/components/__tests__/DesktopWorkerSettings.test.tsx
index d5274734..4585bd7f 100644
--- a/static/src/components/__tests__/DesktopWorkerSettings.test.tsx
+++ b/static/src/components/__tests__/DesktopWorkerSettings.test.tsx
@@ -22,6 +22,7 @@ beforeEach(() => {
has_token: false,
start_at_login: false,
status: null,
+ service: null,
settings_path: '/home/ann/.config/psf-guard/worker.json',
};
native.get.mockImplementation(async () => worker);
@@ -121,6 +122,21 @@ describe('this computer\'s worker', () => {
expect(native.save.mock.calls[0][0]).toMatchObject({ token: null, work: ['stack'], slots: 2, name: 'studio-pc' });
});
+ it('offers no second worker while the Windows service runs one', async () => {
+ worker = {
+ ...worker,
+ has_token: true,
+ service: 'running',
+ settings: {
+ server: 'http://observatory:3000', name: null, files: 'remote', maps: [],
+ work_dir: null, rc_astro: null, work: [], slots: 1,
+ },
+ };
+ mount();
+ expect(await screen.findByText(/The PSF Guard Worker service runs a worker/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled();
+ });
+
it('points Linux at the systemd service', async () => {
worker = { ...worker, supported: false };
mount();
diff --git a/static/src/utils/tauri.ts b/static/src/utils/tauri.ts
index 23544af8..af277b82 100644
--- a/static/src/utils/tauri.ts
+++ b/static/src/utils/tauri.ts
@@ -224,6 +224,8 @@ export interface DesktopWorker {
start_at_login: boolean;
/** `null` while the worker is not running. */
status: DesktopWorkerStatus | null;
+ /** The Windows service's state, when the service is installed. */
+ service: 'running' | 'stopped' | 'changing' | null;
settings_path: string;
}
From 469e2e5d2566b06383f2947164a523ffdc9a612c Mon Sep 17 00:00:00 2001
From: Yann Ramin
Date: Sun, 25 Oct 2026 20:58:38 -0700
Subject: [PATCH 3/6] Clarify Windows worker service status, updates and file
removal
---
docs/REMOTE_WORKERS.md | 105 +++++++++++-------
docs/design/remote-workers.md | 7 +-
docs/releases/unreleased.md | 11 +-
src/cli.rs | 3 +-
src/commands/worker/service.rs | 16 +--
static/e2e/desktop-worker-settings.spec.ts | 29 +++++
.../src/components/DesktopWorkerSettings.tsx | 26 +++--
.../__tests__/DesktopWorkerSettings.test.tsx | 11 +-
8 files changed, 136 insertions(+), 72 deletions(-)
diff --git a/docs/REMOTE_WORKERS.md b/docs/REMOTE_WORKERS.md
index 59573ceb..4cf5d625 100644
--- a/docs/REMOTE_WORKERS.md
+++ b/docs/REMOTE_WORKERS.md
@@ -164,55 +164,74 @@ that contains one.
## Run a worker as a Windows service
-A Windows PC that runs work while nobody is logged in can run the worker as
-a service instead of in the tray. The service starts with Windows.
+Use a Windows service when the worker needs to run before anyone logs in.
+The service starts with Windows. It uses its own copies of the app and
+settings, separate from the desktop worker.
-1. Save the worker's settings under **Settings › Workers › This
- computer**, as for the tray. Clear **Start at login**, and stop the tray
- worker, so that only one worker runs on the computer.
-2. Open PowerShell as administrator, and run:
+1. Open **Settings › Workers › Worker on this computer** in the desktop
+ app and enter the worker settings. Turn off **Start worker at login**,
+ then click **Save worker settings**. If the desktop worker is running,
+ click **Stop worker** before installing the service.
+2. Open PowerShell as administrator and run:
```powershell
psf-guard-cli worker-service install
```
- To use another settings file, add `--config FILE`.
-
-The install copies PSF Guard to `C:\Program Files\PSF Guard Worker`, and
-the settings to `C:\ProgramData\PSF Guard Worker`, where only
-administrators and the service can read them; they contain the token. The
-service runs as its own account, `NT SERVICE\PSFGuardWorker`, and keeps its
-task files in that folder's `tasks` folder, whatever work folder the
-settings give.
-
-- **Change the settings, or update PSF Guard.** The service keeps using its
- copies. Save the settings, or install the update, then run
- `psf-guard-cli worker-service install` again; it stops the service,
- updates the copies and starts it. The page's **Restart** button does not
- restart the service.
-- **Logs.** The service writes its log to files starting `worker` in
- `C:\ProgramData\PSF Guard Worker\logs`.
-- **Failures.** When the server refuses the token, the service stops, and
- Windows starts it again after 30 seconds.
-- **Stopping.** Stopping the service, or shutting Windows down, lets the
- worker leave the server, which queues its running task again.
-- **Sleep.** Windows may sleep while nobody is logged in. Set **Sleep** to
- **Never** in the power settings of a computer that runs work overnight.
-- **RC-Astro tools.** The service runs the rc-astro on the computer's
- `PATH`. rc-astro installed or licensed for one user may not run for the
- service; then the service takes stack builds only. Run the tray worker on
- such a computer instead.
-- **Shared storage.** The service's account can use network shares only
- through the computer's own network identity. To read and write shared
- task files, change the service's account in the Services console to a
- user who can use the shares, give that user modify access to
- `C:\ProgramData\PSF Guard Worker`, and enter each share in the shared
- folders as a UNC path, such as `\\nas\astro`.
-
-To remove the service, run `psf-guard-cli worker-service uninstall` from
-PowerShell opened as administrator. The worker stops and the server queues
-its running task again; the copies, with the token, are deleted. Remove the
-service before you uninstall PSF Guard.
+ To use a different settings file, add `--config FILE`.
+
+The command installs and starts the **PSF Guard Worker** service. It copies
+the executable to `%ProgramFiles%\PSF Guard Worker` and settings to
+`%ProgramData%\PSF Guard Worker\worker.json`. SYSTEM, Administrators and
+the service account can access the service's data folder. The settings
+include the worker token.
+
+The service runs under its own account, `NT SERVICE\PSFGuardWorker`.
+Task files use `%ProgramData%\PSF Guard Worker\tasks`; the desktop app's
+**Temporary task folder** setting does not change the service's task folder.
+
+### Apply settings or app updates to the service
+
+After changing worker settings, click **Save worker settings**. After
+saving settings or updating PSF Guard, open PowerShell as administrator and
+run `psf-guard-cli worker-service install` again. The command stops the
+service, updates its copies of the app and settings, and restarts it.
+
+**Restart worker** controls the desktop worker. It does not restart or
+update the Windows service. While the service is running or changing state,
+**Start worker** is disabled to prevent a second worker from starting.
+
+### Check service logs and requirements
+
+- **Logs:** filenames start with `worker` in
+ `%ProgramData%\PSF Guard Worker\logs`.
+- **Failures:** if the server refuses the token, the service logs the reason
+ and stops. Windows restarts it after 30 seconds. Replace a refused token
+ in the desktop settings and run the install command again.
+- **Stopping:** stop the service in Windows Services. The worker disconnects
+ and the server queues unfinished tasks again. Windows shutdown also gives
+ the worker time to disconnect.
+- **Sleep:** prevent the computer from sleeping while you need it to process
+ tasks. A worker cannot process tasks while Windows is asleep.
+- **RC-Astro tools:** the service uses rc-astro from its account's `PATH`,
+ or the executable specified in its settings. An installation or license
+ available only to your desktop account may be unavailable to the service.
+ If the service cannot run RC-Astro, it can still accept stack builds.
+ Use the desktop worker if RC-Astro requires your desktop account.
+- **Shared storage:** the default service account accesses network shares
+ through the computer's network identity. If a share requires a user
+ account, change the service account in Windows Services to a user with
+ access to that share. Give that user modify access to
+ `%ProgramData%\PSF Guard Worker`. Enter folder mappings with UNC paths,
+ such as `\nas\astro`, that the service account can access.
+
+### Remove the Windows service
+
+Before uninstalling PSF Guard, open PowerShell as administrator and run
+`psf-guard-cli worker-service uninstall`. This stops and removes the service
+and deletes its copied executable, settings including the token, task files,
+and logs. The server queues unfinished tasks again. The desktop app's
+saved worker settings remain in its own settings folder.
## Choose the work a worker accepts
diff --git a/docs/design/remote-workers.md b/docs/design/remote-workers.md
index 5ce981dc..90cdb2d1 100644
--- a/docs/design/remote-workers.md
+++ b/docs/design/remote-workers.md
@@ -235,8 +235,11 @@ and registers no plugins.
seconds, for up to 60 seconds of preshutdown. A failed run logs its
reason, then exits with a service-specific code, and the failure actions
start it again after 30 seconds. `worker-service uninstall` stops it,
- deletes it and removes the copies. The desktop app reads the service's
- state and offers no tray worker while the service runs one.
+ deletes the service registration, copied executable, settings, task files
+ and logs. The desktop app disables **Start worker** while the service is
+ running or in another state that is not stopped. It shows a running
+ service separately from a service that has not stopped; it does not
+ report a connection to the processing server from the Windows service state.
- **Start worker at login** (`commands/worker/login.rs`): a LaunchAgent with
`RunAtLoad` on macOS, and a value under
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run` on Windows. Both
diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md
index eb43fdc7..4f91bed3 100644
--- a/docs/releases/unreleased.md
+++ b/docs/releases/unreleased.md
@@ -38,11 +38,12 @@
you log in. Opening PSF Guard while its window is open brings that window
forward instead of starting a second copy.
-- Run a worker as a Windows service, for a PC that runs work while nobody is
- logged in. Save the worker's settings under **Settings › Workers › This
- computer**, then run `psf-guard-cli worker-service install` in PowerShell
- as administrator. Run it again after you change the settings or update PSF
- Guard.
+- Run a worker as a Windows service to process tasks before anyone logs in.
+ Open **Settings › Workers › Worker on this computer**, click **Save worker
+ settings**, then run `psf-guard-cli worker-service install` in PowerShell
+ opened as administrator. Run the command again after changing settings or
+ updating PSF Guard to update and restart the service. Desktop worker
+ controls do not control the service.
- Run a worker as a Linux service. The Fedora RPM and the Debian package
install a `psf-guard-worker` systemd service, not enabled. Enter the
diff --git a/src/cli.rs b/src/cli.rs
index 665f71d6..48d74195 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -24,7 +24,8 @@ pub enum WorkerServiceAction {
#[arg(long, value_name = "FILE")]
config: Option,
},
- /// Stop the service and remove it.
+ /// Stop and remove the service. Delete its copied executable, settings,
+ /// task files and logs.
Uninstall,
/// Run as the service. Windows starts this; it is not for a terminal.
#[command(hide = true)]
diff --git a/src/commands/worker/service.rs b/src/commands/worker/service.rs
index b1b424f4..ce31048b 100644
--- a/src/commands/worker/service.rs
+++ b/src/commands/worker/service.rs
@@ -32,8 +32,8 @@ pub fn install(settings: &Path) -> Result<()> {
std::path::absolute(settings).with_context(|| format!("finding {}", settings.display()))?;
let loaded = super::WorkerSettings::load(&settings)?.ok_or_else(|| {
anyhow::anyhow!(
- "There are no worker settings at {}; save them in PSF Guard's Settings › Workers › This \
- computer, or give a settings file with --config",
+ "No worker settings file was found at {}. Save worker settings in PSF Guard's \
+ Settings › Workers › Worker on this computer, or choose a file with --config FILE",
settings.display()
)
})?;
@@ -138,7 +138,7 @@ mod platform {
const ERROR_ACCESS_DENIED: i32 = 5;
const ERROR_SERVICE_DOES_NOT_EXIST: i32 = 1060;
- const REFUSED: &str = "Windows refused; run this from PowerShell opened as administrator";
+ const REFUSED: &str = "Administrator access is required to manage the PSF Guard Worker service. Open PowerShell as administrator and run the command again.";
static SETTINGS: OnceLock = OnceLock::new();
@@ -148,7 +148,7 @@ mod platform {
fn binary() -> Result {
let folder = std::env::var_os("ProgramFiles")
.map(PathBuf::from)
- .context("Windows did not give the Program Files folder")?;
+ .context("Windows did not report the Program Files folder")?;
Ok(folder.join(FOLDER).join("psf-guard-worker.exe"))
}
@@ -265,7 +265,7 @@ mod platform {
.context("running icacls")?;
anyhow::ensure!(
locked.success(),
- "icacls could not limit {} to administrators and the service ({locked})",
+ "Could not set permissions on {} to allow only SYSTEM, Administrators and the PSF Guard Worker service account ({locked})",
data.display()
);
service.set_description(DESCRIPTION)?;
@@ -450,8 +450,8 @@ mod platform {
mod platform {
use super::*;
- const ELSEWHERE: &str = "The worker runs as a service this way only on Windows. On Linux, use \
- the psf-guard-worker systemd service; on a Mac, open PSF Guard's Settings › Workers";
+ const ELSEWHERE: &str = "This command is available only on Windows. On Linux, use the psf-guard-worker \
+ systemd service. On macOS, open PSF Guard and choose Settings › Workers › Worker on this computer.";
pub fn install(_settings: crate::commands::worker::WorkerSettings) -> Result<()> {
anyhow::bail!(ELSEWHERE)
@@ -514,7 +514,7 @@ mod tests {
assert!(install(&missing)
.unwrap_err()
.to_string()
- .contains("Settings › Workers › This computer"));
+ .contains("Settings › Workers › Worker on this computer"));
std::fs::write(
&missing,
r#"{"server": "observatory:3000", "token": "psfw_secret"}"#,
diff --git a/static/e2e/desktop-worker-settings.spec.ts b/static/e2e/desktop-worker-settings.spec.ts
index afe84df9..66d2aa58 100644
--- a/static/e2e/desktop-worker-settings.spec.ts
+++ b/static/e2e/desktop-worker-settings.spec.ts
@@ -23,6 +23,7 @@ test.beforeEach(async ({ page, request }) => {
};
const calls: Array<{ command: string; args: unknown }> = [];
(window as unknown as { __desktopWorkerCalls: typeof calls }).__desktopWorkerCalls = calls;
+ (window as unknown as { __desktopWorkerState: Worker }).__desktopWorkerState = worker;
const origin = window.location.origin;
const commands: Record) => unknown> = {
get_server_url: () => origin,
@@ -122,3 +123,31 @@ test('this computer saves its worker settings, then starts and stops the worker'
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
await page.screenshot({ path: testInfo.outputPath('worker-on-this-computer-phone.png') });
});
+
+for (const service of ['running', 'changing'] as const) {
+ test(`Windows service ${service} explains how to apply saved settings`, async ({ page }, testInfo) => {
+ await page.setViewportSize({ width: 1280, height: 1400 });
+ await page.goto('/');
+ await page.evaluate((state) => {
+ const worker = (window as unknown as { __desktopWorkerState: Record }).__desktopWorkerState;
+ worker.service = state;
+ worker.has_token = true;
+ worker.settings = {
+ server: 'http://observatory.local:3000', name: 'studio-pc', files: 'remote', maps: [],
+ work_dir: null, rc_astro: null, work: [], slots: 1,
+ };
+ }, service);
+ await openSettings(page);
+ await page.getByRole('tab', { name: 'Workers' }).click();
+ const computer = page.getByRole('region', { name: 'Worker on this computer' });
+ const status = computer.getByRole('group', { name: 'Worker status' });
+ await expect(status).toContainText(service === 'running'
+ ? 'The PSF Guard Worker Windows service is running.'
+ : 'The PSF Guard Worker Windows service has not stopped.');
+ await expect(status.getByRole('button', { name: 'Start worker' })).toBeDisabled();
+ await expect(status).toContainText('Save worker settings');
+ await expect(status).toContainText('psf-guard-cli worker-service install');
+ await expect(status).not.toContainText('Connected to');
+ await status.screenshot({ path: testInfo.outputPath(`windows-service-${service}.png`) });
+ });
+}
diff --git a/static/src/components/DesktopWorkerSettings.tsx b/static/src/components/DesktopWorkerSettings.tsx
index 7ef4c559..3a49c550 100644
--- a/static/src/components/DesktopWorkerSettings.tsx
+++ b/static/src/components/DesktopWorkerSettings.tsx
@@ -182,9 +182,11 @@ export default function DesktopWorkerSettings() {
{status
? statusText(status)
- : serviceRuns
- ? 'The PSF Guard Worker service runs a worker on this computer.'
- : 'Not running.'}
+ : loaded.service === 'running'
+ ? 'The PSF Guard Worker Windows service is running.'
+ : serviceRuns
+ ? 'The PSF Guard Worker Windows service has not stopped. Stop it in Windows Services to use the desktop worker.'
+ : 'Not running.'}
- To change its settings, save them here, then run{' '}
- psf-guard-cli worker-service install in PowerShell as
- administrator.
+ To update the service, click Save worker settings, then run{' '}
+ psf-guard-cli worker-service install in PowerShell opened
+ as administrator. This command updates the service's copies of
+ the app and settings, then restarts the service. The desktop worker
+ controls do not control the Windows service.
Pause lets running tasks finish. A task already requested from the server
may still start. Stop returns unfinished tasks to the server's queue.
-
+ }
Leave blank to use the system temporary folder. The worker stores task files here while tasks run.
+ {loaded.service != null &&
+ The Windows service uses %ProgramData%\PSF Guard Worker\tasks instead.
+ Temporary task folder applies to the desktop worker.
+
}
diff --git a/static/src/components/__tests__/DesktopWorkerSettings.test.tsx b/static/src/components/__tests__/DesktopWorkerSettings.test.tsx
index 4585bd7f..2e4f3752 100644
--- a/static/src/components/__tests__/DesktopWorkerSettings.test.tsx
+++ b/static/src/components/__tests__/DesktopWorkerSettings.test.tsx
@@ -122,19 +122,22 @@ describe('this computer\'s worker', () => {
expect(native.save.mock.calls[0][0]).toMatchObject({ token: null, work: ['stack'], slots: 2, name: 'studio-pc' });
});
- it('offers no second worker while the Windows service runs one', async () => {
+ it.each(['running', 'changing'] as const)('prevents a desktop worker while the Windows service is %s', async (service) => {
worker = {
...worker,
has_token: true,
- service: 'running',
+ service,
settings: {
server: 'http://observatory:3000', name: null, files: 'remote', maps: [],
work_dir: null, rc_astro: null, work: [], slots: 1,
},
};
mount();
- expect(await screen.findByText(/The PSF Guard Worker service runs a worker/)).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled();
+ expect(await screen.findByText(service === 'running'
+ ? 'The PSF Guard Worker Windows service is running.'
+ : 'The PSF Guard Worker Windows service has not stopped. Stop it in Windows Services to use the desktop worker.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Start worker' })).toBeDisabled();
+ expect(screen.getByText(/updates the service's copies/)).toBeInTheDocument();
});
it('points Linux at the systemd service', async () => {
From a4da36ccb95a136bf9c8eeaab16dcf970928fc4f Mon Sep 17 00:00:00 2001
From: Yann Ramin
Date: Sun, 25 Oct 2026 20:47:23 -0700
Subject: [PATCH 4/6] Fix Windows worker service permissions and reinstall
shutdown
---
docs/REMOTE_WORKERS.md | 4 +
docs/releases/unreleased.md | 5 +
src/commands/worker/service.rs | 60 ++++------
src/commands/worker/service/permissions.rs | 112 ++++++++++++++++++
.../worker/service/permissions.tests.ps1 | 22 ++++
src/commands/worker/service/process.rs | 70 +++++++++++
6 files changed, 234 insertions(+), 39 deletions(-)
create mode 100644 src/commands/worker/service/permissions.rs
create mode 100644 src/commands/worker/service/permissions.tests.ps1
create mode 100644 src/commands/worker/service/process.rs
diff --git a/docs/REMOTE_WORKERS.md b/docs/REMOTE_WORKERS.md
index 4cf5d625..a13feeec 100644
--- a/docs/REMOTE_WORKERS.md
+++ b/docs/REMOTE_WORKERS.md
@@ -205,6 +205,10 @@ update the Windows service. While the service is running or changing state,
- **Logs:** filenames start with `worker` in
`%ProgramData%\PSF Guard Worker\logs`.
+- **Access denied:** if the service cannot read `worker.json`, update PSF Guard
+ and run `psf-guard-cli worker-service install` from PowerShell opened as
+ administrator. This repairs the service folder's permissions and starts
+ the worker.
- **Failures:** if the server refuses the token, the service logs the reason
and stops. Windows restarts it after 30 seconds. Replace a refused token
in the desktop settings and run the install command again.
diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md
index 4f91bed3..d89634df 100644
--- a/docs/releases/unreleased.md
+++ b/docs/releases/unreleased.md
@@ -19,6 +19,11 @@
- FITS downloads in stack processing use compact controls with a download icon
and explicit labels. Long labels wrap to fit narrow panels.
+- Windows worker services can now read their saved settings and start
+ successfully. Reinstalling the service also repairs permissions left by an
+ earlier installation and waits for the stopped worker process before
+ replacing its executable.
+
- A fresh flat that contributes to a light's master now provides coverage even
when older flats in the same master move the median capture time beyond
the **Flat age limit**. Age checks and the calibration report use the nearest
diff --git a/src/commands/worker/service.rs b/src/commands/worker/service.rs
index ce31048b..229af35f 100644
--- a/src/commands/worker/service.rs
+++ b/src/commands/worker/service.rs
@@ -13,6 +13,11 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
+#[cfg(windows)]
+mod permissions;
+#[cfg(windows)]
+mod process;
+
/// The service's name, as `sc.exe` and the Services console identify it.
#[cfg_attr(not(windows), allow(dead_code))]
const SERVICE_NAME: &str = "PSFGuardWorker";
@@ -87,25 +92,6 @@ fn launch_arguments(settings: &Path) -> Vec {
]
}
-/// The `icacls` arguments that give the service's data folder to SYSTEM,
-/// Administrators and the service's account alone. SIDs, not names, for
-/// the two built-in groups, which other languages name differently.
-#[cfg_attr(not(windows), allow(dead_code))]
-fn lock_down_arguments(folder: &Path) -> Vec {
- vec![
- folder.as_os_str().to_owned(),
- "/inheritance:r".into(),
- "/grant:r".into(),
- "*S-1-5-18:(OI)(CI)F".into(),
- "/grant:r".into(),
- "*S-1-5-32-544:(OI)(CI)F".into(),
- "/grant:r".into(),
- format!(r"NT SERVICE\{SERVICE_NAME}:(OI)(CI)M").into(),
- "/T".into(),
- "/Q".into(),
- ]
-}
-
#[cfg(windows)]
mod platform {
use std::ffi::OsString;
@@ -191,13 +177,21 @@ mod platform {
}
}
- /// Stop the service and wait until it has stopped.
+ /// Stop the service and wait until its process has released the binary.
fn stop_and_wait(service: &Service) -> Result<()> {
- let state = service.query_status()?.current_state;
- if state == State::Stopped {
+ let status = service.query_status()?;
+ if status.current_state == State::Stopped {
return Ok(());
}
- if state != State::StopPending {
+ // Keep a process handle before requesting Stop: the SCM no longer
+ // reports a PID once the worker publishes its Stopped status, even
+ // though the process can still be releasing its executable and logs.
+ let process = status
+ .process_id
+ .map(process::Process::open)
+ .transpose()?
+ .flatten();
+ if status.current_state != State::StopPending {
service.stop()?;
}
let deadline = std::time::Instant::now() + STOP_WAIT + Duration::from_secs(10);
@@ -208,6 +202,9 @@ mod platform {
);
std::thread::sleep(Duration::from_millis(250));
}
+ if let Some(process) = process {
+ process.wait(deadline.saturating_duration_since(std::time::Instant::now()))?;
+ }
Ok(())
}
@@ -259,15 +256,7 @@ mod platform {
.context("The service could not be created")?,
};
// Only now does the service's account exist to be granted access.
- let locked = std::process::Command::new("icacls")
- .args(lock_down_arguments(&data))
- .status()
- .context("running icacls")?;
- anyhow::ensure!(
- locked.success(),
- "Could not set permissions on {} to allow only SYSTEM, Administrators and the PSF Guard Worker service account ({locked})",
- data.display()
- );
+ permissions::lock_down(&data, &format!(r"NT SERVICE\{SERVICE_NAME}"))?;
service.set_description(DESCRIPTION)?;
service.set_delayed_auto_start(true)?;
// A restart or a shutdown gives the worker time to leave the server.
@@ -498,13 +487,6 @@ mod tests {
service_settings(saved, tasks).work_dir.as_deref(),
Some(r"C:\ProgramData\PSF Guard Worker\tasks")
);
- let locked = lock_down_arguments(Path::new(r"C:\ProgramData\PSF Guard Worker"))
- .iter()
- .map(|argument| argument.to_string_lossy().into_owned())
- .collect::>();
- assert!(locked.contains(&"/inheritance:r".to_string()));
- assert!(locked.contains(&r"NT SERVICE\PSFGuardWorker:(OI)(CI)M".to_string()));
- assert!(!locked.iter().any(|argument| argument.contains("Users")));
}
#[test]
diff --git a/src/commands/worker/service/permissions.rs b/src/commands/worker/service/permissions.rs
new file mode 100644
index 00000000..78f8d2b8
--- /dev/null
+++ b/src/commands/worker/service/permissions.rs
@@ -0,0 +1,112 @@
+//! Restrict the service's data folder, then let its contents inherit that ACL.
+
+use std::path::Path;
+use std::process::Command;
+
+use anyhow::{Context, Result};
+
+pub(super) fn lock_down(folder: &Path, account: &str) -> Result<()> {
+ // Do not recurse here: /inheritance:r on each child removes the ACEs
+ // just inherited from this folder and can leave worker.json unreadable.
+ let locked = Command::new("icacls")
+ .arg(folder)
+ .args([
+ "/inheritance:r",
+ "/grant:r",
+ "*S-1-5-18:(OI)(CI)F",
+ "*S-1-5-32-544:(OI)(CI)F",
+ &format!("{account}:(OI)(CI)M"),
+ "/Q",
+ ])
+ .status()
+ .context("restricting the worker service data folder with icacls")?;
+ anyhow::ensure!(
+ locked.success(),
+ "icacls could not limit {} to administrators and the service ({locked})",
+ folder.display()
+ );
+
+ // Reset only the contents, never the protected root. This also repairs
+ // children whose inheritance an earlier installation disabled, and gives
+ // a replacement settings file the same permissions on reinstall.
+ let inherited = Command::new("icacls")
+ .arg(folder.join("*"))
+ .args(["/reset", "/T", "/Q"])
+ .status()
+ .context("restoring worker service file inheritance with icacls")?;
+ anyhow::ensure!(
+ inherited.success(),
+ "icacls could not restore permissions inside {} ({inherited})",
+ folder.display()
+ );
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+
+ fn powershell(script: &str, folder: &Path) -> std::process::Output {
+ let result = Command::new("powershell.exe")
+ .args(["-NoProfile", "-NonInteractive", "-Command", script])
+ // A parent PowerShell 7 may export module paths incompatible
+ // with the Windows PowerShell used by this test.
+ .env_remove("PSModulePath")
+ .env("PSF_GUARD_ACL_TEST_FOLDER", folder)
+ .output()
+ .unwrap();
+ assert!(
+ result.status.success(),
+ "{}\n{}",
+ String::from_utf8_lossy(&result.stdout),
+ String::from_utf8_lossy(&result.stderr)
+ );
+ result
+ }
+
+ #[test]
+ fn settings_and_task_files_inherit_permissions_on_install_and_reinstall() {
+ let temp = tempfile::tempdir().unwrap();
+ let folder = temp.path().join("Worker data with spaces");
+ std::fs::create_dir_all(folder.join("tasks/nested")).unwrap();
+ std::fs::write(folder.join("worker.json"), "test token").unwrap();
+ std::fs::write(folder.join("tasks/nested/output.fits"), "test output").unwrap();
+ let user = powershell(
+ "[Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
+ &folder,
+ );
+ // Substitute the test user's SID for the service account. No service
+ // installation or administrator privileges are needed in Windows CI.
+ let account = format!("*{}", String::from_utf8(user.stdout).unwrap().trim());
+ lock_down(&folder, &account).unwrap();
+ powershell(include_str!("permissions.tests.ps1"), &folder);
+
+ // Reproduce the old installer's empty protected child ACL, including
+ // a nested task file, before checking that reinstall repairs it.
+ powershell(
+ r#"
+$ErrorActionPreference = 'Stop'
+$root = $env:PSF_GUARD_ACL_TEST_FOLDER
+foreach ($name in @('worker.json', 'tasks/nested/output.fits')) {
+ $path = Join-Path $root $name
+ $acl = Get-Acl -LiteralPath $path
+ $acl.SetAccessRuleProtection($true, $false)
+ Set-Acl -LiteralPath $path -AclObject $acl
+ if ((Get-Acl -LiteralPath $path).Access.Count -ne 0) { throw 'Expected an empty ACL' }
+}
+"#,
+ &folder,
+ );
+ lock_down(&folder, &account).unwrap();
+ powershell(include_str!("permissions.tests.ps1"), &folder);
+
+ // Atomic settings replacement and newly created task files must
+ // remain accessible without applying a separate file-specific ACL.
+ let mut replacement = tempfile::NamedTempFile::new_in(&folder).unwrap();
+ replacement.write_all(b"test token").unwrap();
+ replacement.persist(folder.join("worker.json")).unwrap();
+ std::fs::write(folder.join("tasks/new.fits"), "new output").unwrap();
+ powershell(include_str!("permissions.tests.ps1"), &folder);
+ }
+}
diff --git a/src/commands/worker/service/permissions.tests.ps1 b/src/commands/worker/service/permissions.tests.ps1
new file mode 100644
index 00000000..0de91d00
--- /dev/null
+++ b/src/commands/worker/service/permissions.tests.ps1
@@ -0,0 +1,22 @@
+$ErrorActionPreference = 'Stop'
+$root = $env:PSF_GUARD_ACL_TEST_FOLDER
+$user = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
+$expected = @('S-1-5-18', 'S-1-5-32-544', $user) | Sort-Object
+$paths = @((Get-Item -LiteralPath $root)) + @(Get-ChildItem -LiteralPath $root -Recurse -Force)
+foreach ($item in $paths) {
+ $acl = Get-Acl -LiteralPath $item.FullName
+ $isRoot = $item.FullName -eq $root
+ if ($acl.AreAccessRulesProtected -ne $isRoot) { throw "Wrong inheritance: $($item.FullName)" }
+ $actual = @($acl.Access | ForEach-Object { $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value }) | Sort-Object
+ if (Compare-Object $expected $actual) { throw "Unexpected access list: $($item.FullName)" }
+ foreach ($ace in $acl.Access) {
+ if ($ace.AccessControlType -ne 'Allow' -or $ace.IsInherited -eq $isRoot) { throw "Wrong ACE: $($item.FullName)" }
+ $sid = $ace.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value
+ $rights = if ($sid -eq $user) { [Security.AccessControl.FileSystemRights]::Modify } else { [Security.AccessControl.FileSystemRights]::FullControl }
+ if (($ace.FileSystemRights -band $rights) -ne $rights) { throw "Missing rights for $sid" }
+ }
+}
+if ((Get-Content -LiteralPath (Join-Path $root 'worker.json') -Raw).Trim() -ne 'test token') { throw 'Cannot read the settings' }
+$probe = Join-Path $root 'tasks/write-probe'
+[IO.File]::WriteAllText($probe, 'writable')
+Remove-Item -LiteralPath $probe
diff --git a/src/commands/worker/service/process.rs b/src/commands/worker/service/process.rs
new file mode 100644
index 00000000..3b329a98
--- /dev/null
+++ b/src/commands/worker/service/process.rs
@@ -0,0 +1,70 @@
+//! Wait for a stopped service's process before replacing its executable.
+
+use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
+use std::time::Duration;
+
+use anyhow::{Context, Result};
+use windows_sys::Win32::Foundation::{ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT};
+use windows_sys::Win32::System::Threading::{
+ OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE,
+};
+
+pub(super) struct Process(OwnedHandle);
+
+impl Process {
+ pub(super) fn open(pid: u32) -> Result