Backport Mystic-Stars/Axolotl b2f81391e584aebf3ef07e8db2fc9cdad2ffb4c7. Adapt validation to this fork and protect CurseForge archive paths and MRPack uninstall cleanup.
645 lines
21 KiB
Rust
645 lines
21 KiB
Rust
//! Server process control: starting, stopping, and monitoring the JVM.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
use base64::Engine;
|
|
use chrono::Utc;
|
|
use dashmap::{DashMap, DashSet};
|
|
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
|
use std::sync::LazyLock;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::process::{Child, ChildStdin, Command};
|
|
|
|
use crate::event::ServerPayloadType;
|
|
use crate::event::emit::emit_server;
|
|
use crate::state::{clear_log_buffer, get_log_buffer, push_log_line};
|
|
use crate::util::io::IOError;
|
|
use crate::{ErrorKind, Result};
|
|
|
|
use super::files::safe_relative_join;
|
|
use super::logs::{
|
|
analyze_exit_reason, stream_server_output, stream_server_pty_output,
|
|
tail_server_log_file,
|
|
};
|
|
use super::manifest::{
|
|
read_manifest, resolve_jar_name, server_path, write_manifest,
|
|
};
|
|
|
|
const DEFAULT_MEMORY_MB: u32 = 2048;
|
|
const STOP_TIMEOUT_SECS: u64 = 60;
|
|
const MAX_CONSOLE_ROWS: u16 = 8192;
|
|
|
|
struct ServerProcess {
|
|
child: tokio::sync::Mutex<ServerChild>,
|
|
input: tokio::sync::Mutex<ServerInput>,
|
|
pty_master: Option<tokio::sync::Mutex<Box<dyn MasterPty + Send>>>,
|
|
stop_requested: AtomicBool,
|
|
}
|
|
|
|
enum ServerChild {
|
|
Piped(Child),
|
|
Pty(Box<dyn portable_pty::Child + Send + Sync>),
|
|
}
|
|
|
|
enum ServerInput {
|
|
Piped(ChildStdin),
|
|
Pty(Box<dyn std::io::Write + Send>),
|
|
}
|
|
|
|
impl ServerInput {
|
|
async fn write_all(&mut self, data: &[u8]) -> std::io::Result<()> {
|
|
match self {
|
|
Self::Piped(stdin) => {
|
|
stdin.write_all(data).await?;
|
|
stdin.flush().await
|
|
}
|
|
Self::Pty(writer) => {
|
|
writer.write_all(data)?;
|
|
writer.flush()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ServerChild {
|
|
fn try_wait(&mut self) -> std::io::Result<Option<bool>> {
|
|
match self {
|
|
Self::Piped(child) => {
|
|
child.try_wait().map(|status| status.map(|s| s.success()))
|
|
}
|
|
Self::Pty(child) => {
|
|
child.try_wait().map(|status| status.map(|s| s.success()))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn kill(&mut self) -> std::io::Result<()> {
|
|
match self {
|
|
Self::Piped(child) => child.kill().await,
|
|
Self::Pty(child) => child.kill(),
|
|
}
|
|
}
|
|
}
|
|
|
|
static SERVER_PROCESSES: LazyLock<DashMap<String, Arc<ServerProcess>>> =
|
|
LazyLock::new(DashMap::new);
|
|
|
|
/// Synchronous start-in-flight guard. Reserving the slot before the first
|
|
/// `.await` prevents concurrent `start` calls (e.g. double-clicks) from both
|
|
/// passing the running check and spawning two JVMs on the same directory.
|
|
static SERVER_STARTING: LazyLock<DashSet<String>> = LazyLock::new(DashSet::new);
|
|
|
|
pub(super) fn is_running(server_id: &str) -> bool {
|
|
SERVER_PROCESSES.contains_key(server_id)
|
|
}
|
|
|
|
/// Whether a server process is currently tracked. Used by the log-file tailer
|
|
/// to stop following once the server has exited.
|
|
pub(super) fn is_server_running(server_id: &str) -> bool {
|
|
SERVER_PROCESSES.contains_key(server_id)
|
|
}
|
|
|
|
pub async fn start(
|
|
server_id: &str,
|
|
java_path: Option<String>,
|
|
memory_mb: Option<u32>,
|
|
jvm_args: Option<Vec<String>>,
|
|
) -> Result<()> {
|
|
if SERVER_PROCESSES.contains_key(server_id)
|
|
|| !SERVER_STARTING.insert(server_id.to_string())
|
|
{
|
|
return Err(ErrorKind::InputError(
|
|
"Server is already running".to_string(),
|
|
)
|
|
.as_error());
|
|
}
|
|
let result = start_inner(server_id, java_path, memory_mb, jvm_args).await;
|
|
SERVER_STARTING.remove(server_id);
|
|
result
|
|
}
|
|
|
|
async fn start_inner(
|
|
server_id: &str,
|
|
java_path: Option<String>,
|
|
memory_mb: Option<u32>,
|
|
jvm_args: Option<Vec<String>>,
|
|
) -> Result<()> {
|
|
let dir = server_path(server_id).await?;
|
|
let mut manifest = read_manifest(&dir).await?;
|
|
let launch_args = if uses_pty_transport(&manifest.server_type) {
|
|
forge_launch_args(&dir)?
|
|
} else {
|
|
let jar_name = resolve_jar_name(&manifest);
|
|
let jar_path = safe_relative_join(&dir, &jar_name)?;
|
|
if !jar_path.exists() {
|
|
return Err(ErrorKind::LauncherError(format!(
|
|
"Server jar not found: {jar_name}. Download the server files first."
|
|
))
|
|
.as_error());
|
|
}
|
|
vec!["-jar".to_string(), jar_name, "nogui".to_string()]
|
|
};
|
|
|
|
let java = java_path
|
|
.or_else(|| manifest.java_path.clone())
|
|
.unwrap_or_else(|| "java".to_string());
|
|
let memory = memory_mb
|
|
.or(manifest.memory_mb)
|
|
.unwrap_or(DEFAULT_MEMORY_MB);
|
|
|
|
// Ensure eula.txt exists (create with eula=false if missing)
|
|
let eula_path = dir.join("eula.txt");
|
|
let eula_created = !eula_path.exists();
|
|
if eula_created {
|
|
tokio::fs::write(&eula_path, "eula=false\n")
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, &eula_path))?;
|
|
}
|
|
|
|
let mut args = vec![format!("-Xmx{memory}M")];
|
|
if uses_pty_transport(&manifest.server_type) {
|
|
args.push("-Dorg.jline.reader.props.list-max=0".to_string());
|
|
}
|
|
args.extend(jvm_args.unwrap_or_else(|| manifest.jvm_args.clone()));
|
|
args.extend(launch_args);
|
|
let removed_environment = [
|
|
"DYLD_LIBRARY_PATH",
|
|
"DYLD_FALLBACK_LIBRARY_PATH",
|
|
"DYLD_FRAMEWORK_PATH",
|
|
"DYLD_FALLBACK_FRAMEWORK_PATH",
|
|
"DYLD_INSERT_LIBRARIES",
|
|
];
|
|
|
|
let (mut child, input, pty_master, stdout, stderr, pty_reader) =
|
|
if uses_pty_transport(&manifest.server_type) {
|
|
let pair = native_pty_system()
|
|
.openpty(PtySize {
|
|
rows: 12,
|
|
cols: 80,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.map_err(|e| {
|
|
ErrorKind::LauncherError(format!(
|
|
"Failed to create Forge console PTY: {e}"
|
|
))
|
|
.as_error()
|
|
})?;
|
|
let reader = pair.master.try_clone_reader().map_err(|e| {
|
|
ErrorKind::LauncherError(format!(
|
|
"Failed to capture Forge console output: {e}"
|
|
))
|
|
.as_error()
|
|
})?;
|
|
let writer = pair.master.take_writer().map_err(|e| {
|
|
ErrorKind::LauncherError(format!(
|
|
"Failed to capture Forge console input: {e}"
|
|
))
|
|
.as_error()
|
|
})?;
|
|
let mut command = CommandBuilder::new(&java);
|
|
command.args(&args);
|
|
command.cwd(&dir);
|
|
command.env("TERM", "xterm-256color");
|
|
for variable in removed_environment {
|
|
command.env_remove(variable);
|
|
}
|
|
let child = pair.slave.spawn_command(command).map_err(|e| {
|
|
ErrorKind::LauncherError(format!(
|
|
"Failed to start Forge server process: {e}"
|
|
))
|
|
.as_error()
|
|
})?;
|
|
(
|
|
ServerChild::Pty(child),
|
|
ServerInput::Pty(writer),
|
|
Some(tokio::sync::Mutex::new(pair.master)),
|
|
None,
|
|
None,
|
|
Some(reader),
|
|
)
|
|
} else {
|
|
let mut command = Command::new(&java);
|
|
command.args(&args);
|
|
command.current_dir(&dir);
|
|
for variable in removed_environment {
|
|
command.env_remove(variable);
|
|
}
|
|
command.stdout(std::process::Stdio::piped());
|
|
command.stderr(std::process::Stdio::piped());
|
|
command.stdin(std::process::Stdio::piped());
|
|
command.kill_on_drop(true);
|
|
|
|
let mut child = command.spawn().map_err(|e| {
|
|
ErrorKind::LauncherError(format!(
|
|
"Failed to start server process: {e}"
|
|
))
|
|
.as_error()
|
|
})?;
|
|
let stdout = child.stdout.take();
|
|
let stderr = child.stderr.take();
|
|
let stdin = child.stdin.take().ok_or_else(|| {
|
|
ErrorKind::LauncherError(
|
|
"Server stdin could not be captured".to_string(),
|
|
)
|
|
.as_error()
|
|
})?;
|
|
(
|
|
ServerChild::Piped(child),
|
|
ServerInput::Piped(stdin),
|
|
None,
|
|
stdout,
|
|
stderr,
|
|
None,
|
|
)
|
|
};
|
|
|
|
manifest.last_started_at = Some(Utc::now());
|
|
manifest.last_exit_crashed = false;
|
|
if let Err(error) = write_manifest(&dir, &manifest).await {
|
|
let _ = child.kill().await;
|
|
return Err(error);
|
|
}
|
|
|
|
clear_log_buffer(server_id);
|
|
|
|
// Start each run from a clean log file. Minecraft's log4j appender appends
|
|
// to logs/latest.log across launches, so without truncating it the file
|
|
// tailer would replay the previous run's history into the fresh buffer on
|
|
// every restart.
|
|
let _ = std::fs::remove_file(dir.join("logs").join("latest.log"));
|
|
|
|
// Surface every startup step in the console. A loader's first launch (e.g.
|
|
// Fabric downloading the Minecraft server) can stay silent for a long time,
|
|
// so these lines stop the console from looking frozen.
|
|
let loader_first_run =
|
|
matches!(manifest.server_type.as_str(), "fabric" | "quilt")
|
|
&& !safe_relative_join(
|
|
&dir,
|
|
&format!("{}-server-launch.jar", manifest.server_type),
|
|
)?
|
|
.exists();
|
|
|
|
log_server_step(
|
|
server_id,
|
|
&format!(
|
|
"Starting server '{}' ({} · Minecraft {})",
|
|
manifest.name, manifest.server_type, manifest.game_version,
|
|
),
|
|
)
|
|
.await;
|
|
log_server_step(server_id, &format!("Java: {java}")).await;
|
|
log_server_step(server_id, &format!("Memory: {memory} MB")).await;
|
|
if eula_created {
|
|
log_server_step(
|
|
server_id,
|
|
"eula.txt not found — created with eula=false. Accept the EULA to start the server.",
|
|
)
|
|
.await;
|
|
}
|
|
log_server_step(
|
|
server_id,
|
|
&format!(
|
|
"Launching {} server ({} · nogui)",
|
|
manifest.server_type,
|
|
resolve_jar_name(&manifest),
|
|
),
|
|
)
|
|
.await;
|
|
if loader_first_run {
|
|
log_server_step(
|
|
server_id,
|
|
"First launch: downloading Minecraft server files. This may take a few minutes — the console will keep updating as it progresses.",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let process = Arc::new(ServerProcess {
|
|
child: tokio::sync::Mutex::new(child),
|
|
input: tokio::sync::Mutex::new(input),
|
|
pty_master,
|
|
stop_requested: AtomicBool::new(false),
|
|
});
|
|
SERVER_PROCESSES.insert(server_id.to_string(), process.clone());
|
|
|
|
if let Some(stdout) = stdout {
|
|
tokio::spawn(stream_server_output(server_id.to_string(), stdout));
|
|
}
|
|
if let Some(stderr) = stderr {
|
|
tokio::spawn(stream_server_output(server_id.to_string(), stderr));
|
|
}
|
|
if let Some(reader) = pty_reader {
|
|
tokio::spawn(stream_server_pty_output(server_id.to_string(), reader));
|
|
}
|
|
// The process pipes (above) capture JVM/installer output, but the server's
|
|
// own log4j console output is normally written to logs/latest.log rather
|
|
// than the stdout pipe. Tail that file so the console always shows the
|
|
// complete, lossless server log (matching what's on disk).
|
|
tokio::spawn(tail_server_log_file(server_id.to_string(), dir.clone()));
|
|
tokio::spawn(monitor_server_process(server_id.to_string(), dir, process));
|
|
|
|
emit_server(server_id, ServerPayloadType::Started)
|
|
.await
|
|
.ok();
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn send_command(server_id: &str, command: &str) -> Result<()> {
|
|
let process = SERVER_PROCESSES
|
|
.get(server_id)
|
|
.map(|entry| entry.value().clone())
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError("Server is not running".to_string())
|
|
.as_error()
|
|
})?;
|
|
let mut input = process.input.lock().await;
|
|
input
|
|
.write_all(&command_bytes(command))
|
|
.await
|
|
.map_err(|e| {
|
|
ErrorKind::LauncherError(format!("Failed to send command: {e}"))
|
|
.as_error()
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn uses_pty_transport(server_type: &str) -> bool {
|
|
server_type == "forge"
|
|
}
|
|
|
|
fn command_bytes(command: &str) -> Vec<u8> {
|
|
format!("{command}\n").into_bytes()
|
|
}
|
|
|
|
pub async fn send_console_input(server_id: &str, data: &str) -> Result<()> {
|
|
let process = SERVER_PROCESSES
|
|
.get(server_id)
|
|
.map(|entry| entry.value().clone())
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError("Server is not running".to_string())
|
|
.as_error()
|
|
})?;
|
|
if process.pty_master.is_none() {
|
|
return Err(ErrorKind::InputError(
|
|
"Raw console input is only available for Forge servers".to_string(),
|
|
)
|
|
.as_error());
|
|
}
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(data)
|
|
.map_err(|e| {
|
|
ErrorKind::InputError(format!("Invalid console input: {e}"))
|
|
.as_error()
|
|
})?;
|
|
if bytes.len() > 64 * 1024 {
|
|
return Err(ErrorKind::InputError(
|
|
"Console input exceeds 64 KiB".to_string(),
|
|
)
|
|
.as_error());
|
|
}
|
|
let mut input = process.input.lock().await;
|
|
input.write_all(&bytes).await.map_err(|e| {
|
|
ErrorKind::LauncherError(format!("Failed to send command: {e}"))
|
|
.as_error()
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn resize_console(
|
|
server_id: &str,
|
|
cols: u16,
|
|
rows: u16,
|
|
) -> Result<()> {
|
|
let process = SERVER_PROCESSES
|
|
.get(server_id)
|
|
.map(|entry| entry.value().clone())
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError("Server is not running".to_string())
|
|
.as_error()
|
|
})?;
|
|
let master = process.pty_master.as_ref().ok_or_else(|| {
|
|
ErrorKind::InputError(
|
|
"Console resizing is only available for Forge servers".to_string(),
|
|
)
|
|
.as_error()
|
|
})?;
|
|
master
|
|
.lock()
|
|
.await
|
|
.resize(PtySize {
|
|
rows: rows.clamp(4, MAX_CONSOLE_ROWS),
|
|
cols: cols.clamp(20, 500),
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.map_err(|e| {
|
|
ErrorKind::LauncherError(format!(
|
|
"Failed to resize Forge console: {e}"
|
|
))
|
|
.as_error()
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn stop(server_id: &str) -> Result<()> {
|
|
let process = SERVER_PROCESSES
|
|
.get(server_id)
|
|
.map(|entry| entry.value().clone())
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError("Server is not running".to_string())
|
|
.as_error()
|
|
})?;
|
|
process.stop_requested.store(true, Ordering::SeqCst);
|
|
let mut input = process.input.lock().await;
|
|
let _ = input.write_all(b"stop\n").await;
|
|
|
|
let watchdog = process.clone();
|
|
let server_id = server_id.to_string();
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(std::time::Duration::from_secs(STOP_TIMEOUT_SECS))
|
|
.await;
|
|
if let Some(current) = SERVER_PROCESSES.get(&server_id)
|
|
&& current.stop_requested.load(Ordering::SeqCst)
|
|
{
|
|
let _ = watchdog.child.lock().await.kill().await;
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn kill(server_id: &str) -> Result<()> {
|
|
let process = SERVER_PROCESSES
|
|
.get(server_id)
|
|
.map(|entry| entry.value().clone())
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError("Server is not running".to_string())
|
|
.as_error()
|
|
})?;
|
|
process.stop_requested.store(true, Ordering::SeqCst);
|
|
let mut child = process.child.lock().await;
|
|
child.kill().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn monitor_server_process(
|
|
server_id: String,
|
|
dir: PathBuf,
|
|
process: Arc<ServerProcess>,
|
|
) {
|
|
loop {
|
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
|
let exit_status = {
|
|
let mut child = process.child.lock().await;
|
|
match child.try_wait() {
|
|
Ok(Some(success)) => Some(success),
|
|
Ok(None) => continue,
|
|
Err(_) => None,
|
|
}
|
|
};
|
|
|
|
SERVER_PROCESSES.remove(&server_id);
|
|
let stop_requested = process.stop_requested.load(Ordering::SeqCst);
|
|
let eula_accepted = read_eula_accepted(&dir).await;
|
|
let crashed = exit_status
|
|
.map(|success| !success && !stop_requested && eula_accepted)
|
|
.unwrap_or(false);
|
|
|
|
// Classify self-exits from the tail of the console output so the UI
|
|
// can react (e.g. offer the EULA dialog). User-requested stops and
|
|
// unmatched exits stay unclassified. The brief settle wait lets the
|
|
// output-stream tasks flush their final lines into the buffer first.
|
|
let reason = if stop_requested {
|
|
None
|
|
} else {
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
analyze_exit_reason(&get_log_buffer(&server_id))
|
|
};
|
|
|
|
if let Ok(mut manifest) = read_manifest(&dir).await {
|
|
manifest.last_exit_crashed = crashed;
|
|
let _ = write_manifest(&dir, &manifest).await;
|
|
}
|
|
|
|
emit_server(&server_id, ServerPayloadType::Stopped { crashed, reason })
|
|
.await
|
|
.ok();
|
|
return;
|
|
}
|
|
}
|
|
|
|
/// Builds the JVM launch arguments for a Forge server. Modern Forge (1.17+)
|
|
/// ships `@args` files that enumerate the classpath and main class; legacy Forge
|
|
/// (<=1.16) produces a single runnable `forge-*.jar`.
|
|
fn forge_launch_args(dir: &Path) -> Result<Vec<String>> {
|
|
let forge_dir = dir
|
|
.join("libraries")
|
|
.join("net")
|
|
.join("minecraftforge")
|
|
.join("forge");
|
|
if let Ok(entries) = std::fs::read_dir(&forge_dir) {
|
|
let args_file = if cfg!(windows) {
|
|
"win_args.txt"
|
|
} else {
|
|
"unix_args.txt"
|
|
};
|
|
for entry in entries.flatten() {
|
|
let candidate = entry.path().join(args_file);
|
|
if candidate.is_file() {
|
|
let mut args = Vec::new();
|
|
if dir.join("user_jvm_args.txt").exists() {
|
|
args.push("@user_jvm_args.txt".to_string());
|
|
}
|
|
args.push(format!("@{}", candidate.to_string_lossy()));
|
|
args.push("nogui".to_string());
|
|
return Ok(args);
|
|
}
|
|
}
|
|
}
|
|
if let Some(jar) = find_forge_jar(dir) {
|
|
return Ok(vec!["-jar".to_string(), jar, "nogui".to_string()]);
|
|
}
|
|
Err(ErrorKind::LauncherError(
|
|
"Forge server files are missing. Reinstall the server.".to_string(),
|
|
)
|
|
.as_error())
|
|
}
|
|
|
|
fn find_forge_jar(dir: &Path) -> Option<String> {
|
|
let entry = std::fs::read_dir(dir).ok()?.flatten().find(|e| {
|
|
e.file_name().to_string_lossy().starts_with("forge-")
|
|
&& e.path().extension().is_some_and(|ext| ext == "jar")
|
|
})?;
|
|
Some(entry.file_name().to_string_lossy().into_owned())
|
|
}
|
|
|
|
async fn read_eula_accepted(dir: &Path) -> bool {
|
|
match tokio::fs::read_to_string(dir.join("eula.txt")).await {
|
|
Ok(text) => text
|
|
.lines()
|
|
.find_map(|line| line.split_once('='))
|
|
.filter(|(key, _)| key.trim() == "eula")
|
|
.is_some_and(|(_, value)| {
|
|
value.trim().eq_ignore_ascii_case("true")
|
|
}),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
/// Emits a timestamped, info-level line to the server console: it is both
|
|
/// persisted to the log buffer and pushed as a live `Log` event, so startup
|
|
/// progress is visible even before the JVM produces any output of its own.
|
|
async fn log_server_step(server_id: &str, message: &str) {
|
|
let line = format!(
|
|
"{} [Axolotl/INFO]: {}",
|
|
chrono::Local::now().format("%H:%M:%S"),
|
|
message,
|
|
);
|
|
push_log_line(server_id, line.clone());
|
|
emit_server(server_id, ServerPayloadType::Log { line })
|
|
.await
|
|
.ok();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[derive(Clone)]
|
|
struct SharedWriter(Arc<std::sync::Mutex<Vec<u8>>>);
|
|
|
|
impl std::io::Write for SharedWriter {
|
|
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
|
|
self.0.lock().unwrap().extend_from_slice(buffer);
|
|
Ok(buffer.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn selects_pty_only_for_forge() {
|
|
assert!(uses_pty_transport("forge"));
|
|
assert!(!uses_pty_transport("vanilla"));
|
|
assert!(!uses_pty_transport("fabric"));
|
|
assert!(!uses_pty_transport("neoforge"));
|
|
}
|
|
|
|
#[test]
|
|
fn whole_line_commands_end_with_one_newline() {
|
|
assert_eq!(command_bytes("say hello"), b"say hello\n");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pty_input_preserves_raw_bytes() {
|
|
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let mut input =
|
|
ServerInput::Pty(Box::new(SharedWriter(captured.clone())));
|
|
input.write_all(b"\x1b[D\t\x7f").await.unwrap();
|
|
assert_eq!(*captured.lock().unwrap(), b"\x1b[D\t\x7f");
|
|
}
|
|
}
|