mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-13 20:13:13 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c639b802a |
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."
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", ®()).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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user