feat(pumpkin): add /debug start and stop commands (#2782)

Implements the vanilla /debug start and /debug stop commands (tracked in #15):

- /debug start opens one server-wide tick profiling session and rejects duplicate starts.
- /debug stop reports elapsed seconds, processed ticks, and average TPS, then returns the floored average TPS.
- Profiling state handles tick counter wraparound and can be restarted after a completed session.
- Registers minecraft:command.debug at operator permission level 3 and uses the existing Java and Bedrock translation keys.

/debug function is intentionally out of scope because Pumpkin does not yet have the required function runtime.
This commit is contained in:
Gabe D.
2026-08-22 07:22:44 -04:00
committed by GitHub
parent 381ca7c976
commit 7c4b4baaab
4 changed files with 259 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
use std::sync::atomic::Ordering;
use pumpkin_data::translation;
use pumpkin_util::PermissionLvl;
use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry};
use pumpkin_util::text::TextComponent;
use crate::command::CommandSender;
use crate::command::argument_builder::{ArgumentBuilder, command, literal};
use crate::command::context::command_context::CommandContext;
use crate::command::errors::error_types::CommandErrorType;
use crate::command::node::dispatcher::CommandDispatcher;
use crate::command::node::{CommandExecutor, CommandExecutorResult};
use crate::server::debug_profiler::{StartDebugProfileError, StopDebugProfileError};
const DESCRIPTION: &str = "Starts or stops a tick profiling session.";
const PERMISSION: &str = "minecraft:command.debug";
const ALREADY_RUNNING_ERROR_TYPE: CommandErrorType<0> = CommandErrorType::new(
translation::java::COMMANDS_DEBUG_ALREADYRUNNING,
translation::java::COMMANDS_DEBUG_ALREADYRUNNING,
);
const NOT_RUNNING_ERROR_TYPE: CommandErrorType<0> = CommandErrorType::new(
translation::java::COMMANDS_DEBUG_NOTRUNNING,
translation::bedrock::COMMANDS_DEBUG_NOTSTARTED,
);
struct DebugStartExecutor;
impl CommandExecutor for DebugStartExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let server = context.server();
let current_tick = server.tick_count.load(Ordering::Relaxed);
server.debug_profiler.start(current_tick).map_err(
|StartDebugProfileError::AlreadyRunning| {
ALREADY_RUNNING_ERROR_TYPE.create_without_context()
},
)?;
context
.source
.send_feedback(
TextComponent::translate_cross(
translation::java::COMMANDS_DEBUG_STARTED,
translation::bedrock::COMMANDS_DEBUG_START,
[],
),
true,
)
.await;
Ok(1)
})
}
}
struct DebugStopExecutor;
impl CommandExecutor for DebugStopExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let server = context.server();
let current_tick = server.tick_count.load(Ordering::Relaxed);
let result = server.debug_profiler.stop(current_tick).map_err(
|StopDebugProfileError::NotRunning| NOT_RUNNING_ERROR_TYPE.create_without_context(),
)?;
let seconds = result.duration.as_secs_f64();
let tps = result.ticks_per_second();
let arguments = [
TextComponent::text(format!("{seconds:.2}")),
TextComponent::text(result.ticks.to_string()),
TextComponent::text(format!("{tps:.2}")),
];
let feedback = if matches!(context.source.output, CommandSender::Player(_)) {
TextComponent::translate_cross(
translation::java::COMMANDS_DEBUG_STOPPED,
translation::bedrock::COMMANDS_DEBUG_STOP,
arguments,
)
} else {
// Bedrock's debug-stop translation uses printf-style placeholders that the
// server-side console renderer cannot resolve. Non-player command sources need
// an already-rendered message; players still receive their native translation.
TextComponent::text(format!(
"Stopped tick profiling after {seconds:.2} second(s) and {} tick(s) ({tps:.2} tick(s) per second)",
result.ticks
))
};
context.source.send_feedback(feedback, true).await;
Ok(result.command_result())
})
}
}
pub fn register(dispatcher: &mut CommandDispatcher, registry: &PermissionRegistry) {
registry.register_permission_or_panic(Permission::new(
PERMISSION,
DESCRIPTION,
PermissionDefault::Op(PermissionLvl::Three),
));
dispatcher.register(
command("debug", DESCRIPTION)
.requires(PERMISSION)
.then(literal("start").executes(DebugStartExecutor))
.then(literal("stop").executes(DebugStopExecutor)),
);
}

View File

