From 9d61c0da89a1bf838da44eb0427e80dd27c8b2fa Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 02:07:33 -0400 Subject: [PATCH 01/17] Forward server and tunnel output with source prefixes - log4j console pattern prefixes lines with MINECRAFT [LEVEL] [thread] - server output is always read; join and leave events print status lines - tunnel agent output is echoed with a TUNNEL: prefix under --tunnel-logs --- docs/src/commands/run.md | 24 ++++++++++++++-- docs/src/guides/systemd.md | 9 ++++++ docs/src/guides/tunnel.md | 3 +- src/minecraft/log.rs | 59 ++++++++++++++++++++++++++++++++++++++ src/minecraft/log4j.rs | 2 +- src/minecraft/mod.rs | 1 + src/minecraft/tests.rs | 51 ++++++++++++++++++++++++++++++++ src/ops/run.rs | 56 ++++++++++++++++++++++++++++++------ src/ops/tunnel.rs | 36 ++++++++++++++++++++--- src/utils/process.rs | 27 +++++++++++++++++ src/utils/shell.rs | 10 +++++++ 11 files changed, 261 insertions(+), 17 deletions(-) create mode 100644 src/minecraft/log.rs diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index 8feeabd..976e5d6 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -21,10 +21,26 @@ is hidden unless `--server-logs` is passed; it is always written to `instance/logs/` by the server itself. The console is not interactive; use the remote console (RCON) for live administration. +mc reads the console output whether or not it is shown, and prints a status +line for the events it recognizes: a player joining or leaving the instance. +With `--server-logs`, the console line that produced the event is shown as +well, ahead of the status line. + +```text +MINECRAFT [INFO] [Server thread]: Notch joined the game + Joined Notch +``` + +Console lines are prefixed with `MINECRAFT [LEVEL] [thread]:` when mc +configures the instance's logging. When the `jvm-arguments` in the manifest +select a logging configuration file of their own, the console keeps that +file's format and no events are recognized. + With a `tunnel` section, the tunnel agent starts beside the instance and is restarted if it stops on its own; the public address is printed at startup. The agent's output is hidden unless `--tunnel-logs` is passed, and -goes to `.tunnel/playitd.log` otherwise. The first start from a terminal prints a claim link to approve in a +goes to `.tunnel/playitd.log` otherwise. When shown, every agent line is +prefixed with `TUNNEL:`. The first start from a terminal prints a claim link to approve in a browser and saves the resulting secret under `.tunnel/`. Without a terminal and without a secret, `mc run` fails with instructions rather than waiting — see [Tunnels](../guides/tunnel.md). @@ -53,5 +69,7 @@ instance's exit code. - `--manifest-path ` — path to `mc.kdl`. Defaults to `./mc.kdl`. - `--lockfile-path ` — path to `mc.lock`. Defaults to `./mc.lock`. -- `--server-logs` — show the instance's console output in the terminal. -- `--tunnel-logs` — show the tunnel agent's output in the terminal. +- `--server-logs` — show the instance's console output in the terminal, + prefixed with `MINECRAFT`. Recognized events are printed either way. +- `--tunnel-logs` — show the tunnel agent's output in the terminal, + prefixed with `TUNNEL:`. diff --git a/docs/src/guides/systemd.md b/docs/src/guides/systemd.md index 76492a4..40645ca 100644 --- a/docs/src/guides/systemd.md +++ b/docs/src/guides/systemd.md @@ -71,5 +71,14 @@ tunnel agent as well): journalctl -u myserver -f ``` +Each forwarded line carries the prefix of its source, `MINECRAFT` or +`TUNNEL`, so the two are easy to tell apart from mc's own status lines: + +```text +MINECRAFT [INFO] [Server thread]: Notch joined the game + Joined Notch +TUNNEL: 2026-09-09T12:34:56.123456Z INFO playit_agent_core::tunnel: connected +``` + mc's own log verbosity is controlled by the global `--verbose` flag; the instance's console log level follows it. diff --git a/docs/src/guides/tunnel.md b/docs/src/guides/tunnel.md index b50ab6e..035a8b8 100644 --- a/docs/src/guides/tunnel.md +++ b/docs/src/guides/tunnel.md @@ -56,7 +56,8 @@ new agent on the next start. The agent runs for as long as the instance does and is restarted by mc if it stops on its own. Its output is written to `.tunnel/playitd.log`, or shown in -the terminal when the instance is started with `mc run --tunnel-logs`. +the terminal, prefixed with `TUNNEL:`, when the instance is started with +`mc run --tunnel-logs`. Stopping the instance stops the agent. Traffic between players and the instance flows through the provider's relay, diff --git a/src/minecraft/log.rs b/src/minecraft/log.rs new file mode 100644 index 0000000..d90a957 --- /dev/null +++ b/src/minecraft/log.rs @@ -0,0 +1,59 @@ +/// A console line as printed with the pattern mc writes into `log4j2.xml`: +/// `MINECRAFT [LEVEL] [thread]: message`. +pub struct ServerLogLine<'a> { + pub level: &'a str, + pub thread: &'a str, + pub message: &'a str +} + +impl<'a> ServerLogLine<'a> { + pub fn parse(line: &'a str) -> Option { + let rest = line.strip_prefix("MINECRAFT [")?; + let (level, rest) = rest.split_once("] [")?; + let (thread, message) = rest.split_once("]: ")?; + + Some(ServerLogLine { + level, + thread, + message + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ServerLogEvent { + Joined(String), + Left(String) +} + +impl ServerLogEvent { + pub fn recognize(line: &str) -> Option { + let line = ServerLogLine::parse(line)?; + + if line.level != "INFO" || line.thread != "Server thread" { + return None; + } + + if let Some(name) = line.message.strip_suffix(" joined the game") { + return player_name(name).map(ServerLogEvent::Joined); + } + + if let Some(name) = line.message.strip_suffix(" left the game") { + return player_name(name).map(ServerLogEvent::Left); + } + + None + } +} + +// Chat, `/say`, and `/me` all put a space or angle brackets before the +// player-controlled text, so a bare name is the only shape accepted. +fn player_name(name: &str) -> Option { + let valid = !name.is_empty() + && name.len() <= 16 + && name + .chars() + .all(|c| c.is_ascii_graphic() && !matches!(c, '<' | '>')); + + valid.then(|| name.to_string()) +} diff --git a/src/minecraft/log4j.rs b/src/minecraft/log4j.rs index bc24ba1..25ea184 100644 --- a/src/minecraft/log4j.rs +++ b/src/minecraft/log4j.rs @@ -2,7 +2,7 @@ const TEMPLATE: &str = r#" - + diff --git a/src/minecraft/mod.rs b/src/minecraft/mod.rs index a982634..b8da46a 100644 --- a/src/minecraft/mod.rs +++ b/src/minecraft/mod.rs @@ -4,6 +4,7 @@ use serde::Serialize; use serde::Serializer; pub mod eula; +pub mod log; pub mod log4j; pub mod players; pub mod seed; diff --git a/src/minecraft/tests.rs b/src/minecraft/tests.rs index 763deaa..eb188f9 100644 --- a/src/minecraft/tests.rs +++ b/src/minecraft/tests.rs @@ -4,6 +4,8 @@ use chrono::DateTime; use chrono::Utc; use uuid::Uuid; +use crate::minecraft::log::ServerLogEvent; +use crate::minecraft::log::ServerLogLine; use crate::minecraft::players::BanDetails; use crate::minecraft::players::BanEntry; use crate::minecraft::players::OpEntry; @@ -81,3 +83,52 @@ fn unknown_property_keys_are_reported() -> McResult<()> { Ok(()) } + +#[test] +fn console_lines_split_into_level_thread_and_message() { + let line = ServerLogLine::parse( + "MINECRAFT [WARN] [Worker-Main-3]: Can't keep up! Is the server overloaded?" + ); + + assert!(line.is_some_and(|line| { + line.level == "WARN" + && line.thread == "Worker-Main-3" + && line.message == "Can't keep up! Is the server overloaded?" + })); + assert!( + ServerLogLine::parse("[12:34:56] [Server thread/INFO]: Notch joined the game").is_none() + ); + assert!( + ServerLogLine::parse("WARNING: A terminally deprecated method has been called").is_none() + ); +} + +#[test] +fn join_and_leave_events_are_recognized() { + assert_eq!( + ServerLogEvent::recognize("MINECRAFT [INFO] [Server thread]: Notch joined the game"), + Some(ServerLogEvent::Joined(String::from("Notch"))) + ); + assert_eq!( + ServerLogEvent::recognize("MINECRAFT [INFO] [Server thread]: Notch left the game"), + Some(ServerLogEvent::Left(String::from("Notch"))) + ); +} + +#[test] +fn player_controlled_text_is_not_an_event() { + let lines = [ + "MINECRAFT [INFO] [Server thread]: Steve joined the game", + "MINECRAFT [INFO] [Server thread]: joined the game", + "MINECRAFT [INFO] [Server thread]: [Server] Steve left the game", + "MINECRAFT [INFO] [Server thread]: * Notch left the game", + "MINECRAFT [INFO] [Server thread]: joined the game", + "MINECRAFT [WARN] [Server thread]: Notch joined the game", + "MINECRAFT [INFO] [Netty Server IO #1]: Notch joined the game", + "[12:34:56] [Server thread/INFO]: Notch joined the game" + ]; + + for line in lines { + assert_eq!(ServerLogEvent::recognize(line), None, "{}", line); + } +} diff --git a/src/ops/run.rs b/src/ops/run.rs index b710a9f..8850759 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -3,11 +3,15 @@ use std::io::ErrorKind; use std::io::IsTerminal; use std::process::ExitStatus; use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex; use std::sync::PoisonError; use std::time::Duration; use anyhow::Context; +use tokio::io::AsyncRead; use tokio::io::AsyncWriteExt; +use tokio::task::JoinHandle; use tokio_cron_scheduler::Job; use tokio_cron_scheduler::JobScheduler; use tokio_util::sync::CancellationToken; @@ -17,6 +21,7 @@ use crate::env::Architecture; use crate::env::Platform; use crate::manifest::Manifest; use crate::manifest::ManifestPaths; +use crate::minecraft::log::ServerLogEvent; use crate::minecraft::log4j; use crate::minecraft::server_properties::ManagedServerProperties; use crate::minecraft::server_properties::PropertyEntries; @@ -37,6 +42,7 @@ use crate::ops::tunnel::TunnelInstallOptions; use crate::services::playit_api::DASHBOARD_TUNNELS_URL; use crate::utils; use crate::utils::errors::McResult; +use crate::utils::shell::Shell; /// How long to wait for the server to save and exit after asking it to stop /// before forcing it down. Kept under systemd's default 90s `TimeoutStopSec` so @@ -64,12 +70,29 @@ pub struct RunOptions { pub tunnel_logs: bool } -fn child_output(shown: bool) -> Stdio { - if shown { - Stdio::inherit() - } else { - Stdio::null() - } +/// Drains one of the server's output pipes, echoing every line when +/// `echo` is set and printing the events it recognizes either way. +fn forward_server_output(shell: Arc>, output: R, echo: bool) -> JoinHandle<()> +where + R: AsyncRead + Unpin + Send + 'static +{ + tokio::spawn(async move { + utils::process::for_each_line(output, |line| { + let event = ServerLogEvent::recognize(line); + let mut shell = shell.lock().unwrap_or_else(PoisonError::into_inner); + + if echo { + _ = shell.echo(line); + } + + match event { + Some(ServerLogEvent::Joined(name)) => _ = shell.status("Joined", name), + Some(ServerLogEvent::Left(name)) => _ = shell.status("Left", name), + None => {} + } + }) + .await + }) } fn has_jvm_property(arguments: &[String], property: &str) -> bool { @@ -385,8 +408,8 @@ pub async fn run(context: &mut McContext, options: &RunOptions) -> McResult McResult McResult Command { .env("PLAYIT_LOG", options.log_level) .current_dir(&options.work_directory) .stdin(Stdio::null()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) .kill_on_drop(true); // The agent logs to stderr unless given a log file, so hidden output is // kept on disk rather than dropped. - if !options.logs { - command.arg("--log-path").arg(LOG_FILE_NAME); + if options.logs { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + } else { + command + .arg("--log-path") + .arg(LOG_FILE_NAME) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); } utils::process::detach_from_terminal_signals(&mut command); @@ -376,6 +381,21 @@ async fn stop_agent(child: &mut Child) { let _ = child.kill().await; } +fn forward_agent_output(shell: Arc>, output: R) +where + R: AsyncRead + Unpin + Send + 'static +{ + tokio::spawn(async move { + utils::process::for_each_line(output, |line| { + _ = shell + .lock() + .unwrap_or_else(PoisonError::into_inner) + .echo(format!("TUNNEL: {}", line)); + }) + .await + }); +} + fn warn(shell: &Arc>, message: String) { _ = shell .lock() @@ -412,6 +432,14 @@ pub fn supervise( } }; + if let Some(stdout) = child.stdout.take() { + forward_agent_output(shell.clone(), stdout); + } + + if let Some(stderr) = child.stderr.take() { + forward_agent_output(shell.clone(), stderr); + } + if let Some(address) = options.address.as_ref().filter(|_| !announced) { announced = true; diff --git a/src/utils/process.rs b/src/utils/process.rs index f594529..23a828c 100644 --- a/src/utils/process.rs +++ b/src/utils/process.rs @@ -1,6 +1,9 @@ use std::future::pending; use anyhow::Context; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncRead; +use tokio::io::BufReader; use tokio::process::Command; #[cfg(unix)] use tokio::signal::unix::Signal; @@ -36,6 +39,30 @@ pub fn render_command(command: &Command) -> String { .join(" ") } +/// Calls `handle` with every line `output` yields until it closes or fails. +/// Invalid UTF-8 is replaced rather than dropping the line. +pub async fn for_each_line(output: R, mut handle: F) +where + R: AsyncRead + Unpin, + F: FnMut(&str) +{ + let mut reader = BufReader::new(output); + let mut buffer = Vec::new(); + + loop { + buffer.clear(); + + match reader.read_until(b'\n', &mut buffer).await { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + + let line = String::from_utf8_lossy(&buffer); + + handle(line.trim_end_matches(['\n', '\r'])); + } +} + pub fn detach_from_terminal_signals(command: &mut Command) { #[cfg(unix)] command.process_group(0); diff --git a/src/utils/shell.rs b/src/utils/shell.rs index 812edbb..9e49cd9 100644 --- a/src/utils/shell.rs +++ b/src/utils/shell.rs @@ -102,6 +102,16 @@ impl Shell { } } + /// Writes a forwarded child process line to stdout regardless of verbosity. + pub fn echo(&mut self, line: M) -> anyhow::Result<()> + where + M: fmt::Display + { + writeln!(self.stdout, "{line}")?; + + Ok(()) + } + pub fn status(&mut self, status: S, message: M) -> anyhow::Result<()> where S: fmt::Display, From 393170ad14b8c94e192b9985b6a29e2e917d4a6d Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 02:08:40 -0400 Subject: [PATCH 02/17] Test the child output line reader --- src/utils/mod.rs | 3 +++ src/utils/tests.rs | 11 +++++++++++ 2 files changed, 14 insertions(+) create mode 100644 src/utils/tests.rs diff --git a/src/utils/mod.rs b/src/utils/mod.rs index a320b74..3104721 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -14,3 +14,6 @@ pub mod shell; pub mod verbosity; pub use case_iterable::CaseIterable; + +#[cfg(test)] +mod tests; diff --git a/src/utils/tests.rs b/src/utils/tests.rs new file mode 100644 index 0000000..538696a --- /dev/null +++ b/src/utils/tests.rs @@ -0,0 +1,11 @@ +use crate::utils::process::for_each_line; + +#[tokio::test] +async fn lines_are_split_and_decoded_leniently() { + let output: &[u8] = b"first\r\nsec\xffond\n\nlast"; + let mut lines = Vec::new(); + + for_each_line(output, |line| lines.push(line.to_string())).await; + + assert_eq!(lines, ["first", "sec\u{fffd}ond", "", "last"]); +} From b0f80ae7098c216baae7b42ad626736c88fce0d9 Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:14:05 -0400 Subject: [PATCH 03/17] Render forwarded lines in the status form - Minecraft lines carry a level-colored dot, tunnel lines the provider label - autosave completion prints the time since the save started --- docs/src/commands/run.md | 25 ++++++++++------- docs/src/guides/systemd.md | 8 +++--- docs/src/guides/tunnel.md | 4 +-- src/cli/styles.rs | 8 ++++++ src/minecraft/log.rs | 50 +++++++++++++++++++++++++++------- src/minecraft/log4j.rs | 2 +- src/minecraft/tests.rs | 55 +++++++++++++++++++++++++++----------- src/ops/run.rs | 46 +++++++++++++++++++++++++++++-- src/ops/tunnel.rs | 20 +++++++++----- src/tunnel/provider.rs | 8 ++++++ src/utils/shell.rs | 27 ++++++++++++++----- 11 files changed, 197 insertions(+), 56 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index 976e5d6..d368c8f 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -22,17 +22,22 @@ is hidden unless `--server-logs` is passed; it is always written to the remote console (RCON) for live administration. mc reads the console output whether or not it is shown, and prints a status -line for the events it recognizes: a player joining or leaving the instance. -With `--server-logs`, the console line that produced the event is shown as -well, ahead of the status line. +line for the events it recognizes: a player joining or leaving the instance, +and a completed world save with the time it took. With `--server-logs`, the +console line that produced the event is shown as well, ahead of the status +line. ```text -MINECRAFT [INFO] [Server thread]: Notch joined the game + Minecraft ● Notch joined the game Joined Notch + Minecraft ● ThreadedAnvilChunkStorage: All dimensions are saved + Autosave completed in 1.2s ``` -Console lines are prefixed with `MINECRAFT [LEVEL] [thread]:` when mc -configures the instance's logging. When the `jvm-arguments` in the manifest +Shown console lines take the same form as mc's own status lines: a +`Minecraft` label, a dot colored by the line's log level, and the message. +Lines without a level, such as the continuation lines of a stack trace, keep +the label and print as they are. When the `jvm-arguments` in the manifest select a logging configuration file of their own, the console keeps that file's format and no events are recognized. @@ -40,7 +45,7 @@ With a `tunnel` section, the tunnel agent starts beside the instance and is restarted if it stops on its own; the public address is printed at startup. The agent's output is hidden unless `--tunnel-logs` is passed, and goes to `.tunnel/playitd.log` otherwise. When shown, every agent line is -prefixed with `TUNNEL:`. The first start from a terminal prints a claim link to approve in a +labeled with the provider's name, in the same form as the console lines. The first start from a terminal prints a claim link to approve in a browser and saves the resulting secret under `.tunnel/`. Without a terminal and without a secret, `mc run` fails with instructions rather than waiting — see [Tunnels](../guides/tunnel.md). @@ -70,6 +75,6 @@ instance's exit code. - `--manifest-path ` — path to `mc.kdl`. Defaults to `./mc.kdl`. - `--lockfile-path ` — path to `mc.lock`. Defaults to `./mc.lock`. - `--server-logs` — show the instance's console output in the terminal, - prefixed with `MINECRAFT`. Recognized events are printed either way. -- `--tunnel-logs` — show the tunnel agent's output in the terminal, - prefixed with `TUNNEL:`. + labeled `Minecraft`. Recognized events are printed either way. +- `--tunnel-logs` — show the tunnel agent's output in the terminal, labeled + with the provider's name. diff --git a/docs/src/guides/systemd.md b/docs/src/guides/systemd.md index 40645ca..717730f 100644 --- a/docs/src/guides/systemd.md +++ b/docs/src/guides/systemd.md @@ -71,13 +71,13 @@ tunnel agent as well): journalctl -u myserver -f ``` -Each forwarded line carries the prefix of its source, `MINECRAFT` or -`TUNNEL`, so the two are easy to tell apart from mc's own status lines: +Each forwarded line is labeled with its source, so the instance, the tunnel +agent, and mc's own status lines are easy to tell apart: ```text -MINECRAFT [INFO] [Server thread]: Notch joined the game + Minecraft ● Notch joined the game Joined Notch -TUNNEL: 2026-09-09T12:34:56.123456Z INFO playit_agent_core::tunnel: connected + Playit 2026-09-09T12:34:56.123456Z INFO playit_agent_core::tunnel: connected ``` mc's own log verbosity is controlled by the global `--verbose` flag; the diff --git a/docs/src/guides/tunnel.md b/docs/src/guides/tunnel.md index 035a8b8..aa0c00b 100644 --- a/docs/src/guides/tunnel.md +++ b/docs/src/guides/tunnel.md @@ -56,8 +56,8 @@ new agent on the next start. The agent runs for as long as the instance does and is restarted by mc if it stops on its own. Its output is written to `.tunnel/playitd.log`, or shown in -the terminal, prefixed with `TUNNEL:`, when the instance is started with -`mc run --tunnel-logs`. +the terminal, labeled with the provider's name, when the instance is started +with `mc run --tunnel-logs`. Stopping the instance stops the agent. Traffic between players and the instance flows through the provider's relay, diff --git a/src/cli/styles.rs b/src/cli/styles.rs index 2ae9704..1295bcc 100644 --- a/src/cli/styles.rs +++ b/src/cli/styles.rs @@ -1,3 +1,4 @@ +use anstyle::Ansi256Color; use anstyle::AnsiColor; use anstyle::Effects; use anstyle::Style; @@ -11,6 +12,13 @@ pub const ERROR: Style = annotate_snippets::renderer::DEFAULT_ERROR_STYLE; pub const WARN: Style = annotate_snippets::renderer::DEFAULT_WARNING_STYLE; pub const VALID: Style = AnsiColor::BrightCyan.on_default().effects(Effects::BOLD); pub const INVALID: Style = annotate_snippets::renderer::DEFAULT_WARNING_STYLE; +pub const MINECRAFT: Style = AnsiColor::Green.on_default().effects(Effects::BOLD); +pub const TUNNEL: Style = Ansi256Color(208).on_default().effects(Effects::BOLD); +pub const LOG_ERROR: Style = AnsiColor::Red.on_default(); +pub const LOG_WARN: Style = AnsiColor::Yellow.on_default(); +pub const LOG_INFO: Style = AnsiColor::Green.on_default(); +pub const LOG_DEBUG: Style = AnsiColor::Cyan.on_default(); +pub const LOG_TRACE: Style = Style::new().effects(Effects::DIMMED); pub fn styles() -> Styles { Styles::styled() diff --git a/src/minecraft/log.rs b/src/minecraft/log.rs index d90a957..620e37b 100644 --- a/src/minecraft/log.rs +++ b/src/minecraft/log.rs @@ -1,19 +1,43 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServerLogLevel { + Fatal, + Error, + Warn, + Info, + Debug, + Trace +} + +impl ServerLogLevel { + fn parse(level: &str) -> Option { + match level { + "FATAL" => Some(ServerLogLevel::Fatal), + "ERROR" => Some(ServerLogLevel::Error), + "WARN" => Some(ServerLogLevel::Warn), + "INFO" => Some(ServerLogLevel::Info), + "DEBUG" => Some(ServerLogLevel::Debug), + "TRACE" => Some(ServerLogLevel::Trace), + _ => None + } + } +} + /// A console line as printed with the pattern mc writes into `log4j2.xml`: -/// `MINECRAFT [LEVEL] [thread]: message`. +/// `[LEVEL] [thread]: message`. pub struct ServerLogLine<'a> { - pub level: &'a str, + pub level: ServerLogLevel, pub thread: &'a str, pub message: &'a str } impl<'a> ServerLogLine<'a> { pub fn parse(line: &'a str) -> Option { - let rest = line.strip_prefix("MINECRAFT [")?; + let rest = line.strip_prefix('[')?; let (level, rest) = rest.split_once("] [")?; let (thread, message) = rest.split_once("]: ")?; Some(ServerLogLine { - level, + level: ServerLogLevel::parse(level)?, thread, message }) @@ -23,14 +47,14 @@ impl<'a> ServerLogLine<'a> { #[derive(Debug, PartialEq, Eq)] pub enum ServerLogEvent { Joined(String), - Left(String) + Left(String), + SaveStarted, + SaveCompleted } impl ServerLogEvent { - pub fn recognize(line: &str) -> Option { - let line = ServerLogLine::parse(line)?; - - if line.level != "INFO" || line.thread != "Server thread" { + pub fn recognize(line: &ServerLogLine<'_>) -> Option { + if line.level != ServerLogLevel::Info || line.thread != "Server thread" { return None; } @@ -42,6 +66,14 @@ impl ServerLogEvent { return player_name(name).map(ServerLogEvent::Left); } + if line.message.starts_with("Saving chunks for level '") { + return Some(ServerLogEvent::SaveStarted); + } + + if line.message == "ThreadedAnvilChunkStorage: All dimensions are saved" { + return Some(ServerLogEvent::SaveCompleted); + } + None } } diff --git a/src/minecraft/log4j.rs b/src/minecraft/log4j.rs index 25ea184..5baff39 100644 --- a/src/minecraft/log4j.rs +++ b/src/minecraft/log4j.rs @@ -2,7 +2,7 @@ const TEMPLATE: &str = r#" - + diff --git a/src/minecraft/tests.rs b/src/minecraft/tests.rs index eb188f9..48d7a58 100644 --- a/src/minecraft/tests.rs +++ b/src/minecraft/tests.rs @@ -5,6 +5,7 @@ use chrono::Utc; use uuid::Uuid; use crate::minecraft::log::ServerLogEvent; +use crate::minecraft::log::ServerLogLevel; use crate::minecraft::log::ServerLogLine; use crate::minecraft::players::BanDetails; use crate::minecraft::players::BanEntry; @@ -86,49 +87,73 @@ fn unknown_property_keys_are_reported() -> McResult<()> { #[test] fn console_lines_split_into_level_thread_and_message() { - let line = ServerLogLine::parse( - "MINECRAFT [WARN] [Worker-Main-3]: Can't keep up! Is the server overloaded?" - ); + let line = + ServerLogLine::parse("[WARN] [Worker-Main-3]: Can't keep up! Is the server overloaded?"); assert!(line.is_some_and(|line| { - line.level == "WARN" + line.level == ServerLogLevel::Warn && line.thread == "Worker-Main-3" && line.message == "Can't keep up! Is the server overloaded?" })); assert!( ServerLogLine::parse("[12:34:56] [Server thread/INFO]: Notch joined the game").is_none() ); + assert!(ServerLogLine::parse("[LOUD] [Server thread]: Notch joined the game").is_none()); assert!( ServerLogLine::parse("WARNING: A terminally deprecated method has been called").is_none() ); + assert!(ServerLogLine::parse("\tat net.minecraft.server.MinecraftServer.run").is_none()); +} + +fn recognize(line: &str) -> Option { + ServerLogLine::parse(line) + .as_ref() + .and_then(ServerLogEvent::recognize) } #[test] -fn join_and_leave_events_are_recognized() { +fn join_leave_and_save_events_are_recognized() { assert_eq!( - ServerLogEvent::recognize("MINECRAFT [INFO] [Server thread]: Notch joined the game"), + recognize("[INFO] [Server thread]: Notch joined the game"), Some(ServerLogEvent::Joined(String::from("Notch"))) ); assert_eq!( - ServerLogEvent::recognize("MINECRAFT [INFO] [Server thread]: Notch left the game"), + recognize("[INFO] [Server thread]: Notch left the game"), Some(ServerLogEvent::Left(String::from("Notch"))) ); + assert_eq!( + recognize( + "[INFO] [Server thread]: Saving chunks for level 'ServerLevel[world]'/minecraft:overworld" + ), + Some(ServerLogEvent::SaveStarted) + ); + assert_eq!( + recognize( + "[INFO] [Server thread]: ThreadedAnvilChunkStorage (world): All chunks are saved" + ), + None + ); + assert_eq!( + recognize("[INFO] [Server thread]: ThreadedAnvilChunkStorage: All dimensions are saved"), + Some(ServerLogEvent::SaveCompleted) + ); } #[test] fn player_controlled_text_is_not_an_event() { let lines = [ - "MINECRAFT [INFO] [Server thread]: Steve joined the game", - "MINECRAFT [INFO] [Server thread]: joined the game", - "MINECRAFT [INFO] [Server thread]: [Server] Steve left the game", - "MINECRAFT [INFO] [Server thread]: * Notch left the game", - "MINECRAFT [INFO] [Server thread]: joined the game", - "MINECRAFT [WARN] [Server thread]: Notch joined the game", - "MINECRAFT [INFO] [Netty Server IO #1]: Notch joined the game", + "[INFO] [Server thread]: Steve joined the game", + "[INFO] [Server thread]: joined the game", + "[INFO] [Server thread]: [Server] Steve left the game", + "[INFO] [Server thread]: * Notch left the game", + "[INFO] [Server thread]: joined the game", + "[INFO] [Server thread]: Saving chunks for level 'x'", + "[WARN] [Server thread]: Notch joined the game", + "[INFO] [Netty Server IO #1]: Notch joined the game", "[12:34:56] [Server thread/INFO]: Notch joined the game" ]; for line in lines { - assert_eq!(ServerLogEvent::recognize(line), None, "{}", line); + assert_eq!(recognize(line), None, "{}", line); } } diff --git a/src/ops/run.rs b/src/ops/run.rs index 8850759..ae23a09 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -7,7 +7,9 @@ use std::sync::Arc; use std::sync::Mutex; use std::sync::PoisonError; use std::time::Duration; +use std::time::Instant; +use anstyle::Style; use anyhow::Context; use tokio::io::AsyncRead; use tokio::io::AsyncWriteExt; @@ -16,12 +18,15 @@ use tokio_cron_scheduler::Job; use tokio_cron_scheduler::JobScheduler; use tokio_util::sync::CancellationToken; +use crate::cli::styles; use crate::context::McContext; use crate::env::Architecture; use crate::env::Platform; use crate::manifest::Manifest; use crate::manifest::ManifestPaths; use crate::minecraft::log::ServerLogEvent; +use crate::minecraft::log::ServerLogLevel; +use crate::minecraft::log::ServerLogLine; use crate::minecraft::log4j; use crate::minecraft::server_properties::ManagedServerProperties; use crate::minecraft::server_properties::PropertyEntries; @@ -70,6 +75,16 @@ pub struct RunOptions { pub tunnel_logs: bool } +fn level_style(level: ServerLogLevel) -> &'static Style { + match level { + ServerLogLevel::Fatal | ServerLogLevel::Error => &styles::LOG_ERROR, + ServerLogLevel::Warn => &styles::LOG_WARN, + ServerLogLevel::Info => &styles::LOG_INFO, + ServerLogLevel::Debug => &styles::LOG_DEBUG, + ServerLogLevel::Trace => &styles::LOG_TRACE + } +} + /// Drains one of the server's output pipes, echoing every line when /// `echo` is set and printing the events it recognizes either way. fn forward_server_output(shell: Arc>, output: R, echo: bool) -> JoinHandle<()> @@ -77,17 +92,42 @@ where R: AsyncRead + Unpin + Send + 'static { tokio::spawn(async move { + let mut save_started: Option = None; + utils::process::for_each_line(output, |line| { - let event = ServerLogEvent::recognize(line); + let parsed = ServerLogLine::parse(line); + let event = parsed.as_ref().and_then(ServerLogEvent::recognize); let mut shell = shell.lock().unwrap_or_else(PoisonError::into_inner); if echo { - _ = shell.echo(line); + match &parsed { + Some(parsed) => { + let dot = level_style(parsed.level); + + _ = shell.echo( + "Minecraft", + format!("{dot}●{dot:#} {}", parsed.message), + &styles::MINECRAFT + ); + } + None => _ = shell.echo("Minecraft", line, &styles::MINECRAFT) + } } match event { Some(ServerLogEvent::Joined(name)) => _ = shell.status("Joined", name), Some(ServerLogEvent::Left(name)) => _ = shell.status("Left", name), + Some(ServerLogEvent::SaveStarted) => { + save_started.get_or_insert_with(Instant::now); + } + Some(ServerLogEvent::SaveCompleted) => { + let message = match save_started.take() { + Some(started) => format!("completed in {:.1?}", started.elapsed()), + None => String::from("completed") + }; + + _ = shell.status("Autosave", message); + } None => {} } }) @@ -313,6 +353,7 @@ pub async fn run(context: &mut McContext, options: &RunOptions) -> McResult McResult } @@ -381,16 +384,19 @@ async fn stop_agent(child: &mut Child) { let _ = child.kill().await; } -fn forward_agent_output(shell: Arc>, output: R) +// The agent colors its own output; stripping it keeps the line in mc's +// style rather than a mix of both. +fn forward_agent_output(shell: Arc>, label: &'static str, output: R) where R: AsyncRead + Unpin + Send + 'static { tokio::spawn(async move { utils::process::for_each_line(output, |line| { - _ = shell - .lock() - .unwrap_or_else(PoisonError::into_inner) - .echo(format!("TUNNEL: {}", line)); + _ = shell.lock().unwrap_or_else(PoisonError::into_inner).echo( + label, + strip_str(line), + &styles::TUNNEL + ); }) .await }); @@ -433,11 +439,11 @@ pub fn supervise( }; if let Some(stdout) = child.stdout.take() { - forward_agent_output(shell.clone(), stdout); + forward_agent_output(shell.clone(), options.provider.label(), stdout); } if let Some(stderr) = child.stderr.take() { - forward_agent_output(shell.clone(), stderr); + forward_agent_output(shell.clone(), options.provider.label(), stderr); } if let Some(address) = options.address.as_ref().filter(|_| !announced) { diff --git a/src/tunnel/provider.rs b/src/tunnel/provider.rs index 047f7a4..2e92814 100644 --- a/src/tunnel/provider.rs +++ b/src/tunnel/provider.rs @@ -9,6 +9,14 @@ pub enum TunnelProviderKind { Playit } +impl TunnelProviderKind { + pub fn label(&self) -> &'static str { + match self { + TunnelProviderKind::Playit => "Playit" + } + } +} + impl fmt::Display for TunnelProviderKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let provider = match self { diff --git a/src/utils/shell.rs b/src/utils/shell.rs index 9e49cd9..9707039 100644 --- a/src/utils/shell.rs +++ b/src/utils/shell.rs @@ -64,13 +64,12 @@ impl Shell { self.stderr = AutoStream::new(std::io::stderr(), self.color_choice); } - fn output_stderr( - &mut self, + fn render( status: &dyn fmt::Display, message: Option<&dyn fmt::Display>, style: &Style, justified: bool - ) -> anyhow::Result<()> { + ) -> anyhow::Result> { let mut buffer = Vec::new(); if justified { @@ -84,6 +83,18 @@ impl Shell { None => write!(buffer, " ")? } + Ok(buffer) + } + + fn output_stderr( + &mut self, + status: &dyn fmt::Display, + message: Option<&dyn fmt::Display>, + style: &Style, + justified: bool + ) -> anyhow::Result<()> { + let buffer = Shell::render(status, message, style, justified)?; + self.stderr.write_all(&buffer)?; Ok(()) @@ -102,12 +113,16 @@ impl Shell { } } - /// Writes a forwarded child process line to stdout regardless of verbosity. - pub fn echo(&mut self, line: M) -> anyhow::Result<()> + /// Writes a forwarded child process line to stdout in the status form, + /// regardless of verbosity. + pub fn echo(&mut self, status: S, message: M, style: &Style) -> anyhow::Result<()> where + S: fmt::Display, M: fmt::Display { - writeln!(self.stdout, "{line}")?; + let buffer = Shell::render(&status, Some(&message), style, true)?; + + self.stdout.write_all(&buffer)?; Ok(()) } From ddde02b64ee775fa23cc3f8f6b5f56bee5a2146e Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:17:27 -0400 Subject: [PATCH 04/17] Rename the save status line --- docs/src/commands/run.md | 4 ++-- src/ops/run.rs | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index d368c8f..556e379 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -23,7 +23,7 @@ the remote console (RCON) for live administration. mc reads the console output whether or not it is shown, and prints a status line for the events it recognizes: a player joining or leaving the instance, -and a completed world save with the time it took. With `--server-logs`, the +and a world save flushed to disk with the time it took. With `--server-logs`, the console line that produced the event is shown as well, ahead of the status line. @@ -31,7 +31,7 @@ line. Minecraft ● Notch joined the game Joined Notch Minecraft ● ThreadedAnvilChunkStorage: All dimensions are saved - Autosave completed in 1.2s + Save all dimensions flushed to disk in 1.2s ``` Shown console lines take the same form as mc's own status lines: a diff --git a/src/ops/run.rs b/src/ops/run.rs index ae23a09..c1a3477 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -122,11 +122,14 @@ where } Some(ServerLogEvent::SaveCompleted) => { let message = match save_started.take() { - Some(started) => format!("completed in {:.1?}", started.elapsed()), - None => String::from("completed") + Some(started) => format!( + "all dimensions flushed to disk in {:.1?}", + started.elapsed() + ), + None => String::from("all dimensions flushed to disk") }; - _ = shell.status("Autosave", message); + _ = shell.status("Save", message); } None => {} } From 35f617d4eac9e5524ceb0f0ee36a16b5b39349a1 Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:18:43 -0400 Subject: [PATCH 05/17] Print Minecraft lines flush left --- src/ops/run.rs | 5 +++-- src/utils/shell.rs | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/ops/run.rs b/src/ops/run.rs index c1a3477..15712eb 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -107,10 +107,11 @@ where _ = shell.echo( "Minecraft", format!("{dot}●{dot:#} {}", parsed.message), - &styles::MINECRAFT + &styles::MINECRAFT, + false ); } - None => _ = shell.echo("Minecraft", line, &styles::MINECRAFT) + None => _ = shell.echo("Minecraft", line, &styles::MINECRAFT, false) } } diff --git a/src/utils/shell.rs b/src/utils/shell.rs index 9707039..f037cfa 100644 --- a/src/utils/shell.rs +++ b/src/utils/shell.rs @@ -113,14 +113,25 @@ impl Shell { } } - /// Writes a forwarded child process line to stdout in the status form, - /// regardless of verbosity. - pub fn echo(&mut self, status: S, message: M, style: &Style) -> anyhow::Result<()> + /// Writes a forwarded child process line to stdout regardless of + /// verbosity, in the status form when `justified` and flush left + /// otherwise. + pub fn echo( + &mut self, + status: S, + message: M, + style: &Style, + justified: bool + ) -> anyhow::Result<()> where S: fmt::Display, M: fmt::Display { - let buffer = Shell::render(&status, Some(&message), style, true)?; + let buffer = if justified { + Shell::render(&status, Some(&message), style, true)? + } else { + format!("{style}{status}{style:#} {message}\n").into_bytes() + }; self.stdout.write_all(&buffer)?; From e8ddd173a2aca9ff3b761d5001586ca4d4e64886 Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:19:08 -0400 Subject: [PATCH 06/17] Fix the tunnel echo call and darken the Minecraft label --- docs/src/commands/run.md | 8 ++++---- docs/src/guides/systemd.md | 2 +- src/cli/styles.rs | 2 +- src/ops/tunnel.rs | 3 ++- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index 556e379..14e1290 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -28,14 +28,14 @@ console line that produced the event is shown as well, ahead of the status line. ```text - Minecraft ● Notch joined the game +Minecraft ● Notch joined the game Joined Notch - Minecraft ● ThreadedAnvilChunkStorage: All dimensions are saved +Minecraft ● ThreadedAnvilChunkStorage: All dimensions are saved Save all dimensions flushed to disk in 1.2s ``` -Shown console lines take the same form as mc's own status lines: a -`Minecraft` label, a dot colored by the line's log level, and the message. +Shown console lines carry a `Minecraft` label, a dot colored by the line's +log level, and the message. Lines without a level, such as the continuation lines of a stack trace, keep the label and print as they are. When the `jvm-arguments` in the manifest select a logging configuration file of their own, the console keeps that diff --git a/docs/src/guides/systemd.md b/docs/src/guides/systemd.md index 717730f..d895934 100644 --- a/docs/src/guides/systemd.md +++ b/docs/src/guides/systemd.md @@ -75,7 +75,7 @@ Each forwarded line is labeled with its source, so the instance, the tunnel agent, and mc's own status lines are easy to tell apart: ```text - Minecraft ● Notch joined the game +Minecraft ● Notch joined the game Joined Notch Playit 2026-09-09T12:34:56.123456Z INFO playit_agent_core::tunnel: connected ``` diff --git a/src/cli/styles.rs b/src/cli/styles.rs index 1295bcc..0ed557d 100644 --- a/src/cli/styles.rs +++ b/src/cli/styles.rs @@ -12,7 +12,7 @@ pub const ERROR: Style = annotate_snippets::renderer::DEFAULT_ERROR_STYLE; pub const WARN: Style = annotate_snippets::renderer::DEFAULT_WARNING_STYLE; pub const VALID: Style = AnsiColor::BrightCyan.on_default().effects(Effects::BOLD); pub const INVALID: Style = annotate_snippets::renderer::DEFAULT_WARNING_STYLE; -pub const MINECRAFT: Style = AnsiColor::Green.on_default().effects(Effects::BOLD); +pub const MINECRAFT: Style = Ansi256Color(28).on_default().effects(Effects::BOLD); pub const TUNNEL: Style = Ansi256Color(208).on_default().effects(Effects::BOLD); pub const LOG_ERROR: Style = AnsiColor::Red.on_default(); pub const LOG_WARN: Style = AnsiColor::Yellow.on_default(); diff --git a/src/ops/tunnel.rs b/src/ops/tunnel.rs index 54a6676..5c0e68e 100644 --- a/src/ops/tunnel.rs +++ b/src/ops/tunnel.rs @@ -395,7 +395,8 @@ where _ = shell.lock().unwrap_or_else(PoisonError::into_inner).echo( label, strip_str(line), - &styles::TUNNEL + &styles::TUNNEL, + true ); }) .await From 3d44179f8fdb6373f68c7cda4eadf70f7addf3d6 Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:19:54 -0400 Subject: [PATCH 07/17] Print tunnel lines flush left --- docs/src/commands/run.md | 2 +- docs/src/guides/systemd.md | 2 +- src/ops/run.rs | 5 ++--- src/ops/tunnel.rs | 3 +-- src/utils/shell.rs | 19 +++---------------- 5 files changed, 8 insertions(+), 23 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index 14e1290..ab10eb5 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -45,7 +45,7 @@ With a `tunnel` section, the tunnel agent starts beside the instance and is restarted if it stops on its own; the public address is printed at startup. The agent's output is hidden unless `--tunnel-logs` is passed, and goes to `.tunnel/playitd.log` otherwise. When shown, every agent line is -labeled with the provider's name, in the same form as the console lines. The first start from a terminal prints a claim link to approve in a +labeled with the provider's name, like the console lines. The first start from a terminal prints a claim link to approve in a browser and saves the resulting secret under `.tunnel/`. Without a terminal and without a secret, `mc run` fails with instructions rather than waiting — see [Tunnels](../guides/tunnel.md). diff --git a/docs/src/guides/systemd.md b/docs/src/guides/systemd.md index d895934..1d453a5 100644 --- a/docs/src/guides/systemd.md +++ b/docs/src/guides/systemd.md @@ -77,7 +77,7 @@ agent, and mc's own status lines are easy to tell apart: ```text Minecraft ● Notch joined the game Joined Notch - Playit 2026-09-09T12:34:56.123456Z INFO playit_agent_core::tunnel: connected +Playit 2026-09-09T12:34:56.123456Z INFO playit_agent_core::tunnel: connected ``` mc's own log verbosity is controlled by the global `--verbose` flag; the diff --git a/src/ops/run.rs b/src/ops/run.rs index 15712eb..c1a3477 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -107,11 +107,10 @@ where _ = shell.echo( "Minecraft", format!("{dot}●{dot:#} {}", parsed.message), - &styles::MINECRAFT, - false + &styles::MINECRAFT ); } - None => _ = shell.echo("Minecraft", line, &styles::MINECRAFT, false) + None => _ = shell.echo("Minecraft", line, &styles::MINECRAFT) } } diff --git a/src/ops/tunnel.rs b/src/ops/tunnel.rs index 5c0e68e..54a6676 100644 --- a/src/ops/tunnel.rs +++ b/src/ops/tunnel.rs @@ -395,8 +395,7 @@ where _ = shell.lock().unwrap_or_else(PoisonError::into_inner).echo( label, strip_str(line), - &styles::TUNNEL, - true + &styles::TUNNEL ); }) .await diff --git a/src/utils/shell.rs b/src/utils/shell.rs index f037cfa..e95e599 100644 --- a/src/utils/shell.rs +++ b/src/utils/shell.rs @@ -114,26 +114,13 @@ impl Shell { } /// Writes a forwarded child process line to stdout regardless of - /// verbosity, in the status form when `justified` and flush left - /// otherwise. - pub fn echo( - &mut self, - status: S, - message: M, - style: &Style, - justified: bool - ) -> anyhow::Result<()> + /// verbosity. + pub fn echo(&mut self, status: S, message: M, style: &Style) -> anyhow::Result<()> where S: fmt::Display, M: fmt::Display { - let buffer = if justified { - Shell::render(&status, Some(&message), style, true)? - } else { - format!("{style}{status}{style:#} {message}\n").into_bytes() - }; - - self.stdout.write_all(&buffer)?; + writeln!(self.stdout, "{style}{status}{style:#} {message}")?; Ok(()) } From 5cd37abf586bda47fffa4539f031776bcbad3488 Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:24:28 -0400 Subject: [PATCH 08/17] Give JVM warnings and errors a level dot --- src/minecraft/log.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/minecraft/log.rs b/src/minecraft/log.rs index 620e37b..1203a9f 100644 --- a/src/minecraft/log.rs +++ b/src/minecraft/log.rs @@ -22,23 +22,44 @@ impl ServerLogLevel { } } -/// A console line as printed with the pattern mc writes into `log4j2.xml`: -/// `[LEVEL] [thread]: message`. +/// A console line, either printed with the pattern mc writes into +/// `log4j2.xml` (`[LEVEL] [thread]: message`) or by the JVM itself outside +/// the logger (`WARNING: message`, `ERROR: message`), which has no thread. pub struct ServerLogLine<'a> { pub level: ServerLogLevel, - pub thread: &'a str, + pub thread: Option<&'a str>, pub message: &'a str } impl<'a> ServerLogLine<'a> { pub fn parse(line: &'a str) -> Option { + Self::parse_logger(line).or_else(|| Self::parse_jvm(line)) + } + + fn parse_logger(line: &'a str) -> Option { let rest = line.strip_prefix('[')?; let (level, rest) = rest.split_once("] [")?; let (thread, message) = rest.split_once("]: ")?; Some(ServerLogLine { level: ServerLogLevel::parse(level)?, - thread, + thread: Some(thread), + message + }) + } + + fn parse_jvm(line: &'a str) -> Option { + let (level, message) = if let Some(message) = line.strip_prefix("WARNING: ") { + (ServerLogLevel::Warn, message) + } else if let Some(message) = line.strip_prefix("ERROR: ") { + (ServerLogLevel::Error, message) + } else { + return None; + }; + + Some(ServerLogLine { + level, + thread: None, message }) } @@ -54,7 +75,7 @@ pub enum ServerLogEvent { impl ServerLogEvent { pub fn recognize(line: &ServerLogLine<'_>) -> Option { - if line.level != ServerLogLevel::Info || line.thread != "Server thread" { + if line.level != ServerLogLevel::Info || line.thread != Some("Server thread") { return None; } From e6889cc5d528516a134852f56b96814f2564e12d Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:25:05 -0400 Subject: [PATCH 09/17] Fix the console line tests for the JVM shape --- docs/src/commands/run.md | 3 ++- src/minecraft/tests.rs | 27 ++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index ab10eb5..2395341 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -35,7 +35,8 @@ Minecraft ● ThreadedAnvilChunkStorage: All dimensions are saved ``` Shown console lines carry a `Minecraft` label, a dot colored by the line's -log level, and the message. +log level, and the message. Warnings and errors the Java runtime prints +outside the logger get their dot from the `WARNING:` or `ERROR:` prefix. Lines without a level, such as the continuation lines of a stack trace, keep the label and print as they are. When the `jvm-arguments` in the manifest select a logging configuration file of their own, the console keeps that diff --git a/src/minecraft/tests.rs b/src/minecraft/tests.rs index 48d7a58..ab39a9f 100644 --- a/src/minecraft/tests.rs +++ b/src/minecraft/tests.rs @@ -92,17 +92,38 @@ fn console_lines_split_into_level_thread_and_message() { assert!(line.is_some_and(|line| { line.level == ServerLogLevel::Warn - && line.thread == "Worker-Main-3" + && line.thread == Some("Worker-Main-3") && line.message == "Can't keep up! Is the server overloaded?" })); assert!( ServerLogLine::parse("[12:34:56] [Server thread/INFO]: Notch joined the game").is_none() ); assert!(ServerLogLine::parse("[LOUD] [Server thread]: Notch joined the game").is_none()); + assert!(ServerLogLine::parse("\tat net.minecraft.server.MinecraftServer.run").is_none()); assert!( - ServerLogLine::parse("WARNING: A terminally deprecated method has been called").is_none() + ServerLogLine::parse( + "Starting net.fabricmc.loader.impl.game.minecraft.BundlerClassPathCapture" + ) + .is_none() ); - assert!(ServerLogLine::parse("\tat net.minecraft.server.MinecraftServer.run").is_none()); +} + +#[test] +fn jvm_lines_carry_a_level_but_no_thread() { + let line = ServerLogLine::parse( + "WARNING: A terminally deprecated method in sun.misc.Unsafe has been called" + ); + + assert!(line.is_some_and(|line| { + line.level == ServerLogLevel::Warn + && line.thread.is_none() + && line.message == "A terminally deprecated method in sun.misc.Unsafe has been called" + })); + + let line = ServerLogLine::parse("ERROR: Could not create the Java Virtual Machine."); + + assert!(line.is_some_and(|line| line.level == ServerLogLevel::Error && line.thread.is_none())); + assert_eq!(recognize("WARNING: Notch joined the game"), None); } fn recognize(line: &str) -> Option { From 0242e3c7bdb6eb7cc7bc9a0aba0b960eeee71b1a Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:26:28 -0400 Subject: [PATCH 10/17] Warn on abnormal player disconnects --- docs/src/commands/run.md | 4 +++- src/minecraft/log.rs | 8 ++++++++ src/minecraft/tests.rs | 8 ++++++++ src/ops/run.rs | 14 +++++++++++++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index 2395341..a822553 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -23,7 +23,9 @@ the remote console (RCON) for live administration. mc reads the console output whether or not it is shown, and prints a status line for the events it recognizes: a player joining or leaving the instance, -and a world save flushed to disk with the time it took. With `--server-logs`, the +and a world save flushed to disk with the time it took. A player dropped for +any reason other than quitting, such as a timeout or a kick, is reported as +a warning with the reason in place of the leave line. With `--server-logs`, the console line that produced the event is shown as well, ahead of the status line. diff --git a/src/minecraft/log.rs b/src/minecraft/log.rs index 1203a9f..48a69d2 100644 --- a/src/minecraft/log.rs +++ b/src/minecraft/log.rs @@ -69,6 +69,7 @@ impl<'a> ServerLogLine<'a> { pub enum ServerLogEvent { Joined(String), Left(String), + Disconnected { name: String, reason: String }, SaveStarted, SaveCompleted } @@ -87,6 +88,13 @@ impl ServerLogEvent { return player_name(name).map(ServerLogEvent::Left); } + if let Some((name, reason)) = line.message.split_once(" lost connection: ") { + return player_name(name).map(|name| ServerLogEvent::Disconnected { + name, + reason: reason.to_string() + }); + } + if line.message.starts_with("Saving chunks for level '") { return Some(ServerLogEvent::SaveStarted); } diff --git a/src/minecraft/tests.rs b/src/minecraft/tests.rs index ab39a9f..b2402f2 100644 --- a/src/minecraft/tests.rs +++ b/src/minecraft/tests.rs @@ -142,6 +142,13 @@ fn join_leave_and_save_events_are_recognized() { recognize("[INFO] [Server thread]: Notch left the game"), Some(ServerLogEvent::Left(String::from("Notch"))) ); + assert_eq!( + recognize("[INFO] [Server thread]: Notch lost connection: Timed out"), + Some(ServerLogEvent::Disconnected { + name: String::from("Notch"), + reason: String::from("Timed out") + }) + ); assert_eq!( recognize( "[INFO] [Server thread]: Saving chunks for level 'ServerLevel[world]'/minecraft:overworld" @@ -169,6 +176,7 @@ fn player_controlled_text_is_not_an_event() { "[INFO] [Server thread]: * Notch left the game", "[INFO] [Server thread]: joined the game", "[INFO] [Server thread]: Saving chunks for level 'x'", + "[INFO] [Server thread]: Steve lost connection: Timed out", "[WARN] [Server thread]: Notch joined the game", "[INFO] [Netty Server IO #1]: Notch joined the game", "[12:34:56] [Server thread/INFO]: Notch joined the game" diff --git a/src/ops/run.rs b/src/ops/run.rs index c1a3477..2357e8f 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::env; use std::io::ErrorKind; use std::io::IsTerminal; @@ -93,6 +94,7 @@ where { tokio::spawn(async move { let mut save_started: Option = None; + let mut warned_disconnects: HashSet = HashSet::new(); utils::process::for_each_line(output, |line| { let parsed = ServerLogLine::parse(line); @@ -116,7 +118,17 @@ where match event { Some(ServerLogEvent::Joined(name)) => _ = shell.status("Joined", name), - Some(ServerLogEvent::Left(name)) => _ = shell.status("Left", name), + Some(ServerLogEvent::Left(name)) => { + if !warned_disconnects.remove(&name) { + _ = shell.status("Left", name); + } + } + Some(ServerLogEvent::Disconnected { name, reason }) => { + if reason != "Disconnected" { + _ = shell.warn(format!("{} lost connection: {}", name, reason)); + warned_disconnects.insert(name); + } + } Some(ServerLogEvent::SaveStarted) => { save_started.get_or_insert_with(Instant::now); } From 3fe529c51cf67b0c00ffd04b8f87ae0c759eaa13 Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:28:56 -0400 Subject: [PATCH 11/17] Rename the save status verb to Saved --- docs/src/commands/run.md | 2 +- src/ops/run.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index a822553..3ada8eb 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -33,7 +33,7 @@ line. Minecraft ● Notch joined the game Joined Notch Minecraft ● ThreadedAnvilChunkStorage: All dimensions are saved - Save all dimensions flushed to disk in 1.2s + Saved all dimensions flushed to disk in 1.2s ``` Shown console lines carry a `Minecraft` label, a dot colored by the line's diff --git a/src/ops/run.rs b/src/ops/run.rs index 2357e8f..bd513d3 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -141,7 +141,7 @@ where None => String::from("all dimensions flushed to disk") }; - _ = shell.status("Save", message); + _ = shell.status("Saved", message); } None => {} } From 05174291e630e1fc9f56d97bf4e02a4c274b3e89 Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:40:26 -0400 Subject: [PATCH 12/17] Keep the server stopping out of disconnect warnings --- docs/src/commands/run.md | 5 +++-- src/ops/run.rs | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index 3ada8eb..7ca3a95 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -24,8 +24,9 @@ the remote console (RCON) for live administration. mc reads the console output whether or not it is shown, and prints a status line for the events it recognizes: a player joining or leaving the instance, and a world save flushed to disk with the time it took. A player dropped for -any reason other than quitting, such as a timeout or a kick, is reported as -a warning with the reason in place of the leave line. With `--server-logs`, the +any reason other than quitting or the instance stopping, such as a timeout +or a kick, is reported as a warning with the reason in place of the leave +line. With `--server-logs`, the console line that produced the event is shown as well, ahead of the status line. diff --git a/src/ops/run.rs b/src/ops/run.rs index bd513d3..0ec1daf 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -55,6 +55,10 @@ use crate::utils::shell::Shell; /// our grace window runs before systemd SIGKILLs the unit. const SHUTDOWN_GRACE: Duration = Duration::from_secs(85); +/// Disconnect reasons that mean the player or the server chose to end the +/// session, as opposed to a connection problem, a kick, or a rule violation. +const QUIET_DISCONNECT_REASONS: [&str; 2] = ["Disconnected", "Server closed"]; + /// server.properties keys whose values are sensitive. const SECRET_PROPERTY_KEYS: [&str; 3] = [ "management-server-secret", @@ -124,7 +128,7 @@ where } } Some(ServerLogEvent::Disconnected { name, reason }) => { - if reason != "Disconnected" { + if !QUIET_DISCONNECT_REASONS.contains(&reason.as_str()) { _ = shell.warn(format!("{} lost connection: {}", name, reason)); warned_disconnects.insert(name); } From be437f6fabf83f31c8e5f3586589fa37dc864365 Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:44:16 -0400 Subject: [PATCH 13/17] Warn on login-phase disconnects such as refused logins --- docs/src/commands/run.md | 3 ++- mise.lock | 4 ---- src/minecraft/log.rs | 19 +++++++++++++++++-- src/minecraft/tests.rs | 13 +++++++++++++ src/ops/run.rs | 25 +++++++++++++++++++++---- 5 files changed, 53 insertions(+), 11 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index 7ca3a95..5ccb2ce 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -26,7 +26,8 @@ line for the events it recognizes: a player joining or leaving the instance, and a world save flushed to disk with the time it took. A player dropped for any reason other than quitting or the instance stopping, such as a timeout or a kick, is reported as a warning with the reason in place of the leave -line. With `--server-logs`, the +line. A login turned away, by the allow list, a ban, or a full instance, +is reported the same way along with the address it came from. With `--server-logs`, the console line that produced the event is shown as well, ahead of the status line. diff --git a/mise.lock b/mise.lock index 09fc387..29b66ea 100644 --- a/mise.lock +++ b/mise.lock @@ -28,10 +28,6 @@ url_api = "https://api.github.com/repos/rust-lang/mdBook/releases/assets/4682109 version = "1.98.1" backend = "core:rust" -[[tools.rust]] -version = "nightly-2026-08-31" -backend = "core:rust" - [[tools."ubi:cross-rs/cross"]] version = "0.2.5" backend = "ubi:cross-rs/cross" diff --git a/src/minecraft/log.rs b/src/minecraft/log.rs index 48a69d2..1fc99cd 100644 --- a/src/minecraft/log.rs +++ b/src/minecraft/log.rs @@ -69,7 +69,13 @@ impl<'a> ServerLogLine<'a> { pub enum ServerLogEvent { Joined(String), Left(String), - Disconnected { name: String, reason: String }, + /// The address is present while the player is still logging in, which + /// is how refused logins are reported. + Disconnected { + name: String, + address: Option, + reason: String + }, SaveStarted, SaveCompleted } @@ -88,9 +94,18 @@ impl ServerLogEvent { return player_name(name).map(ServerLogEvent::Left); } - if let Some((name, reason)) = line.message.split_once(" lost connection: ") { + if let Some((subject, reason)) = line.message.split_once(" lost connection: ") { + let (name, address) = match subject + .strip_suffix(')') + .and_then(|subject| subject.split_once(" (/")) + { + Some((name, address)) => (name, Some(address.to_string())), + None => (subject, None) + }; + return player_name(name).map(|name| ServerLogEvent::Disconnected { name, + address, reason: reason.to_string() }); } diff --git a/src/minecraft/tests.rs b/src/minecraft/tests.rs index b2402f2..9079be3 100644 --- a/src/minecraft/tests.rs +++ b/src/minecraft/tests.rs @@ -146,9 +146,20 @@ fn join_leave_and_save_events_are_recognized() { recognize("[INFO] [Server thread]: Notch lost connection: Timed out"), Some(ServerLogEvent::Disconnected { name: String::from("Notch"), + address: None, reason: String::from("Timed out") }) ); + assert_eq!( + recognize( + "[INFO] [Server thread]: Notch (/[0:0:0:0:0:0:0:1]:49242) lost connection: You are not white-listed on this server!" + ), + Some(ServerLogEvent::Disconnected { + name: String::from("Notch"), + address: Some(String::from("[0:0:0:0:0:0:0:1]:49242")), + reason: String::from("You are not white-listed on this server!") + }) + ); assert_eq!( recognize( "[INFO] [Server thread]: Saving chunks for level 'ServerLevel[world]'/minecraft:overworld" @@ -177,6 +188,8 @@ fn player_controlled_text_is_not_an_event() { "[INFO] [Server thread]: joined the game", "[INFO] [Server thread]: Saving chunks for level 'x'", "[INFO] [Server thread]: Steve lost connection: Timed out", + "[INFO] [Server thread]: Steve (/203.0.113.7:51234) lost connection: Timed out", + "[INFO] [Server thread]: Disconnecting Notch (/203.0.113.7:51234): You are banned from this server.", "[WARN] [Server thread]: Notch joined the game", "[INFO] [Netty Server IO #1]: Notch joined the game", "[12:34:56] [Server thread/INFO]: Notch joined the game" diff --git a/src/ops/run.rs b/src/ops/run.rs index 0ec1daf..c851505 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -121,16 +121,33 @@ where } match event { - Some(ServerLogEvent::Joined(name)) => _ = shell.status("Joined", name), + Some(ServerLogEvent::Joined(name)) => { + warned_disconnects.remove(&name); + _ = shell.status("Joined", name); + } Some(ServerLogEvent::Left(name)) => { if !warned_disconnects.remove(&name) { _ = shell.status("Left", name); } } - Some(ServerLogEvent::Disconnected { name, reason }) => { + Some(ServerLogEvent::Disconnected { + name, + address, + reason + }) => { if !QUIET_DISCONNECT_REASONS.contains(&reason.as_str()) { - _ = shell.warn(format!("{} lost connection: {}", name, reason)); - warned_disconnects.insert(name); + match address { + Some(address) => { + _ = shell.warn(format!( + "{} ({}) lost connection: {}", + name, address, reason + )); + } + None => { + _ = shell.warn(format!("{} lost connection: {}", name, reason)); + warned_disconnects.insert(name); + } + } } } Some(ServerLogEvent::SaveStarted) => { From 7014fbef31dab4c8122609cbad9a1ce864f521d2 Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 03:56:16 -0400 Subject: [PATCH 14/17] Describe refused logins in plain words --- docs/src/commands/run.md | 8 +++- src/minecraft/log.rs | 80 +++++++++++++++++++++++++++++++++------- src/minecraft/tests.rs | 46 +++++++++++++++++------ src/ops/run.rs | 29 ++++++--------- 4 files changed, 119 insertions(+), 44 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index 5ccb2ce..8a15351 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -26,8 +26,12 @@ line for the events it recognizes: a player joining or leaving the instance, and a world save flushed to disk with the time it took. A player dropped for any reason other than quitting or the instance stopping, such as a timeout or a kick, is reported as a warning with the reason in place of the leave -line. A login turned away, by the allow list, a ban, or a full instance, -is reported the same way along with the address it came from. With `--server-logs`, the +line. A login turned away is reported as a warning naming the cause: the +allow list, a ban, a full instance, or a taken name. + +```text +warning: connection refused for Notch, the user is not in the allow list +``` With `--server-logs`, the console line that produced the event is shown as well, ahead of the status line. diff --git a/src/minecraft/log.rs b/src/minecraft/log.rs index 1fc99cd..8b023b8 100644 --- a/src/minecraft/log.rs +++ b/src/minecraft/log.rs @@ -1,3 +1,5 @@ +use std::fmt; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ServerLogLevel { Fatal, @@ -65,17 +67,65 @@ impl<'a> ServerLogLine<'a> { } } +#[derive(Debug, PartialEq, Eq)] +pub enum RefusalReason { + NotInAllowList, + Banned, + AddressBanned, + Full, + NameTaken, + Other(String) +} + +impl RefusalReason { + fn parse(reason: &str) -> Self { + match reason.trim_end_matches(['!', '.']) { + "You are not white-listed on this server" => RefusalReason::NotInAllowList, + "You are banned from this server" => RefusalReason::Banned, + "Your IP address is banned from this server" + | "You have been IP banned from this server" => RefusalReason::AddressBanned, + "The server is full" => RefusalReason::Full, + "That name is already taken" => RefusalReason::NameTaken, + _ => RefusalReason::Other(reason.to_string()) + } + } + + pub fn raw(&self) -> Option<&str> { + match self { + RefusalReason::Other(reason) => Some(reason), + _ => None + } + } +} + +impl fmt::Display for RefusalReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RefusalReason::NotInAllowList => write!(f, "the user is not in the allow list"), + RefusalReason::Banned => write!(f, "the user is banned"), + RefusalReason::AddressBanned => write!(f, "the address is banned"), + RefusalReason::Full => write!(f, "the instance is full"), + RefusalReason::NameTaken => write!(f, "the name is already taken"), + RefusalReason::Other(reason) => write!(f, "{}", reason) + } + } +} + #[derive(Debug, PartialEq, Eq)] pub enum ServerLogEvent { Joined(String), Left(String), - /// The address is present while the player is still logging in, which - /// is how refused logins are reported. Disconnected { name: String, - address: Option, reason: String }, + /// A connection dropped while the player was still logging in, which is + /// how a login turned away by the server is reported. + Refused { + name: String, + address: String, + reason: RefusalReason + }, SaveStarted, SaveCompleted } @@ -95,19 +145,21 @@ impl ServerLogEvent { } if let Some((subject, reason)) = line.message.split_once(" lost connection: ") { - let (name, address) = match subject + let login = subject .strip_suffix(')') - .and_then(|subject| subject.split_once(" (/")) - { - Some((name, address)) => (name, Some(address.to_string())), - None => (subject, None) + .and_then(|subject| subject.split_once(" (/")); + + return match login { + Some((name, address)) => player_name(name).map(|name| ServerLogEvent::Refused { + name, + address: address.to_string(), + reason: RefusalReason::parse(reason) + }), + None => player_name(subject).map(|name| ServerLogEvent::Disconnected { + name, + reason: reason.to_string() + }) }; - - return player_name(name).map(|name| ServerLogEvent::Disconnected { - name, - address, - reason: reason.to_string() - }); } if line.message.starts_with("Saving chunks for level '") { diff --git a/src/minecraft/tests.rs b/src/minecraft/tests.rs index 9079be3..403fb1f 100644 --- a/src/minecraft/tests.rs +++ b/src/minecraft/tests.rs @@ -4,6 +4,7 @@ use chrono::DateTime; use chrono::Utc; use uuid::Uuid; +use crate::minecraft::log::RefusalReason; use crate::minecraft::log::ServerLogEvent; use crate::minecraft::log::ServerLogLevel; use crate::minecraft::log::ServerLogLine; @@ -146,20 +147,9 @@ fn join_leave_and_save_events_are_recognized() { recognize("[INFO] [Server thread]: Notch lost connection: Timed out"), Some(ServerLogEvent::Disconnected { name: String::from("Notch"), - address: None, reason: String::from("Timed out") }) ); - assert_eq!( - recognize( - "[INFO] [Server thread]: Notch (/[0:0:0:0:0:0:0:1]:49242) lost connection: You are not white-listed on this server!" - ), - Some(ServerLogEvent::Disconnected { - name: String::from("Notch"), - address: Some(String::from("[0:0:0:0:0:0:0:1]:49242")), - reason: String::from("You are not white-listed on this server!") - }) - ); assert_eq!( recognize( "[INFO] [Server thread]: Saving chunks for level 'ServerLevel[world]'/minecraft:overworld" @@ -178,6 +168,40 @@ fn join_leave_and_save_events_are_recognized() { ); } +#[test] +fn refused_logins_carry_a_typed_reason() { + let cases = [ + ( + "You are not white-listed on this server!", + RefusalReason::NotInAllowList + ), + ("You are banned from this server.", RefusalReason::Banned), + ( + "Your IP address is banned from this server.", + RefusalReason::AddressBanned + ), + ("The server is full!", RefusalReason::Full), + ("That name is already taken", RefusalReason::NameTaken), + ("Timed out", RefusalReason::Other(String::from("Timed out"))) + ]; + + for (raw, reason) in cases { + assert_eq!( + recognize(&format!( + "[INFO] [Server thread]: Notch (/[0:0:0:0:0:0:0:1]:49242) lost connection: {}", + raw + )), + Some(ServerLogEvent::Refused { + name: String::from("Notch"), + address: String::from("[0:0:0:0:0:0:0:1]:49242"), + reason + }), + "{}", + raw + ); + } +} + #[test] fn player_controlled_text_is_not_an_event() { let lines = [ diff --git a/src/ops/run.rs b/src/ops/run.rs index c851505..43f1a83 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -130,24 +130,19 @@ where _ = shell.status("Left", name); } } - Some(ServerLogEvent::Disconnected { - name, - address, - reason - }) => { + Some(ServerLogEvent::Disconnected { name, reason }) => { if !QUIET_DISCONNECT_REASONS.contains(&reason.as_str()) { - match address { - Some(address) => { - _ = shell.warn(format!( - "{} ({}) lost connection: {}", - name, address, reason - )); - } - None => { - _ = shell.warn(format!("{} lost connection: {}", name, reason)); - warned_disconnects.insert(name); - } - } + _ = shell.warn(format!("{} lost connection: {}", name, reason)); + warned_disconnects.insert(name); + } + } + Some(ServerLogEvent::Refused { name, reason, .. }) => { + let quiet = reason + .raw() + .is_some_and(|reason| QUIET_DISCONNECT_REASONS.contains(&reason)); + + if !quiet { + _ = shell.warn(format!("connection refused for {}, {}", name, reason)); } } Some(ServerLogEvent::SaveStarted) => { From 3365b14d167e884e990f13892967b9b45da48095 Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Wed, 9 Sep 2026 05:54:05 -0400 Subject: [PATCH 15/17] Keep the console at info and gate echo and events by verbosity - the log4j console appender never drops below info so events are always seen - echoed lines and events are filtered against mc's own log level --- docs/src/commands/run.md | 15 ++++++---- docs/src/guides/systemd.md | 2 +- src/minecraft/log.rs | 4 ++- src/minecraft/log4j.rs | 16 +++++----- src/ops/run.rs | 61 +++++++++++++++++++++++++++++++------- 5 files changed, 71 insertions(+), 27 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index 8a15351..7929ad1 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -23,11 +23,12 @@ the remote console (RCON) for live administration. mc reads the console output whether or not it is shown, and prints a status line for the events it recognizes: a player joining or leaving the instance, -and a world save flushed to disk with the time it took. A player dropped for -any reason other than quitting or the instance stopping, such as a timeout -or a kick, is reported as a warning with the reason in place of the leave -line. A login turned away is reported as a warning naming the cause: the -allow list, a ban, a full instance, or a taken name. +and a world save flushed to disk with the time it took. These are shown +with `--verbose`. A player dropped for any reason other than quitting or +the instance stopping, such as a timeout or a kick, is reported as a warning +with the reason in place of the leave line. A login turned away is reported +as a warning naming the cause: the allow list, a ban, a full instance, or a +taken name. Warnings are shown by default and hidden with `--quiet`. ```text warning: connection refused for Notch, the user is not in the allow list @@ -46,7 +47,9 @@ Shown console lines carry a `Minecraft` label, a dot colored by the line's log level, and the message. Warnings and errors the Java runtime prints outside the logger get their dot from the `WARNING:` or `ERROR:` prefix. Lines without a level, such as the continuation lines of a stack trace, keep -the label and print as they are. When the `jvm-arguments` in the manifest +the label and print as they are. Which levels are shown follows mc's own +verbosity: warnings and errors by default, informational lines with +`--verbose`, and finer levels with more `-v` flags. When the `jvm-arguments` in the manifest select a logging configuration file of their own, the console keeps that file's format and no events are recognized. diff --git a/docs/src/guides/systemd.md b/docs/src/guides/systemd.md index 1d453a5..0a32a6d 100644 --- a/docs/src/guides/systemd.md +++ b/docs/src/guides/systemd.md @@ -81,4 +81,4 @@ Playit 2026-09-09T12:34:56.123456Z INFO playit_agent_core::tunnel: connected ``` mc's own log verbosity is controlled by the global `--verbose` flag; the -instance's console log level follows it. +instance's console lines shown in the journal follow it. diff --git a/src/minecraft/log.rs b/src/minecraft/log.rs index 8b023b8..a0a8025 100644 --- a/src/minecraft/log.rs +++ b/src/minecraft/log.rs @@ -1,6 +1,8 @@ use std::fmt; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Ordered from most to least severe, so `level <= threshold` means the +/// line is at least as important as the threshold. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum ServerLogLevel { Fatal, Error, diff --git a/src/minecraft/log4j.rs b/src/minecraft/log4j.rs index 5baff39..ec4be17 100644 --- a/src/minecraft/log4j.rs +++ b/src/minecraft/log4j.rs @@ -13,24 +13,24 @@ const TEMPLATE: &str = r#" - + - + "#; -pub fn configuration(console_level: &str) -> String { - let root_level = match console_level { - "debug" | "trace" => console_level, +// The console never drops below info: mc reads it to recognize events, and +// filters what it echoes against its own verbosity. +pub fn configuration(level: &str) -> String { + let level = match level { + "debug" | "trace" => level, _ => "info" }; - TEMPLATE - .replace("{root_level}", root_level) - .replace("{console_level}", console_level) + TEMPLATE.replace("{level}", level) } diff --git a/src/ops/run.rs b/src/ops/run.rs index 43f1a83..b1a8e6f 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -90,15 +90,34 @@ fn level_style(level: ServerLogLevel) -> &'static Style { } } -/// Drains one of the server's output pipes, echoing every line when -/// `echo` is set and printing the events it recognizes either way. -fn forward_server_output(shell: Arc>, output: R, echo: bool) -> JoinHandle<()> +fn level_threshold(level: tracing::Level) -> ServerLogLevel { + match level { + tracing::Level::ERROR => ServerLogLevel::Error, + tracing::Level::WARN => ServerLogLevel::Warn, + tracing::Level::INFO => ServerLogLevel::Info, + tracing::Level::DEBUG => ServerLogLevel::Debug, + tracing::Level::TRACE => ServerLogLevel::Trace + } +} + +/// Drains one of the server's output pipes, echoing the lines within +/// `threshold` when `echo` is set and printing the events it recognizes +/// either way. Events carry a level of their own that is held to the same +/// threshold. +fn forward_server_output( + shell: Arc>, + output: R, + echo: bool, + threshold: ServerLogLevel +) -> JoinHandle<()> where R: AsyncRead + Unpin + Send + 'static { tokio::spawn(async move { let mut save_started: Option = None; let mut warned_disconnects: HashSet = HashSet::new(); + let show_info = ServerLogLevel::Info <= threshold; + let show_warn = ServerLogLevel::Warn <= threshold; utils::process::for_each_line(output, |line| { let parsed = ServerLogLine::parse(line); @@ -107,7 +126,7 @@ where if echo { match &parsed { - Some(parsed) => { + Some(parsed) if parsed.level <= threshold => { let dot = level_style(parsed.level); _ = shell.echo( @@ -116,6 +135,7 @@ where &styles::MINECRAFT ); } + Some(_) => {} None => _ = shell.echo("Minecraft", line, &styles::MINECRAFT) } } @@ -123,16 +143,22 @@ where match event { Some(ServerLogEvent::Joined(name)) => { warned_disconnects.remove(&name); - _ = shell.status("Joined", name); + + if show_info { + _ = shell.status("Joined", name); + } } Some(ServerLogEvent::Left(name)) => { - if !warned_disconnects.remove(&name) { + if !warned_disconnects.remove(&name) && show_info { _ = shell.status("Left", name); } } Some(ServerLogEvent::Disconnected { name, reason }) => { if !QUIET_DISCONNECT_REASONS.contains(&reason.as_str()) { - _ = shell.warn(format!("{} lost connection: {}", name, reason)); + if show_warn { + _ = shell.warn(format!("{} lost connection: {}", name, reason)); + } + warned_disconnects.insert(name); } } @@ -141,7 +167,7 @@ where .raw() .is_some_and(|reason| QUIET_DISCONNECT_REASONS.contains(&reason)); - if !quiet { + if !quiet && show_warn { _ = shell.warn(format!("connection refused for {}, {}", name, reason)); } } @@ -157,7 +183,9 @@ where None => String::from("all dimensions flushed to disk") }; - _ = shell.status("Saved", message); + if show_info { + _ = shell.status("Saved", message); + } } None => {} } @@ -515,9 +543,20 @@ pub async fn run(context: &mut McContext, options: &RunOptions) -> McResult Date: Thu, 17 Sep 2026 21:53:18 -0400 Subject: [PATCH 16/17] Address review: trim the docs and reuse the warning and error styles --- docs/src/commands/run.md | 31 ++++++++----------------------- docs/src/guides/systemd.md | 9 --------- docs/src/guides/tunnel.md | 3 +-- src/cli/styles.rs | 2 -- src/ops/run.rs | 4 ++-- 5 files changed, 11 insertions(+), 38 deletions(-) diff --git a/docs/src/commands/run.md b/docs/src/commands/run.md index 7929ad1..7496504 100644 --- a/docs/src/commands/run.md +++ b/docs/src/commands/run.md @@ -31,33 +31,20 @@ as a warning naming the cause: the allow list, a ban, a full instance, or a taken name. Warnings are shown by default and hidden with `--quiet`. ```text -warning: connection refused for Notch, the user is not in the allow list -``` With `--server-logs`, the -console line that produced the event is shown as well, ahead of the status -line. - -```text -Minecraft ● Notch joined the game Joined Notch -Minecraft ● ThreadedAnvilChunkStorage: All dimensions are saved Saved all dimensions flushed to disk in 1.2s +warning: connection refused for Notch, the user is not in the allow list ``` -Shown console lines carry a `Minecraft` label, a dot colored by the line's -log level, and the message. Warnings and errors the Java runtime prints -outside the logger get their dot from the `WARNING:` or `ERROR:` prefix. -Lines without a level, such as the continuation lines of a stack trace, keep -the label and print as they are. Which levels are shown follows mc's own -verbosity: warnings and errors by default, informational lines with -`--verbose`, and finer levels with more `-v` flags. When the `jvm-arguments` in the manifest -select a logging configuration file of their own, the console keeps that -file's format and no events are recognized. +With `--server-logs`, the console lines shown follow mc's verbosity: +warnings and errors by default, more with `--verbose`. When the +`jvm-arguments` in the manifest select a logging configuration file of their +own, no events are recognized. With a `tunnel` section, the tunnel agent starts beside the instance and is restarted if it stops on its own; the public address is printed at startup. The agent's output is hidden unless `--tunnel-logs` is passed, and -goes to `.tunnel/playitd.log` otherwise. When shown, every agent line is -labeled with the provider's name, like the console lines. The first start from a terminal prints a claim link to approve in a +goes to `.tunnel/playitd.log` otherwise. The first start from a terminal prints a claim link to approve in a browser and saves the resulting secret under `.tunnel/`. Without a terminal and without a secret, `mc run` fails with instructions rather than waiting — see [Tunnels](../guides/tunnel.md). @@ -86,7 +73,5 @@ instance's exit code. - `--manifest-path ` — path to `mc.kdl`. Defaults to `./mc.kdl`. - `--lockfile-path ` — path to `mc.lock`. Defaults to `./mc.lock`. -- `--server-logs` — show the instance's console output in the terminal, - labeled `Minecraft`. Recognized events are printed either way. -- `--tunnel-logs` — show the tunnel agent's output in the terminal, labeled - with the provider's name. +- `--server-logs` — show the instance's console output in the terminal. +- `--tunnel-logs` — show the tunnel agent's output in the terminal. diff --git a/docs/src/guides/systemd.md b/docs/src/guides/systemd.md index 0a32a6d..94a8ee1 100644 --- a/docs/src/guides/systemd.md +++ b/docs/src/guides/systemd.md @@ -71,14 +71,5 @@ tunnel agent as well): journalctl -u myserver -f ``` -Each forwarded line is labeled with its source, so the instance, the tunnel -agent, and mc's own status lines are easy to tell apart: - -```text -Minecraft ● Notch joined the game - Joined Notch -Playit 2026-09-09T12:34:56.123456Z INFO playit_agent_core::tunnel: connected -``` - mc's own log verbosity is controlled by the global `--verbose` flag; the instance's console lines shown in the journal follow it. diff --git a/docs/src/guides/tunnel.md b/docs/src/guides/tunnel.md index aa0c00b..b50ab6e 100644 --- a/docs/src/guides/tunnel.md +++ b/docs/src/guides/tunnel.md @@ -56,8 +56,7 @@ new agent on the next start. The agent runs for as long as the instance does and is restarted by mc if it stops on its own. Its output is written to `.tunnel/playitd.log`, or shown in -the terminal, labeled with the provider's name, when the instance is started -with `mc run --tunnel-logs`. +the terminal when the instance is started with `mc run --tunnel-logs`. Stopping the instance stops the agent. Traffic between players and the instance flows through the provider's relay, diff --git a/src/cli/styles.rs b/src/cli/styles.rs index 0ed557d..169e707 100644 --- a/src/cli/styles.rs +++ b/src/cli/styles.rs @@ -14,8 +14,6 @@ pub const VALID: Style = AnsiColor::BrightCyan.on_default().effects(Effects::BOL pub const INVALID: Style = annotate_snippets::renderer::DEFAULT_WARNING_STYLE; pub const MINECRAFT: Style = Ansi256Color(28).on_default().effects(Effects::BOLD); pub const TUNNEL: Style = Ansi256Color(208).on_default().effects(Effects::BOLD); -pub const LOG_ERROR: Style = AnsiColor::Red.on_default(); -pub const LOG_WARN: Style = AnsiColor::Yellow.on_default(); pub const LOG_INFO: Style = AnsiColor::Green.on_default(); pub const LOG_DEBUG: Style = AnsiColor::Cyan.on_default(); pub const LOG_TRACE: Style = Style::new().effects(Effects::DIMMED); diff --git a/src/ops/run.rs b/src/ops/run.rs index b1a8e6f..872320e 100644 --- a/src/ops/run.rs +++ b/src/ops/run.rs @@ -82,8 +82,8 @@ pub struct RunOptions { fn level_style(level: ServerLogLevel) -> &'static Style { match level { - ServerLogLevel::Fatal | ServerLogLevel::Error => &styles::LOG_ERROR, - ServerLogLevel::Warn => &styles::LOG_WARN, + ServerLogLevel::Fatal | ServerLogLevel::Error => &styles::ERROR, + ServerLogLevel::Warn => &styles::WARN, ServerLogLevel::Info => &styles::LOG_INFO, ServerLogLevel::Debug => &styles::LOG_DEBUG, ServerLogLevel::Trace => &styles::LOG_TRACE From 08fd83420a8ee2eba454d373b0642c251115cd4e Mon Sep 17 00:00:00 2001 From: Alexandre Frigon Date: Fri, 18 Sep 2026 01:14:59 -0400 Subject: [PATCH 17/17] version bump --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1508ad9..5aba416 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1956,7 +1956,7 @@ dependencies = [ [[package]] name = "mc" -version = "0.10.2" +version = "0.11.0" dependencies = [ "annotate-snippets", "anstream", diff --git a/Cargo.toml b/Cargo.toml index 2229391..878c723 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mc" -version = "0.10.2" +version = "0.11.0" edition = "2024" license = "MIT"