fix(service): diagnose 1053 as a handshake, not a bad config
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m2s

Every failed `sc.exe start` was reported with "a service that exits immediately
usually cannot read its config", which for the one error code that actually
occurs is the wrong place to look. 1053 is the SCM giving up after 30 seconds
waiting for the process to identify itself; the process started fine and is
very likely serving traffic. A reader who follows the old sentence goes and
stares at a config file that is correct.

Replace it with windows_start_failure(), which names the real cause per code:

- 1053: a handshake failure, almost always a sidecar older than v1.2.0 (the
  first release that speaks the SCM protocol). Says how to check the version,
  and how to prove the binary is healthy by running it in the foreground.
- 1069: the virtual service account was refused, which is local policy rather
  than a bad credential, and points at INSTALL.md Appendix A4.
- anything else: does not guess, and hands over the event log, `sc query` for
  the service's own exit code, and the foreground command.

Pure and tested on both platforms, like windows_bin_path above it, so the text
is covered on the Linux CI runner that never sees an SCM.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-07 13:36:05 -05:00
parent 07a2cca5f6
commit b7d1bbbc78

View File

@@ -44,6 +44,11 @@ pub const WINDOWS_SERVICE: &str = "RunicGatewayLink";
pub const SERVICE_USER: &str = "runicgateway";
/// What both platforms show a human.
const DISPLAY_NAME: &str = "Runic Gateway uo-link sidecar";
/// The first `link` release whose sidecar speaks the Windows SCM startup protocol, and so the
/// oldest one that can be started as a service at all. Named only in the 1053 diagnosis; nothing
/// enforces it, because the Linux side has no such floor and a version gate on an installed binary
/// would refuse deployments that are working.
const MIN_SERVICE_SIDECAR: &str = "v1.2.0";
/// Which service manager this host has — or why it has none this installer can drive.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -307,6 +312,57 @@ pub fn windows_bin_path(binary: &Path, config: &Path) -> String {
format!("\"{}\" --config \"{}\"", binary.display(), config.display())
}
/// What to tell the operator when `sc.exe start` fails.
///
/// Pure and tested on both platforms, because the *wrong* explanation here is expensive. This
/// originally blamed every failure on the config file — "a service that exits immediately usually
/// cannot read its config" — which for the one error code that actually shows up sends the reader
/// to inspect a file that is almost certainly fine.
///
/// **1053 is not a crash.** It is the SCM giving up after 30 seconds waiting for the service
/// process to call `StartServiceCtrlDispatcher` and identify itself. The process starts, runs, and
/// is very likely serving traffic; it simply never had the conversation the SCM required. A sidecar
/// older than the one that speaks the SCM protocol produces this *every time*, on a perfectly good
/// config — so the config is the last thing to look at, not the first.
pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String {
let command = crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]);
match code {
1053 => format!(
"`{command}` failed with 1053 — the service did not respond to the start request in \
time.\n\n This is a handshake failure, not a crash: Windows waited 30 seconds for \
the process to identify itself to the service control manager. The usual cause is a \
sidecar built before the service support was added, which runs perfectly in the \
foreground and can never start as a service. Check its version:\n\n \
\"{binary}\" --version\n\n and confirm it is at least {MIN_SERVICE_SIDECAR}. To \
see whether the sidecar itself is healthy, run it in the foreground — if that works, \
the binary is the problem, not the configuration:\n\n \"{binary}\" --config \
\"{config}\"",
binary = binary.display(),
config = config.display(),
),
// ERROR_SERVICE_LOGON_FAILED. The account is the virtual one the SCM makes itself, so this
// is a policy that forbids virtual service accounts rather than a wrong password.
1069 => format!(
"`{command}` failed with 1069 — the service could not log on as {account}.\n\n \
That account is a virtual service account created by the SCM itself and has no \
password, so this is a local policy forbidding them rather than a bad credential. \
Register the service by hand against an account this host allows — INSTALL.md \
Appendix A4.",
account = windows_service_account(),
),
_ => format!(
"`{command}` failed with exit code {code}.\n\n Check the Windows event log \
(System, source \"Service Control Manager\"), and `sc query {WINDOWS_SERVICE}` for \
the service's own exit code. A sidecar that exits immediately usually cannot read its \
config: {}\n\n Running it in the foreground prints the reason:\n\n \
\"{}\" --config \"{}\"",
config.display(),
binary.display(),
config.display(),
),
}
}
#[cfg(windows)]
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
let bin_path = windows_bin_path(binary, config);
@@ -373,13 +429,11 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
if !start.status.success() && start.status.code() != Some(1056) {
anyhow::bail!(
"`{}` failed with exit code {}. Check the Windows event log; a service that exits \
immediately usually cannot read its config: {}",
crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]),
anyhow::bail!(windows_start_failure(
start.status.code().unwrap_or(-1),
config.display()
);
binary,
config
));
}
Ok(Outcome::Registered {
@@ -994,6 +1048,45 @@ mod tests {
}
}
#[test]
fn error_1053_is_diagnosed_as_a_handshake_not_a_bad_config() {
// The regression this guards: 1053 used to be reported as "a service that exits immediately
// usually cannot read its config", which is the one thing it almost never is. A reader who
// follows that sentence goes and stares at a config file that is fine.
let msg = windows_start_failure(
1053,
Path::new(r"C:\Program Files\RunicGateway\uo-link-sidecar.exe"),
Path::new(r"C:\ProgramData\RunicGateway\sidecar.toml"),
);
assert!(msg.contains("1053"), "{msg}");
assert!(msg.contains("handshake"), "{msg}");
assert!(!msg.contains("cannot read its config"), "{msg}");
// It has to name the two things that actually resolve it: check the version, and prove the
// binary is healthy by running it in the foreground.
assert!(msg.contains("--version"), "{msg}");
assert!(msg.contains(MIN_SERVICE_SIDECAR), "{msg}");
assert!(msg.contains("uo-link-sidecar.exe"), "{msg}");
assert!(msg.contains("sidecar.toml"), "{msg}");
}
#[test]
fn a_logon_failure_points_at_policy_not_a_password() {
let msg = windows_start_failure(1069, Path::new("bin.exe"), Path::new("c.toml"));
assert!(msg.contains("NT SERVICE\\RunicGatewayLink"), "{msg}");
assert!(msg.contains("policy"), "{msg}");
}
#[test]
fn an_unrecognized_code_still_says_how_to_see_the_real_error() {
// The fallback must not pretend to know the cause; it must hand over the two places the
// cause is actually written down.
let msg = windows_start_failure(5, Path::new("bin.exe"), Path::new("c.toml"));
assert!(msg.contains("exit code 5"), "{msg}");
assert!(msg.contains("event log"), "{msg}");
assert!(msg.contains("sc query RunicGatewayLink"), "{msg}");
assert!(msg.contains("--config"), "{msg}");
}
#[test]
fn the_manual_steps_are_a_complete_recipe() {
// This text is all an operator gets on a host the installer cannot drive, so it has to name