@@ -17,6 +17,7 @@ mod clear;
mod clone;
mod damage;
mod data;
mod debug;
pub mod defaultgamemode;
mod deop;
mod dialog;
@@ -177,6 +178,7 @@ pub fn default_dispatcher(
say::register(&mut dispatcher, registry);
banlist::register(&mut dispatcher, registry);
difficulty::register(&mut dispatcher, registry);
debug::register(&mut dispatcher, registry);
dialog::register(&mut dispatcher, registry);
execute::register(&mut dispatcher, registry);
fillbiome::register(&mut dispatcher, registry);

View File

@@ -0,0 +1,141 @@
use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Clone, Copy)]
struct DebugProfileSession {
started_at: Instant,
started_tick: u32,
}
/// Measurements collected by a completed `/debug start` profiling session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DebugProfileResult {
pub duration: Duration,
pub ticks: u32,
}
impl DebugProfileResult {
#[must_use]
pub fn ticks_per_second(self) -> f64 {
let seconds = self.duration.as_secs_f64();
if seconds == 0.0 {
return 0.0;
}
f64::from(self.ticks) / seconds
}
#[must_use]
pub fn command_result(self) -> i32 {
let floored_tps = self
.ticks_per_second()
.floor()
.clamp(0.0, f64::from(i32::MAX));
#[expect(clippy::cast_possible_truncation)]
{
floored_tps as i32
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartDebugProfileError {
AlreadyRunning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopDebugProfileError {
NotRunning,
}
/// Owns the single server-wide tick profiling session used by `/debug`.
#[derive(Default)]
pub struct DebugProfiler {
active_session: Mutex<Option<DebugProfileSession>>,
}
impl DebugProfiler {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn start(&self, current_tick: i32) -> Result<(), StartDebugProfileError> {
self.start_at(current_tick as u32, Instant::now())
}
pub fn stop(&self, current_tick: i32) -> Result<DebugProfileResult, StopDebugProfileError> {
self.stop_at(current_tick as u32, Instant::now())
}
fn start_at(&self, current_tick: u32, now: Instant) -> Result<(), StartDebugProfileError> {
let mut active_session = self
.active_session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if active_session.is_some() {
return Err(StartDebugProfileError::AlreadyRunning);
}
*active_session = Some(DebugProfileSession {
started_at: now,
started_tick: current_tick,
});
Ok(())
}
fn stop_at(
&self,
current_tick: u32,
now: Instant,
) -> Result<DebugProfileResult, StopDebugProfileError> {
let session = self
.active_session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
.ok_or(StopDebugProfileError::NotRunning)?;
Ok(DebugProfileResult {
duration: now.saturating_duration_since(session.started_at),
ticks: current_tick.wrapping_sub(session.started_tick),
})
}
}
#[cfg(test)]
mod tests {
use super::{DebugProfiler, StartDebugProfileError, StopDebugProfileError};
use std::time::{Duration, Instant};
#[test]
fn profile_lifecycle_enforces_state_and_reports_measurements() {
let profiler = DebugProfiler::new();
let start = Instant::now();
assert_eq!(
profiler.stop_at(42, start),
Err(StopDebugProfileError::NotRunning)
);
assert_eq!(profiler.start_at(42, start), Ok(()));
assert_eq!(
profiler.start_at(100, start + Duration::from_secs(1)),
Err(StartDebugProfileError::AlreadyRunning)
);
let result = profiler
.stop_at(62, start + Duration::from_secs(2))
.expect("the running profile should stop");
assert_eq!(result.duration, Duration::from_secs(2));
assert_eq!(result.ticks, 20);
assert_eq!(result.ticks_per_second(), 10.0);
assert_eq!(result.command_result(), 10);
assert_eq!(
profiler.stop_at(62, start + Duration::from_secs(2)),
Err(StopDebugProfileError::NotRunning)
);
}
}

View File

@@ -52,6 +52,7 @@ use tokio::task::{JoinHandle, JoinSet};
use tokio_util::task::TaskTracker;
mod connection_cache;
pub(crate) mod debug_profiler;
pub mod enchantment;
mod key_store;
pub mod recipe;
@@ -130,6 +131,8 @@ pub struct Server {
pub aggregated_tick_times_nanos: AtomicI64,
/// Total number of ticks processed by the server
pub tick_count: AtomicI32,
/// Owns the server-wide tick profiling session used by `/debug`.
pub(crate) debug_profiler: debug_profiler::DebugProfiler,
/// Random unique Server ID used by Bedrock Edition
pub server_guid: u64,
/// Player idle timeout in minutes (0 = disabled)
@@ -284,6 +287,7 @@ impl Server {
tick_times_nanos: Mutex::new([0; 100]),
aggregated_tick_times_nanos: AtomicI64::new(0),
tick_count: AtomicI32::new(0),
debug_profiler: debug_profiler::DebugProfiler::new(),
tasks: TaskTracker::new(),
runtime: tokio::runtime::Handle::current(),
task_scheduler: Arc::new(TaskScheduler::new()),