mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-20 03:22:05 +00:00
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:
@@ -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(() => [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user