Compare commits

..
Author SHA1 Message Date
rdbtCVSandClaude Sonnet 5.5 5c639b802a Dedicated server: /restart saves, stops and exits for horizon-server to start the game again
Registered only on a server horizon-server runs (HORIZON_SUPERVISED), owner permission like /stop.
Exit code 75 is Exit::Restart in the supervisor. Unsupervised, the console says nothing would start it again.

Refs #16251

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-14 01:16:53 +00:00
12 changed files with 221 additions and 27 deletions

No files matched your search

@@ -1223,5 +1223,7 @@
"horizon.perf.copy_hover": "Copy the link.",
"horizon.perf.retry_button": "[Retry]",
"horizon.perf.retry_hover": "Try the upload again. The profile is still saved on your computer.",
"horizon.commands.restart.restarting": "Restarting the server",
"horizon.commands.restart.unsupervised": "Nothing would start the game again: /restart needs a server run by horizon-server",
"horizon.perf.link_hover": "Open the shared profile in your browser."
}
+4
View File
@@ -323,6 +323,10 @@ pub fn main_with(mods: crate::mod_api::Mods) {
}
horizon_hooks::engine::shutdown();
logging::flush();
// `/restart`: the world is saved; the supervisor reads this code as "start the game again".
if game::exit_code::restart_requested() {
std::process::exit(game::exit_code::RESTART);
}
}
/// The JVM raises its open-file limit to the hard limit as it starts
+1 -1
View File
@@ -1133,7 +1133,7 @@ impl Game<'_> {
id if id.starts_with("banlist") => self.cmd_banlist(id),
id if id.starts_with("whitelist.") => self.cmd_whitelist(id, ctx),
"setidletimeout" => self.cmd_setidle_timeout(ctx),
"stop" => self.cmd_stop(),
"stop" | "restart" => self.cmd_stop(id == "restart"),
"save-all" => self.cmd_save_all(),
"save-off" | "save-on" => self.cmd_auto_save(id == "save-on"),
"transfer" => self.cmd_transfer(ctx),
@@ -26,11 +26,18 @@ impl Game<'_> {
}
}
/// StopCommand: the server loop (game/dedicated.rs) ends after this tick.
pub(super) fn cmd_stop(&mut self) -> R<i32> {
/// StopCommand: the server loop (game/dedicated.rs) ends after this tick. `restart` (Horizon's `/restart`,
/// registered only on a server `horizon-server` runs) also has the supervisor start the game again.
pub(super) fn cmd_stop(&mut self, restart: bool) -> R<i32> {
let lists = self.dedicated_lists()?;
self.feedback(Text::translate("commands.stop.stopping", &[]));
lists.lock().unwrap().stop = true;
let reply = if restart {
let supervised = super::super::dedicated::supervised();
super::super::dedicated::request_restart(supervised, &mut lists.lock().unwrap().stop)
} else {
lists.lock().unwrap().stop = true;
Text::translate("commands.stop.stopping", &[])
};
self.feedback(reply);
Ok(1)
}
@@ -163,6 +163,23 @@ struct Setup {
port: Option<u16>,
started: Instant,
}
/// Whether `horizon-server` runs this game, and so starts it again after a `/restart`.
pub(super) fn supervised() -> bool {
std::env::var_os(super::launch_api::SUPERVISED_ENV).is_some_and(|v| !v.is_empty())
}
/// `/restart` (Horizon's own, on a server `horizon-server` runs): like `/stop`, then the process exits
/// with a code that asks the supervisor to start the game again. `stop` is the server loop's flag.
/// Unsupervised nothing would start the game again, so it says so and stops nothing.
pub(super) fn request_restart(supervised: bool, stop: &mut bool) -> Text {
if !supervised {
return Text::translate("horizon.commands.restart.unsupervised", &[]);
}
super::exit_code::set_restart_requested();
*stop = true;
Text::translate("horizon.commands.restart.restarting", &[])
}
static SETUP: OnceLock<Mutex<Option<Setup>>> = OnceLock::new();
/// usercache.json (Services.nameToIdCache): used by the server thread only.
static USER_CACHE: Mutex<Option<UserCache>> = Mutex::new(None);
@@ -465,6 +482,7 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
// A dedicated server's tree: /op, /ban, /whitelist... (single player's
// server thread is an integrated one and never gets here).
horizon_client::command::vanilla::set_dedicated_commands(true);
horizon_client::command::vanilla::set_supervised_commands(supervised());
// Horizon's own commands are the owner's choice, off as on vanilla.
if props
.raw
@@ -1854,6 +1872,13 @@ impl Game<'_> {
self.console_reply(Text::translate("commands.stop.stopping", &[]));
state.lists.lock().unwrap().stop = true;
}
// Horizon's own, unlike its tree entry also here without the supervisor, to say why it does nothing.
"restart" => {
let mut lists = state.lists.lock().unwrap();
let reply = request_restart(supervised(), &mut lists.stop);
drop(lists);
self.console_reply(reply);
}
// Horizon's own, on the console only: a performance report (report.rs).
"horizon-report" | "horizon-trace" => {
let trace = command == "horizon-trace";
@@ -2650,6 +2675,25 @@ mod tests {
time::{SystemTime, UNIX_EPOCH},
};
/// `/restart` without `horizon-server` refuses and stops nothing; with it, it asks the server loop to end.
#[test]
fn restart_without_a_supervisor_does_nothing_and_says_why() {
let mut stop = false;
let reply = super::request_restart(false, &mut stop);
assert!(!stop);
assert!(!super::super::exit_code::restart_requested());
assert_eq!(
reply.translation_key(),
Some("horizon.commands.restart.unsupervised")
);
let reply = super::request_restart(true, &mut stop);
assert!(stop);
assert_eq!(
reply.translation_key(),
Some("horizon.commands.restart.restarting")
);
}
/// A console line runs in the overworld even after a server tick left the nether in place.
#[test]
fn console_commands_run_in_the_overworld() {
@@ -97,8 +97,26 @@ pub mod exit_code {
pub const USAGE: i32 = 2;
/// A panic; a crash report was saved.
pub const PANIC: i32 = 101;
/// A dedicated server's `/restart`: saved and stopped, to be started again by `horizon-server`
/// (crates/horizon-server/src/engine.rs holds the same number).
pub const RESTART: i32 = 75;
static RESTART_REQUESTED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// `/restart` stopped a dedicated server: entry.rs exits with `exit_code::RESTART` once it is saved.
pub fn restart_requested() -> bool {
RESTART_REQUESTED.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn set_restart_requested() {
RESTART_REQUESTED.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
/// Set by `horizon-server` in the game's environment: something outlives the game and starts it again.
pub const SUPERVISED_ENV: &str = "HORIZON_SUPERVISED";
const EXIT_CODES: &[(i32, &str)] = &[
(
exit_code::OK,
@@ -116,6 +134,10 @@ const EXIT_CODES: &[(i32, &str)] = &[
exit_code::PANIC,
"The game panicked. A crash report was saved.",
),
(
exit_code::RESTART,
"A dedicated server saved and stopped on `/restart`: horizon-server starts it again.",
),
];
/// A command line the game refuses: exit code 2, no crash report.
+29
View File
@@ -880,6 +880,35 @@ fn the_dedicated_commands_complete_from_the_servers_lists() {
assert!(suggest("ban ").contains(&"@a".to_owned()));
}
#[test]
fn restart_is_only_on_a_supervised_dedicated_server_and_asks_for_stop_permission() {
// A vanilla-like server's tree stays vanilla: /restart needs the supervisor.
for dev in [false, true] {
for horizon in [false, true] {
assert!(
vanilla::game_tree_with(dev, horizon, true, false)
.find("restart")
.is_none()
);
// An integrated server never has it, supervised or not.
assert!(
vanilla::game_tree_with(dev, horizon, false, true)
.find("restart")
.is_none()
);
let d = vanilla::game_tree_with(dev, horizon, true, true);
let restart = d
.find("restart")
.expect("supervised dedicated tree has /restart");
let stop = d.find("stop").unwrap();
assert_eq!(d.nodes[restart].permission, d.nodes[stop].permission);
assert_eq!(
d.parse("restart", &reg()).executable().unwrap().1,
"restart"
);
}
}
}
#[test]
fn only_a_dedicated_server_has_the_dedicated_commands() {
// Commands.CommandSelection: an integrated server (single player, a LAN
// world) registers no /op, /ban, /ban-ip, /banlist, /pardon, /pardon-ip,
+60 -20
View File
@@ -576,10 +576,11 @@ fn apply_vanilla_order(d: &mut Dispatcher) {
/// (SharedConstants.DEBUG_DEV_COMMANDS), plus Horizon's own commands when
/// the server turned them on (`set_horizon_commands`).
pub fn game_dispatcher() -> &'static Dispatcher {
game_tree(
game_tree_with(
std::env::var_os("HORIZON_DEV_COMMANDS").is_some(),
HORIZON_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
DEDICATED_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
SUPERVISED_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
)
}
/// The server's tree for this world's enabled features. Cache entries are
@@ -588,19 +589,28 @@ pub fn game_dispatcher_with_features(features: &[String]) -> &'static Dispatcher
if !features.iter().any(|f| f == MINECART_IMPROVEMENTS) {
return game_dispatcher();
}
static TREES: [std::sync::OnceLock<Dispatcher>; 8] = [const { std::sync::OnceLock::new() }; 8];
static TREES: [std::sync::OnceLock<Dispatcher>; 16] =
[const { std::sync::OnceLock::new() }; 16];
let dev = std::env::var_os("HORIZON_DEV_COMMANDS").is_some();
let horizon = HORIZON_COMMANDS.load(std::sync::atomic::Ordering::Relaxed);
let dedicated = DEDICATED_COMMANDS.load(std::sync::atomic::Ordering::Relaxed);
TREES[usize::from(dev) | usize::from(horizon) << 1 | usize::from(dedicated) << 2].get_or_init(
|| {
let mut d = build_for_features(dev, dedicated, true);
if horizon {
horizon_commands(&mut d, 0);
}
d
},
)
let supervised = dedicated && SUPERVISED_COMMANDS.load(std::sync::atomic::Ordering::Relaxed);
TREES[tree_slot(dev, horizon, dedicated, supervised)].get_or_init(|| {
let mut d = build_for_features(dev, dedicated, true);
if horizon {
horizon_commands(&mut d, 0);
}
if supervised {
supervised_commands(&mut d, 0);
}
d
})
}
fn tree_slot(dev: bool, horizon: bool, dedicated: bool, supervised: bool) -> usize {
usize::from(dev)
| usize::from(horizon) << 1
| usize::from(dedicated) << 2
| usize::from(supervised) << 3
}
/// Whether this process is a dedicated server, whose tree has the dedicated
/// commands (`/op`, `/ban`, `/whitelist`...). Single player's server thread
@@ -622,24 +632,54 @@ static HORIZON_COMMANDS: std::sync::atomic::AtomicBool = std::sync::atomic::Atom
pub fn set_horizon_commands(on: bool) {
HORIZON_COMMANDS.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// Whether this dedicated server runs under `horizon-server`, which starts the game again after
/// `/restart`. Off otherwise, so the tree stays vanilla's.
static SUPERVISED_COMMANDS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// A server run by `horizon-server` turns `/restart` on, for the trees `game_dispatcher` returns
/// from now on (only a dedicated server's tree has it).
pub fn set_supervised_commands(on: bool) {
SUPERVISED_COMMANDS.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// The server.properties key that turns `/pregen` on (Horizon's own; vanilla
/// ignores keys it doesn't know).
pub const PREGEN_PROPERTY: &str = "horizon-pregen";
#[cfg(test)]
pub(crate) fn game_tree(dev: bool, horizon: bool, dedicated: bool) -> &'static Dispatcher {
static TREES: [std::sync::OnceLock<Dispatcher>; 8] = [const { std::sync::OnceLock::new() }; 8];
game_tree_with(dev, horizon, dedicated, false)
}
pub(crate) fn game_tree_with(
dev: bool,
horizon: bool,
dedicated: bool,
supervised: bool,
) -> &'static Dispatcher {
static TREES: [std::sync::OnceLock<Dispatcher>; 16] =
[const { std::sync::OnceLock::new() }; 16];
let supervised = dedicated && supervised;
match (dev, horizon, dedicated) {
(true, false, false) => dispatcher(),
(false, false, false) => release_dispatcher(),
_ => TREES[usize::from(dev) | usize::from(horizon) << 1 | usize::from(dedicated) << 2]
.get_or_init(|| {
let mut d = build_for(dev, dedicated);
if horizon {
horizon_commands(&mut d, 0);
}
d
}),
_ => TREES[tree_slot(dev, horizon, dedicated, supervised)].get_or_init(|| {
let mut d = build_for(dev, dedicated);
if horizon {
horizon_commands(&mut d, 0);
}
if supervised {
supervised_commands(&mut d, 0);
}
d
}),
}
}
/// `/restart`: only on a dedicated server `horizon-server` runs. It saves and stops like `/stop`, and the
/// supervisor starts the game again (docs/hosting.md). The same permission level as `/stop`.
const OWNERS: u8 = 4;
fn supervised_commands(d: &mut Dispatcher, root: usize) {
let restart = d.literal(root, "restart");
d.requires(restart, OWNERS);
d.executes(restart, "restart");
}
/// Commands vanilla doesn't have, after its own: `/pregen`
/// (docs/pregeneration.md).
fn horizon_commands(d: &mut Dispatcher, root: usize) {
+7
View File
@@ -14,6 +14,12 @@ use std::{
/// The game's program names, most specific first: the server build (the game without sound or
/// controllers, so it needs neither ALSA nor udev on Linux; `horizon-server-linux-x86_64.tar.gz` holds it),
/// else the full game.
/// The exit code of a game that saved and stopped on `/restart`: start it again. The game's
/// `exit_code::RESTART` (crates/horizon-client/src/game/launch_api.rs) is the same number.
pub const RESTART_EXIT_CODE: i32 = 75;
/// What the game's environment carries to say this supervisor starts it again: only then does it
/// register `/restart`. The game's `SUPERVISED_ENV` is the same name.
const SUPERVISED_ENV: &str = "HORIZON_SUPERVISED";
const PROGRAMS: [&str; 2] = ["horizon-engine", "horizon-client"];
fn exe(name: &str) -> String {
@@ -282,6 +288,7 @@ fn command(
.args(["--dedicated", "--nogui"])
.current_dir(&dir)
.env("HORIZON_NO_AUDIO", "1")
.env(SUPERVISED_ENV, "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
+30 -1
View File
@@ -162,7 +162,7 @@ enum Exit {
/// Asked to stop, or the game ended by itself without an error (`stop` typed in its console, the EULA
/// not agreed, the port taken): not restarted.
Stopped,
/// Asked to restart (`horizon-server restart`).
/// Asked to restart (`horizon-server restart`, `/restart`).
Restart,
Crashed(ExitStatus),
/// Started on another release, the game wasn't up in time and was stopped.
@@ -842,6 +842,16 @@ fn supervise(
match child.try_wait() {
Ok(Some(_)) if not_up => return Exit::NotUp,
Ok(Some(_)) if restart => return Exit::Restart,
// `/restart` in game, on the console or through RCON: the game saved, stopped and says so by its exit
// code. A shutdown asked for since wins: it is not started again.
Ok(Some(status))
if status.code() == Some(engine::RESTART_EXIT_CODE)
&& !shared.shutdown.load(Ordering::SeqCst) =>
{
note(shared, "INFO", "Restarting the server");
shared.set_phase(Phase::Restarting);
return Exit::Restart;
}
Ok(Some(status)) if stopping.is_some() || status.success() => return Exit::Stopped,
Ok(Some(status)) => return Exit::Crashed(status),
Ok(None) => thread::sleep(Duration::from_millis(50)),
@@ -1219,6 +1229,25 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn the_restart_exit_code_starts_the_game_again_and_others_do_not() {
let dir = std::env::temp_dir().join(format!("horizon-server-exit-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (_console, console) = mpsc::channel();
let run = |script: &str| {
let shared = Shared::with_install(&dir, None);
let mut child = game(script);
(supervise(&shared, &mut child, &console, &mut None), shared)
};
let (exit, shared) = run(&format!("exit {}", engine::RESTART_EXIT_CODE));
assert!(matches!(exit, Exit::Restart));
assert_eq!(shared.state.lock().unwrap().phase, Phase::Restarting);
assert!(matches!(run("exit 0").0, Exit::Stopped));
assert!(matches!(run("exit 1").0, Exit::Crashed(_)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn one_supervisor_per_folder() {
let dir = std::env::temp_dir().join(format!("horizon-server-lock-{}", std::process::id()));
+3 -1
View File
@@ -69,7 +69,8 @@ Prints one JSON object on stdout and exits 0. `horizon` is the build's version;
0,
1,
2,
101
101,
75
],
"horizon": "<version>",
"launch_api": "0.1.0",
@@ -137,5 +138,6 @@ Prints one JSON object on stdout and exits 0. `horizon` is the build's version;
| 1 | The game stopped on an error. A crash report was saved. |
| 2 | The command line was refused: an unknown option, a missing or bad value. No crash report. |
| 101 | The game panicked. A crash report was saved. |
| 75 | A dedicated server saved and stopped on `/restart`: horizon-server starts it again. |
When a crash report is saved, the last line on stderr is `Horizon: crash report saved to` followed by the report's path.
+8
View File
@@ -407,6 +407,14 @@ A running server takes the difficulty, the default game mode, the whitelist, the
player limit and the backup settings at once; the rest applies at its next start, and `config set` says which. `--json` prints that as `saved`, `applied`
and `restart`.
**`/restart`** saves and stops the game as `/stop` does, and the server starts it again: from in game, the console,
RCON and `horizon-server cmd restart`, for owners (the permission `/stop` needs). The command exists only on a
server `horizon-server` runs, so vanilla's command tree is unchanged elsewhere; a game started by hand answers on its
console that nothing would start it again, and does nothing. The game program is looked up again at every start, so a
new engine put in place beforehand (`engine.path`, or the release `current` points at) runs after the restart. Put it
there with `mv` or `install`, which swap the file: copying over the running program fails or corrupts it.
Players are disconnected for now; [#16251](https://github.com/horizon-rust/horizon/issues/16251) is about holding them through it.
The server restarts the game by itself if it crashes: after 5 s, then waiting longer each time, up to 5
minutes. It keeps the crash report and gives up after 5 crashes in 10 minutes, and `status` says why.