fix(windows): reliable service auto-start on boot and start-on-boot GUI entry (#2579)

* fix(service): restart windows service indefinitely on failure

The windows service was installed with only the AutoStart start type
and no SCM failure actions configured. When the service failed during
boot (e.g. network not yet ready, config load error), it reported
SERVICE_STOPPED with a non-zero exit code and stayed stopped until
started manually, which is reported as no auto-start after reboot in
issue #1771.

Configure failure actions on install and update when restart is not
disabled:

- three Restart actions (1s/5s/10s delay) with reset period Never;
  the SCM repeats the last action once the failure count exceeds the
  actions array, so restarts are retried indefinitely
- set fFailureActionsOnNonCrashFailures so exits that report
  SERVICE_STOPPED with a non-zero exit code (the error path in
  win_service_event_loop) are also treated as failures; manual stops
  still exit with 0 and do not trigger a restart

Also exit the service process with a non-zero code after reporting the
error status. Without it the process stayed alive after reporting
SERVICE_STOPPED and was only counted as failed after the SCM
force-killed it, adding roughly 30s of dead time to every retry cycle.

This matches the systemd path, which already generates Restart=always
with StartLimitIntervalSec=0. The --disable-restart-on-failure option
now also clears previously configured failure actions on windows.

Verified on a real windows host: crash failures and reported-error
failures both restart with the configured 1s/5s/10s cadence
indefinitely, manual stops are not restarted, and
--disable-restart-on-failure clears the actions.

* feat(gui): add start-on-boot menu entry pointing to service mode

Issue #1771 reports that users cannot find how to make EasyTier start
on boot. Auto-start is provided by service mode, but the GUI offered
no entry named after it, so the connection was hard to discover.

Add a "Start on Boot" item to the settings menu. It opens the mode
dialog with service mode preselected and shows an info message
explaining that enabling service mode registers EasyTier as a system
service that starts automatically at boot and keeps running in the
background.

When the dialog is opened with service mode preselected, the mode
watcher in ModeSwitcher can run before the default config/log dirs
have been resolved, leaving the fields empty and failing validation on
save. Fill them from the resolved defaults after mount in that case.

Add mode.autostart / mode.autostart_hint strings to the cn/en locales
in frontend-lib.

* fix(cli): stop collecting the --core-args flag into the service args

InstallArgs.core_args was declared without an explicit `long`, so
clap treated it as a trailing positional argument instead of a named
option. Passing `service install --core-args --daemon ...` therefore
collected the literal "--core-args" token into the value, and the
installed service was registered with an invalid command line that
failed on every start (observed on a real windows host: the binPath
contained `easytier-core.exe --core-args --daemon ...`).

Declare it as a real option (`long` + `num_args = 1..`) while keeping
allow_hyphen_values and the trailing semantics: --core-args must be
the last option of install and consumes everything after it.

The bare-positional spelling (`service install --daemon`), the only
correctly-working form before, now fails with a clear "unexpected
argument" error; scripts written against it need to add the
--core-args prefix.

Add unit tests covering the flag, `=` and mixed forms.
This commit is contained in:
KKRainbow
2026-09-16 23:02:40 +08:00
committed by GitHub
parent 7223677264
commit 7853ec8685
7 changed files with 155 additions and 5 deletions
@@ -26,6 +26,14 @@ function normalizeRpcListenPort(port: unknown): number {
onMounted(async () => {
defaultConfigDir.value = await join(await appConfigDir(), 'config.d')
defaultLogDir.value = await appLogDir()
// the mode watch may have run before these defaults resolved (e.g. when the
// dialog is opened with service mode preselected), leaving the fields empty.
if (model.value.mode === 'service') {
const serviceModel = model.value as ServiceMode
serviceModel.config_dir = serviceModel.config_dir || defaultConfigDir.value
serviceModel.file_log_dir = serviceModel.file_log_dir || defaultLogDir.value
}
})
const modeOptions = computed(() => [
+19
View File
@@ -36,8 +36,18 @@ const manualDisconnect = ref(false)
const configServerDialogVisible = ref(false)
const configServerConnected = ref(false)
const showAutostartHint = ref(false)
async function openModeDialog() {
editingMode.value = JSON.parse(JSON.stringify(loadMode()))
showAutostartHint.value = false
modeDialogVisible.value = true
}
async function openAutostartDialog() {
editingMode.value = JSON.parse(JSON.stringify(loadMode()))
editingMode.value.mode = 'service'
showAutostartHint.value = true
modeDialogVisible.value = true
}
@@ -400,6 +410,12 @@ const setting_menu_items: Ref<MenuItem[]> = ref([
command: openModeDialog,
visible: () => type() !== 'android',
},
{
label: () => t('mode.autostart'),
icon: 'pi pi-clock',
command: openAutostartDialog,
visible: () => type() !== 'android',
},
{
label: () => `${t('config-server.title')}${t('config-server.' + configServerConnectionStatus.value)}`,
icon: 'pi pi-globe',
@@ -497,6 +513,9 @@ const configServerConnectionStatus = computed(() => {
<About />
</Dialog>
<Dialog v-model:visible="modeDialogVisible" modal :header="t('mode.switch_mode')" :style="{ width: '50vw' }">
<Message v-if="showAutostartHint" severity="info" :closable="false" class="mb-4">
{{ t('mode.autostart_hint') }}
</Message>
<ModeSwitcher v-model="editingMode" @uninstall-service="onUninstallService" @stop-service="onStopService" />
<template #footer>
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="modeDialogVisible = false" text />
@@ -465,6 +465,8 @@ mode:
remote_rpc_address_empty: 远程RPC地址不能为空
service_config_empty: 服务配置不能为空
rpc_connection_failed: "RPC 连接失败:{error}"
autostart: 开机自启
autostart_hint: 开机自启由服务模式提供。切换为「服务模式」并保存后,EasyTier 将注册为系统服务,开机自动启动并在后台运行。
config-server:
title: 配置服务器
@@ -465,6 +465,8 @@ mode:
remote_rpc_address_empty: Remote RPC Address cannot be empty
service_config_empty: Service Config cannot be empty
rpc_connection_failed: "RPC connection failed: {error}"
autostart: Start on Boot
autostart_hint: Auto-start on boot is provided by service mode. After switching to Service mode and saving, EasyTier will be registered as a system service that starts automatically at boot and keeps running in the background.
config-server:
title: Config Server
+5
View File
@@ -1440,6 +1440,11 @@ fn win_service_event_loop(
Err(error) => {
status_handle.set_service_status(error_status).unwrap();
log::error!(?error);
// exit with non-zero code so the SCM treats this as
// a non-crash failure and applies the configured
// failure actions; staying alive would leave a
// zombie process until the SCM force-kills it.
std::process::exit(1);
}
}
},
+66 -2
View File
@@ -535,9 +535,10 @@ struct InstallArgs {
service_work_dir: Option<PathBuf>,
#[arg(
trailing_var_arg = true,
long,
num_args = 1..,
allow_hyphen_values = true,
help = "args to pass to easytier-core"
help = "args to pass to easytier-core, must be the last option of install"
)]
core_args: Option<Vec<OsString>>,
}
@@ -701,6 +702,69 @@ mod tests {
assert!(!dropped.contains(&proxy_index));
assert!(total_width <= 79);
}
fn parse_install_core_args(argv: &[&str]) -> Vec<OsString> {
let cli = Cli::try_parse_from(argv).expect("failed to parse cli");
let SubCommand::Service(service_args) = cli.sub_command else {
panic!("not a service subcommand");
};
let ServiceSubCommand::Install(install_args) = service_args.sub_command else {
panic!("not an install subcommand");
};
install_args.core_args.expect("no core args")
}
#[test]
fn install_core_args_do_not_include_the_flag_itself() {
// trailing_var_arg used to collect the "--core-args" token itself into
// the value, breaking the installed service's command line.
let args = parse_install_core_args(&[
"easytier-cli",
"service",
"install",
"--core-args",
"--daemon",
"--config-dir",
"/nonexistent",
]);
assert_eq!(args, vec!["--daemon", "--config-dir", "/nonexistent"]);
}
#[test]
fn install_core_args_support_equals_form() {
let args = parse_install_core_args(&[
"easytier-cli",
"service",
"install",
"--core-args=--daemon",
]);
assert_eq!(args, vec!["--daemon"]);
}
#[test]
fn install_options_before_core_args_still_parse() {
let cli = Cli::try_parse_from([
"easytier-cli",
"service",
"install",
"--disable-autostart",
"true",
"--core-args",
"--daemon",
])
.expect("failed to parse cli");
let SubCommand::Service(service_args) = cli.sub_command else {
panic!("not a service subcommand");
};
let ServiceSubCommand::Install(install_args) = service_args.sub_command else {
panic!("not an install subcommand");
};
assert_eq!(install_args.disable_autostart, Some(true));
assert_eq!(
install_args.core_args.expect("no core args"),
vec!["--daemon"]
);
}
}
fn format_proxy_cidrs(value: &str) -> String {
+53 -3
View File
@@ -338,11 +338,12 @@ impl Service {
#[cfg(target_os = "windows")]
mod win_service_manager {
use std::{ffi::OsStr, ffi::OsString, io, path::PathBuf};
use std::{ffi::OsStr, ffi::OsString, io, path::PathBuf, time::Duration};
use windows_service::{
service::{
ServiceAccess, ServiceDependency, ServiceErrorControl, ServiceInfo, ServiceStartType,
ServiceType,
Service, ServiceAccess, ServiceAction, ServiceActionType, ServiceDependency,
ServiceErrorControl, ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo,
ServiceStartType, ServiceType,
},
service_manager::{ServiceManager, ServiceManagerAccess},
};
@@ -442,6 +443,8 @@ mod win_service_manager {
set_service_work_directory(&ctx.label.to_qualified_name(), work_dir)?;
}
configure_failure_actions(&service, ctx.disable_restart_on_failure)?;
Ok(())
}
@@ -533,6 +536,8 @@ mod win_service_manager {
set_service_work_directory(&ctx.label.to_qualified_name(), work_dir)?;
}
configure_failure_actions(&service, ctx.disable_restart_on_failure)?;
Ok(())
}
}
@@ -544,4 +549,49 @@ mod win_service_manager {
.set_value::<OsString, _>(service_name, &work_directory.as_os_str().to_os_string())?;
Ok(())
}
fn configure_failure_actions(
service: &Service,
disable_restart_on_failure: bool,
) -> io::Result<()> {
// the SCM repeats the last action once the failure count exceeds the
// actions array, so Restart actions retry indefinitely.
let actions = if disable_restart_on_failure {
// empty actions clear previously configured ones
Vec::new()
} else {
vec![
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(1),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(5),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(10),
},
]
};
service
.update_failure_actions(ServiceFailureActions {
reset_period: ServiceFailureResetPeriod::Never,
reboot_msg: None,
command: None,
actions: Some(actions),
})
.map_err(io::Error::other)?;
// by default the SCM only performs failure actions on crashes; our
// service reports SERVICE_STOPPED with a non-zero exit code on
// failure, which requires this flag to be treated as a failure.
service
.set_failure_actions_on_non_crash_failures(!disable_restart_on_failure)
.map_err(io::Error::other)?;
Ok(())
}
}