diff --git a/.github/workflows/native_build.yml b/.github/workflows/native_build.yml new file mode 100644 index 0000000..601e727 --- /dev/null +++ b/.github/workflows/native_build.yml @@ -0,0 +1,107 @@ +name: Build Native +on: + workflow_dispatch: + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + build_date: ${{ steps.get_date.outputs.date }} + steps: + - name: Generate build date + id: get_date + run: echo "date=$(date +%s)" >> "$GITHUB_OUTPUT" + build: + name: Build ${{ matrix.target }} + needs: prepare + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: ./native + strategy: + fail-fast: false + matrix: + include: + # Linux (x86_64) + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + extension: "" + # Linux (aarch64) + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + extension: "" + # macOS (x86_64) + - os: macos-latest + target: x86_64-apple-darwin + extension: "" + # macOS (aarch64) + - os: macos-latest + target: aarch64-apple-darwin + extension: "" + # Windows (x86_64) + - os: windows-latest + target: x86_64-pc-windows-msvc + extension: ".exe" + # Windows (aarch64) + - os: windows-latest + target: aarch64-pc-windows-msvc + extension: ".exe" + env: + CI_BUILD_DATE: ${{ needs.prepare.outputs.build_date }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Install dependencies + if: matrix.os == 'ubuntu-latest' && matrix.target == 'x86_64-unknown-linux-gnu' + run: sudo apt-get install -y libcurl4-openssl-dev libssl-dev + + - name: Install arm64 dependencies + if: matrix.os == 'ubuntu-latest' && matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo sed -i 's/^Types: deb/Types: deb\nArchitectures: amd64/' /etc/apt/sources.list.d/ubuntu.sources + + UBUNTU_CODENAME=$(lsb_release -cs) + sudo cat <, + jvm_args: Vec, + app_args: Vec, + aot_cache_hash: [u8; 32], +} + +impl AotMeta { + pub fn build( + java_home: &str, + classpath: &[OsString], + jvm_args: &[String], + app_args: &[String], + aot_cache_file: &Path, + ) -> Result { + let java_home = Path::new(java_home); + let java = java_bin(java_home); + + let output = err! { + Command::new(&java).arg("-Xinternalversion").output() + => format!("Failed to execute 'java -Xinternalversion' command ({})", java.display()) + }?; + let jvm_ident = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + let mut classpath_hashed = HashMap::::new(); + for path in classpath { + let file = Path::new(path); + if !file.exists() { + return generic!( + "Cannot compute AOT info: classpath entry does not exist: {}", + path.display() + ); + } + + let meta = err! { + std::fs::metadata(path) + => format!("Failed to read file metadata for {}", path.display()) + }?; + let modified_time = err! { + try { + meta + .modified().loc(l!())? + .duration_since(SystemTime::UNIX_EPOCH).loc(l!())? + .as_millis() + } + => format!("Failed to read file modification time for {}", path.display()) + }?; + let hash = l!(file_hash(file))?; + classpath_hashed.insert( + path.clone(), + Fingerprint { + hash, + timestamp: modified_time, + }, + ); + } + + let aot_cache_hash = l!(file_hash(aot_cache_file))?; + + Ok(AotMeta { + jvm_ident, + classpath: classpath_hashed, + jvm_args: jvm_args.iter().map(|s| s.to_string()).collect(), + app_args: app_args.iter().map(|s| s.to_string()).collect(), + aot_cache_hash, + }) + } + + pub fn aot_files(dir: &Path) -> (PathBuf, PathBuf) { + (AotMeta::aot_cache_file(dir), AotMeta::aot_meta_file(dir)) + } + pub fn aot_cache_file(dir: &Path) -> PathBuf { + dir.join(".paper").join("cache").join(AOT_CACHE_FILE) + } + + pub fn aot_meta_file(dir: &Path) -> PathBuf { + dir.join(".paper").join("cache").join(AOT_CACHE_META) + } + + pub fn read(aot_meta_file: &Path) -> Result { + let bytes = err! { + std::fs::read(aot_meta_file) + => format!("Failed to read AOT meta file: {}", aot_meta_file.display()) + }?; + let meta = err! { + postcard::from_bytes::(&bytes) + => format!("Failed to parse AOT meta file: {}", aot_meta_file.display()) + }?; + Ok(meta) + } + + pub fn write(&self, aot_meta_file: &Path) -> Result<(), Error> { + let bytes = Vec::new(); + let bytes = err! { + postcard::to_extend(&self, bytes) + => "Failed to serialize AOT meta file" + }?; + if let Some(parent) = aot_meta_file.parent() { + create_directory(parent)?; + } + let tmp_out = aot_meta_file.with_file_name("paper.aot.meta.tmp"); + err! { + std::fs::write(&tmp_out, &bytes) + => format!("Failed to write AOT meta file: {}", tmp_out.display()) + }?; + err! { + std::fs::rename(&tmp_out, aot_meta_file) + => format!("Failed to rename AOT meta file: {} -> {}", tmp_out.display(), aot_meta_file.display()) + }?; + + Ok(()) + } +} + +pub enum AotCacheAction { + Use { aot_cache_file: PathBuf }, + Record { aot_cache_file: PathBuf }, + None, +} +impl AotCacheAction { + pub fn use_cache(aot_cache_file: PathBuf) -> Self { + Self::Use { aot_cache_file } + } + + pub fn record(aot_cache_file: PathBuf) -> Self { + Self::Record { aot_cache_file } + } + pub fn none() -> Self { + Self::None + } +} + +pub fn check_aot_opt( + repo_dir: &Path, + mode: RecordMode, + java_home: &str, + classpath: &[OsString], + jvm_args: &[String], + app_args: &[String], +) -> Result { + if mode == RecordMode::NoAot { + return Ok(AotCacheAction::none()); + } + + let (aot_cache_file, aot_meta_file) = AotMeta::aot_files(repo_dir); + if let Some(parent) = aot_cache_file.parent() { + create_directory(parent)?; + } + + if !aot_cache_file.exists() || !aot_meta_file.exists() { + return match mode { + RecordMode::Normal | RecordMode::OnlyRecord | RecordMode::ForceRecord => { + Ok(AotCacheAction::record(aot_cache_file)) + } + RecordMode::Check => Err(Error::Exit(1)), + RecordMode::OnlyUse => { + eprintln!("No AOT cache found"); + Err(Error::Exit(ONLY_USE_AOT_FAILED_EXIT_CODE)) + } + RecordMode::NoRecord => Ok(AotCacheAction::none()), + RecordMode::NoAot => unreachable!(), + }; + } + + let jvm_args = copy_owned(jvm_args); + let app_args = copy_owned(app_args); + let current_meta = l!(AotMeta::build( + java_home, + classpath, + &jvm_args, + &app_args, + &aot_cache_file + ))?; + let saved_meta = l!(AotMeta::read(&aot_meta_file))?; + + match mode { + RecordMode::Normal => { + if current_meta == saved_meta { + Ok(AotCacheAction::use_cache(aot_cache_file)) + } else { + Ok(AotCacheAction::record(aot_cache_file)) + } + } + RecordMode::Check => Err(Error::Exit(if current_meta == saved_meta { 0 } else { 1 })), + RecordMode::OnlyUse => { + if current_meta != saved_meta { + eprintln!("AOT cache is invalid"); + Err(Error::Exit(ONLY_USE_AOT_FAILED_EXIT_CODE)) + } else { + Ok(AotCacheAction::use_cache(aot_cache_file)) + } + } + RecordMode::NoRecord => { + if current_meta != saved_meta { + Ok(AotCacheAction::none()) + } else { + Ok(AotCacheAction::use_cache(aot_cache_file)) + } + } + RecordMode::OnlyRecord => { + if current_meta == saved_meta { + Err(Error::Exit(0)) + } else { + Ok(AotCacheAction::record(aot_cache_file)) + } + } + RecordMode::ForceRecord => Ok(AotCacheAction::record(aot_cache_file)), + RecordMode::NoAot => unreachable!(), + } +} + +pub fn setup_auto_recording( + jvm: Arc, + java_home: &str, + classpath: &[OsString], + jvm_args: &[String], + app_args: &[String], + mode: RecordMode, +) -> Option>> { + let cwd = match std::env::current_dir() { + Ok(p) => p, + Err(e) => { + eprintln!("Failed to find current directory: {}", e); + return None; + } + }; + let aot_meta_file = AotMeta::aot_meta_file(&cwd); + + if aot_meta_file.exists() { + return None; + } + + // Make copies to pass to the other thread + let jvm_cache_checker = jvm.clone(); + let java_home = java_home.to_string(); + let classpath = classpath.to_vec(); + let jvm_args = copy_owned(jvm_args); + let app_args = copy_owned(app_args); + + let meta_thread_handle = std::thread::spawn(move || { + let watchdog_thread = jni_str!("org/spigotmc/WatchdogThread"); + let has_started_field = jni_str!("hasStarted"); + let has_started_sig = jni_sig!("Z"); + + loop { + std::thread::sleep(Duration::from_millis(100)); + // We don't stay attached, so if the server stops early, `jvm.destroy()` won't be deadlocked waiting + // for us to release the last non-daemon thread. + + let mut scope = ScopeToken::default(); + let mut guard = l!(jni_attach_thread( + &jvm_cache_checker, + &mut scope, + jni_str!("aot-cache-checker"), + ))?; + let env = guard.borrow_env_mut(); + + // While we are waiting, we need to make sure the `main` and `Server thread` threads are still running. + // If both threads are gone, that means the server has crashed. + if !check_threads_are_running(env).loc(l!())? { + return Ok(()); + } + + let has_started = env + .get_static_field(watchdog_thread, has_started_field, &has_started_sig) + .loc(l!())? + .z(); + match has_started { + Ok(true) => break, + Ok(false) => continue, + Err(jni::errors::Error::JavaException) => { + env.exception_clear(); // ignore it + continue; + } + Err(e) => return l!(Err(Error::from(e))), + }; + } + + // Server has completed starting + + let ended_recording = end_aot_recording(&jvm_cache_checker); + let ended_recording = match ended_recording { + Ok(r) => r, + Err(e) => { + return l!(Err(Error::wrap("Failed to end AOT cache recording", e))); + } + }; + + if ended_recording { + // Recording is done, we need to write the meta file + let aot_cache_file = AotMeta::aot_cache_file(&cwd); + if !aot_cache_file.exists() { + return generic!("No AOT cache file found after recording!"); + } + + let meta = match AotMeta::build( + &java_home, + &classpath, + &jvm_args, + &app_args, + &aot_cache_file, + ) { + Ok(m) => m, + Err(e) => { + return l!(Err(Error::wrap("Failed to generate AOT cache meta", e))); + } + }; + + if let Err(e) = meta.write(&aot_meta_file) { + return l!(Err(Error::wrap("Failed to write AOT cache meta", e))); + }; + + let res: Result<(), Error> = jvm_cache_checker.attach_current_thread_with_config( + || { + AttachConfig::default() + .scoped(true) + .thread_name(jni_str!("aot-cache-checker")) + }, + None, + |env| { + let logger = err! { + get_logger(env) + => "Failed to get logger" + }?; + + let message = l!(env.new_string("AOT cache meta written successfully."))?; + env.call_method( + &logger, + jni_str!("info"), + jni_sig!("(Ljava/lang/String;)V"), + &[JValue::Object(&message)], + ) + .loc(l!())?; + + Ok(()) + }, + ); + if let Err(e) = res { + eprintln!("Error logging message: {e}"); + } + } + + // For only record, stop the server + match mode { + RecordMode::OnlyRecord | RecordMode::ForceRecord => { + let _: Result<(), Error> = jvm_cache_checker.attach_current_thread(|env| { + let minecraft_server = env + .get_static_field( + jni_str!("net/minecraft/server/MinecraftServer"), + jni_str!("SERVER"), + jni_sig!("Lnet/minecraft/server/MinecraftServer;"), + ) + .loc(l!())? + .into_object() + .loc(l!())?; + if minecraft_server.is_null() { + return Ok(()); + } + env.call_method( + minecraft_server, + jni_str!("halt"), + jni_sig!("(Z)V"), + &[JValue::Bool(true)], + ) + .loc(l!())?; + Ok(()) + }); + } + _ => {} + } + + Ok(()) + }); + Some(meta_thread_handle) +} + +fn end_aot_recording(jvm: &JavaVM) -> Result { + let mut scope = ScopeToken::default(); + let mut guard = l!(jni_attach_thread( + jvm, + &mut scope, + jni_str!("aot-cache-checker") + ))?; + let env = guard.borrow_env_mut(); + + let logger = try { + let logger = err! { + get_logger(env) + => "Failed to get logger" + }?; + + let message = l!(env.new_string("AOT cache recording ended. Writing AOT file..."))?; + env.call_method( + &logger, + jni_str!("info"), + jni_sig!("(Ljava/lang/String;)V"), + &[JValue::Object(&message)], + ) + .loc(l!())?; + + logger + }; + if let Err(ref e) = logger { + eprintln!("Failed to get logger: {e}"); + } + + let aot_cache_bean_class = + l!(env.find_class(jni_str!("jdk/management/HotSpotAOTCacheMXBean")))?; + // Trigger record + let aot_cache_bean = env + .call_static_method( + jni_str!("java/lang/management/ManagementFactory"), + jni_str!("getPlatformMXBean"), + jni_sig!("(Ljava/lang/Class;)Ljava/lang/management/PlatformManagedObject;"), + &[JValue::Object(&aot_cache_bean_class)], + ) + .loc(l!())? + .into_object() + .loc(l!())?; + if aot_cache_bean.is_null() { + return null!("ManagementFactory", "getThreadMXBean()"); + } + + let ended = env + .call_method( + &aot_cache_bean, + jni_str!("endRecording"), + jni_sig!("()Z"), + &[], + ) + .loc(l!())? + .z() + .loc(l!())?; + + if let Ok(logger) = logger { + // Ignore errors here, it's just logging. + let _ = try { + if ended { + let message = l!(env.new_string("AOT cache file written successfully."))?; + env.call_method( + &logger, + jni_str!("info"), + jni_sig!("(Ljava/lang/String;)V"), + &[JValue::Object(&message)], + ) + } else { + let message = l!(env.new_string("AOT cache file writing failed."))?; + env.call_method( + &logger, + jni_str!("error"), + jni_sig!("(Ljava/lang/String;)V"), + &[JValue::Object(&message)], + ) + } + }; + } + + Ok(ended) +} + +fn get_logger<'a>(env: &mut Env<'a>) -> Result, Error> { + let logger_name = l!(env.new_string("AOT"))?; + env.call_static_method( + jni_str!("org/slf4j/LoggerFactory"), + jni_str!("getLogger"), + jni_sig!("(Ljava/lang/String;)Lorg/slf4j/Logger;"), + &[JValue::Object(&logger_name)], + ) + .loc(l!())? + .into_object() + .loc(l!()) +} + +// Threading + +fn check_threads_are_running(env: &mut Env) -> Result { + let thread_mx_bean = env + .call_static_method( + jni_str!("java/lang/management/ManagementFactory"), + jni_str!("getThreadMXBean"), + jni_sig!("()Ljava/lang/management/ThreadMXBean;"), + &[], + ) + .loc(l!())? + .into_object() + .loc(l!())?; + if thread_mx_bean.is_null() { + return null!("ManagementFactory", "getThreadMXBean()"); + } + + let all_threads = env + .call_method( + &thread_mx_bean, + jni_str!("dumpAllThreads"), + jni_sig!("(ZZI)[Ljava/lang/management/ThreadInfo;"), + &[JValue::Bool(false), JValue::Bool(false), JValue::Int(0)], + ) + .loc(l!())? + .into_object() + .loc(l!())?; + let all_threads = l!(env.cast_local::(all_threads))?; + let len = l!(all_threads.len(env))?; + + if len == 0 { + return Ok(false); + } + + let terminated = env + .get_static_field( + jni_str!("java/lang/Thread$State"), + jni_str!("TERMINATED"), + jni_sig!("Ljava/lang/Thread$State;"), + ) + .loc(l!())? + .into_object() + .loc(l!())?; + + for i in 0..len { + let thread_info = l!(all_threads.get_element(env, i))?; + if thread_info.is_null() { + continue; + } + + let thread_name = l!(thread_name(env, &thread_info))?; + if thread_name != "Server thread" { + continue; + } + + let thread_state = env + .call_method( + thread_info, + jni_str!("getThreadState"), + jni_sig!("()Ljava/lang/Thread$State;"), + &[], + ) + .loc(l!())? + .into_object() + .loc(l!())?; + if thread_state.is_null() { + return null!("ThreadInfo", "getThreadState()"); + } + + let is_terminated = l!(env.is_same_object(&terminated, &thread_state))?; + if !is_terminated { + // At least one thread is still running + return Ok(true); + } + } + + Ok(false) +} + +fn thread_name(env: &mut Env, thread_info: &JObject) -> Result { + let thread_name = env + .call_method( + thread_info, + jni_str!("getThreadName"), + jni_sig!("()Ljava/lang/String;"), + &[], + ) + .loc(l!())? + .into_object() + .loc(l!())?; + if thread_name.is_null() { + return null!("ThreadInfo", "getThreadName()"); + } + let thread_name = l!(env.cast_local::(thread_name))?; + let thread_name = l!(thread_name.mutf8_chars(env))?; + let thread_name = thread_name.to_str(); + Ok(thread_name.to_string()) +} diff --git a/native/src/args.rs b/native/src/args.rs new file mode 100644 index 0000000..c003d5d --- /dev/null +++ b/native/src/args.rs @@ -0,0 +1,686 @@ +use crate::ONLY_USE_AOT_FAILED_EXIT_CODE; +use crate::errors::Error; +use crate::util::ComposingIterator; +use indoc::indoc; +use std::fs::File; +use std::io::{BufRead, BufReader}; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ArgOptions { + pub jar: String, + pub jvm_args: Vec, + pub app_args: Vec, + pub record: RecordMode, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)] +pub enum RecordMode { + #[default] + Normal, + Check, + OnlyUse, + NoRecord, + OnlyRecord, + ForceRecord, + NoAot, +} + +// Split args into (jvm_args, app_args) +// This function handles --version, --help, and other cases +pub fn split_args(args: Vec) -> Result, Error> { + // There are 3 categories of args, which this function tries to split organically: + // 1. paperclip args + // 2. jvm args + // 3. app args + // If a paperclip arg is found, it's handled and we immediately return. + // jvm args come before -jar, and app args come after -jar. + + let mut record_preference = RecordMode::default(); + let mut jar: Option = None; + let mut jvm_args = Vec::::new(); + let mut app_args = Vec::::new(); + + let mut args_iter = ComposingIterator::new(Box::new(args.into_iter())); + let first_arg = args_iter.next().unwrap(); + + let mut disable_at_files = false; + + loop { + let next = args_iter.next(); + if next.is_none() { + break; + } + let arg: String = next.unwrap(); + + if !disable_at_files && !args_iter.is_nested() && arg.starts_with('@') { + let mut nested = Vec::::new(); + args_file(&arg, &mut nested)?; + args_iter.push_layer(Box::new(nested.into_iter())); + continue; + } + + match arg.as_str() { + "-h" | "--help" => { + if jvm_args.is_empty() && app_args.is_empty() && jar.is_none() { + return print_help(first_arg); + } + app_args.push(arg); + } + "-v" | "--version" => { + if jvm_args.is_empty() && app_args.is_empty() && jar.is_none() { + return print_version(); + } + app_args.push(arg); + } + "--check-aot" | "--only-use-aot" | "--no-record" | "--only-record" + | "--force-record" | "--no-aot" => { + if jvm_args.is_empty() && app_args.is_empty() && jar.is_none() { + if record_preference != RecordMode::Normal { + eprintln!( + "--check-aot, --only-use-aot, --no-record, --only-record, \ + --force-record, and --no-aot may only be specified once" + ); + return Err(Error::Exit(1)); + } + record_preference = match arg.as_str() { + "--check-aot" => RecordMode::Check, + "--only-use-aot" => RecordMode::OnlyUse, + "--no-record" => RecordMode::NoRecord, + "--only-record" => RecordMode::OnlyRecord, + "--force-record" => RecordMode::ForceRecord, + "--no-aot" => RecordMode::NoAot, + _ => unreachable!(), + }; + } else { + app_args.push(arg); + } + } + "-jar" | "--jar" => { + if jar.is_some() { + eprintln!("-jar may only be specified once"); + return Err(Error::Exit(1)); + } + + let jar_file = args_iter.next(); + if jar_file.is_none() { + eprintln!("-jar requires a jar file argument"); + return Err(Error::Exit(1)); + } + jar = Some(jar_file.unwrap()); + } + "--disable-@files" => disable_at_files = true, + _ => { + if jar.is_none() { + jvm_args.push(arg); + } else { + app_args.push(arg); + } + } + } + } + + if jar.is_none() { + eprintln!("-jar argument must be provided"); + return Err(Error::Exit(1)); + } + + Ok(Some(ArgOptions { + jar: jar.unwrap(), + record: record_preference, + jvm_args, + app_args, + })) +} + +fn args_file(arg: &str, target: &mut Vec) -> Result<(), Error> { + if !arg.starts_with('@') { + return Ok(()); + } + + if arg.starts_with("@@") { + target.push(arg[1..].to_string()); + return Ok(()); + } + + let filepath = &arg[1..]; + let file = File::open(filepath)?; + let metadata = file.metadata()?; + if metadata.len() > 2_147_483_647 { + return Err(Error::Generic(format!( + "Argument file {} exceeds maximum size of 2147483647 bytes", + filepath + ))); + } + + let reader = BufReader::new(file); + let stream = CharStream::new(reader); + parse_args_file_stream(stream, target)?; + + Ok(()) +} + +struct CharStream { + reader: R, + peeked: Vec, + bytes_read: u64, +} + +impl CharStream { + fn new(reader: R) -> Self { + Self { + reader, + peeked: Vec::with_capacity(4), + bytes_read: 0, + } + } + + fn read_one_char(&mut self) -> Result, std::io::Error> { + let mut first_byte = [0u8; 1]; + if self.reader.read(&mut first_byte)? == 0 { + return Ok(None); + } + self.bytes_read += 1; + if self.bytes_read > 2_147_483_647 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Argument file exceeds maximum size of 2147483647 bytes", + )); + } + + let b = first_byte[0]; + if b < 0x80 { + return Ok(Some(b as char)); + } + + let len = if b & 0xE0 == 0xC0 { + 2 + } else if b & 0xF0 == 0xE0 { + 3 + } else if b & 0xF8 == 0xF0 { + 4 + } else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Invalid UTF-8 sequence", + )); + }; + + let mut buf = vec![0u8; len]; + buf[0] = b; + self.reader.read_exact(&mut buf[1..])?; + self.bytes_read += (len - 1) as u64; + + let s = std::str::from_utf8(&buf) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + + Ok(s.chars().next()) + } + + fn peek(&mut self, offset: usize) -> Result, std::io::Error> { + while self.peeked.len() <= offset { + match self.read_one_char()? { + Some(c) => self.peeked.push(c), + None => return Ok(None), + } + } + Ok(Some(self.peeked[offset])) + } + + fn next(&mut self) -> Result, std::io::Error> { + if !self.peeked.is_empty() { + return Ok(Some(self.peeked.remove(0))); + } + self.read_one_char() + } +} + +fn parse_args_file_stream( + mut stream: CharStream, + target: &mut Vec, +) -> Result<(), Error> { + let mut current_arg = String::new(); + let mut in_token = false; + let mut quote_char: Option = None; + + while let Some(ch) = stream.peek(0)? { + // Line Continuation ('\' followed by EOL) + if ch == '\\' { + let next1 = stream.peek(1)?; + let mut eol_len = 0; + if next1 == Some('\n') { + eol_len = 1; + } else if next1 == Some('\r') { + let next2 = stream.peek(2)?; + if next2 == Some('\n') { + eol_len = 2; + } else { + eol_len = 1; + } + } + + if eol_len > 0 { + stream.next()?; // consume '\' + for _ in 0..eol_len { + stream.next()?; + } + in_token = true; + + // Check if the first character on the new line is '\' + if stream.peek(0)? == Some('\\') { + // Continuation character ('\') at column 1 prevents trimming leading whitespace + stream.next()?; + } else { + // Trim leading whitespace on the new line + while let Some(c) = stream.peek(0)? { + if c == ' ' || c == '\t' || c == '\u{000C}' { + stream.next()?; + } else { + break; + } + } + } + continue; + } + } + + // Escape Sequence ('\' NOT followed by EOL) + if ch == '\\' { + stream.next()?; // consume '\' + if let Some(next_c) = stream.next()? { + match next_c { + 'n' => current_arg.push('\n'), + 'r' => current_arg.push('\r'), + 't' => current_arg.push('\t'), + 'f' => current_arg.push('\u{000C}'), + '\\' => current_arg.push('\\'), + '"' => current_arg.push('"'), + '\'' => current_arg.push('\''), + '#' => current_arg.push('#'), + c => { + current_arg.push('\\'); + current_arg.push(c); + } + } + in_token = true; + } else { + current_arg.push('\\'); + in_token = true; + } + continue; + } + + // Quotes ('"' or '\'') + if ch == '"' || ch == '\'' { + stream.next()?; // consume quote char + if let Some(open_q) = quote_char { + if open_q == ch { + quote_char = None; + } else { + current_arg.push(ch); + } + } else { + quote_char = Some(ch); + } + in_token = true; + continue; + } + + // EOL ('\n' or '\r') + if ch == '\n' || ch == '\r' { + if quote_char.is_some() { + // Inside open quote, EOL without '\' continuation is discarded + stream.next()?; + if ch == '\r' && stream.peek(0)? == Some('\n') { + stream.next()?; + } + } else { + if in_token { + push_token(current_arg, target); + current_arg = String::new(); + in_token = false; + } + stream.next()?; + if ch == '\r' && stream.peek(0)? == Some('\n') { + stream.next()?; + } + } + continue; + } + + // Comment ('#') + if ch == '#' && quote_char.is_none() { + if in_token { + push_token(current_arg, target); + current_arg = String::new(); + in_token = false; + } + while let Some(c) = stream.peek(0)? { + if c == '\n' || c == '\r' { + break; + } + stream.next()?; + } + continue; + } + + // Whitespace (' ', '\t', '\f') + if ch == ' ' || ch == '\t' || ch == '\u{000C}' { + stream.next()?; + if quote_char.is_some() { + current_arg.push(ch); + in_token = true; + } else { + if in_token { + push_token(current_arg, target); + current_arg = String::new(); + in_token = false; + } + } + continue; + } + + // Regular character + stream.next()?; + current_arg.push(ch); + in_token = true; + } + + if in_token { + push_token(current_arg, target); + } + + Ok(()) +} + +fn push_token(token: String, target: &mut Vec) { + let processed = if token.starts_with("@@") { + token[1..].to_string() + } else { + token + }; + target.push(processed); +} + +fn print_help(first_arg: String) -> Result, Error> { + println!( + indoc! {" + paperclip - Execute the Paper server with Project Leyden AOT + (Ahead-Of-Time) cache management. + + License: MIT + + Usage: {} [aot args] [jvm args] -jar [app args] + + Description: + Wrapper to launch the Paper server. Arguments are divided into JVM + arguments (for the Java Virtual Machine) and Application arguments + (for the Paper server itself). + + JVM arguments must be specified BEFORE any Application arguments. + If a JVM argument is encountered after Application arguments have + started, the program will terminate with an error. + + JVM Discovery: + Paperclip relies on the present of a Java Runtime Environment (JRE) + to execute the server. Paperclip attemps to find the JRE based on + the standard JRE discovery mechanisms of the platform its on. The + 'JAVA_HOME' environment variable will always take highest priority + when selecting from multiple JREs. Otherwise, the location of the + 'java' executable on the 'PATH' will be used. + + -jar The path to the Paper server jar file. This argument is + required. The jar file must be a valid Paperclip jar. + + AOT Arguments: + Arguments that control how paperclip handles AOT recording and cache + management. These must strictly come befre any JVM or app arguments, + and are mutually exclusive. Only one of the options in this group + are allowed at a time: + + --check-aot Checks if a valid AOT cache file exists. If it does, + paperclip will immediately exit with exit code 0. If + not, paperclip will exit with exit code 1. + --only-use-aot Require an existing vlid AOT cache to be present. If + no valid AOT cache file exists, paperclip will not + start, returning exit code {} instead. + --no-record Do not record AOT information. If a valid AOT cache + file already exists, it will be used. If no valid AOT + cache file exists, or the existing cache is out of + date, paperclip will ignore it and start without + recording a for a new AOT cache. + --only-record Only record a new AOT cache. Paperclip will shut down + the server immediately once the AOT cache has + successfuly been recorded. This option will not + re-record over a valid AOT cache. + --force-record Force paperclip to record a new AOT cache. This + option behaves the same as '--only-record', except it + will always record a new cache, even if a valid cache + already exists. + --no-aot Disable all AOT features completely. + + JVM Arguments: + Any argument that preceeds '-jar' after the AOT arguments is passed + directly into the JVM. This includes -Xmx, -XX:+UseG1GC, etc. as well + as others like -D for setting system properties. + + Application Arguments: + Any argument tha follows the jar file given to the '-jar' argument is + passed directly into the server. + "}, + first_arg, ONLY_USE_AOT_FAILED_EXIT_CODE, + ); + + Ok(None) +} + +fn print_version() -> Result, Error> { + println!("{}", crate::config::VERSION); + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn create_temp_arg_file( + prefix: &str, + content: &str, + ) -> (std::path::PathBuf, tempfile_cleanup::Cleanup) { + let mut path = std::env::temp_dir(); + let filename = format!( + "paperclip_test_args_{}_{}_{}.txt", + prefix, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + path.push(filename); + let mut file = File::create(&path).unwrap(); + file.write_all(content.as_bytes()).unwrap(); + let cleanup_path = path.clone(); + (path, tempfile_cleanup::Cleanup(cleanup_path)) + } + + mod tempfile_cleanup { + pub struct Cleanup(pub std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + } + + #[test] + fn test_non_at_arg() { + let mut target = Vec::new(); + args_file("foo", &mut target).unwrap(); + assert!(target.is_empty()); + } + + #[test] + fn test_escaped_at_arg() { + let mut target = Vec::new(); + args_file("@@foo", &mut target).unwrap(); + assert_eq!(target, vec!["@foo"]); + } + + #[test] + fn test_open_quote_example1() { + let content = "-cp \"lib/\ncool/\napp/\njars"; + let (path, _cleanup) = create_temp_arg_file("ex1", content); + let arg_str = format!("@{}", path.to_str().unwrap()); + let mut target = Vec::new(); + args_file(&arg_str, &mut target).unwrap(); + assert_eq!(target, vec!["-cp", "lib/cool/app/jars"]); + } + + #[test] + fn test_escaped_backslash_example2() { + let content = "-cp \"c:\\\\Program Files (x86)\\\\Java\\\\jre\\\\lib\\\\ext;c:\\\\Program Files\\\\Java\\\\jre9\\\\lib\\\\ext\""; + let (path, _cleanup) = create_temp_arg_file("ex2", content); + let arg_str = format!("@{}", path.to_str().unwrap()); + let mut target = Vec::new(); + args_file(&arg_str, &mut target).unwrap(); + assert_eq!( + target, + vec![ + "-cp", + "c:\\Program Files (x86)\\Java\\jre\\lib\\ext;c:\\Program Files\\Java\\jre9\\lib\\ext" + ] + ); + } + + #[test] + fn test_line_continuation_example3() { + let content = "-cp \"/lib/cool app/jars:\\\n/lib/another app/jars\""; + let (path, _cleanup) = create_temp_arg_file("ex3", content); + let arg_str = format!("@{}", path.to_str().unwrap()); + let mut target = Vec::new(); + args_file(&arg_str, &mut target).unwrap(); + assert_eq!( + target, + vec!["-cp", "/lib/cool app/jars:/lib/another app/jars"] + ); + } + + #[test] + fn test_line_continuation_column1_example4() { + let content = "-cp \"/lib/cool\\\n\\ app/jars\""; + let (path, _cleanup) = create_temp_arg_file("ex4", content); + let arg_str = format!("@{}", path.to_str().unwrap()); + let mut target = Vec::new(); + args_file(&arg_str, &mut target).unwrap(); + assert_eq!(target, vec!["-cp", "/lib/cool app/jars"]); + } + + #[test] + fn test_comments_and_disable_at_files() { + let content = "-Xmx1g\n# This is a comment\n--disable-@files\n-Xmx2g\n@another.txt"; + let (path, _cleanup) = create_temp_arg_file("ex5", content); + let arg_str = format!("@{}", path.to_str().unwrap()); + let mut target = Vec::new(); + args_file(&arg_str, &mut target).unwrap(); + assert_eq!( + target, + vec!["-Xmx1g", "--disable-@files", "-Xmx2g", "@another.txt"] + ); + } + + #[test] + fn test_single_backslash_path() { + // Spec line 21: c:\Program" "Files should evaluate to c:\Program Files + let content = "c:\\Program\" \"Files"; + let (path, _cleanup) = create_temp_arg_file("def1", content); + let arg_str = format!("@{}", path.to_str().unwrap()); + let mut target = Vec::new(); + args_file(&arg_str, &mut target).unwrap(); + assert_eq!(target, vec!["c:\\Program Files"]); + } + + #[test] + fn test_split_args_basic() { + let args = vec![ + "paperclip".to_string(), + "-Xmx2G".to_string(), + "-jar".to_string(), + "paper.jar".to_string(), + "--nogui".to_string(), + ]; + let res = split_args(args).unwrap().unwrap(); + assert_eq!(res.jar, "paper.jar"); + assert_eq!(res.record, RecordMode::Normal); + assert_eq!(res.jvm_args, vec!["-Xmx2G"]); + assert_eq!(res.app_args, vec!["--nogui"]); + } + + #[test] + fn test_split_args_aot_flag() { + let args = vec![ + "paperclip".to_string(), + "--only-use-aot".to_string(), + "-Xmx1G".to_string(), + "-jar".to_string(), + "server.jar".to_string(), + ]; + let res = split_args(args).unwrap().unwrap(); + assert_eq!(res.jar, "server.jar"); + assert_eq!(res.record, RecordMode::OnlyUse); + assert_eq!(res.jvm_args, vec!["-Xmx1G"]); + assert!(res.app_args.is_empty()); + } + + #[test] + fn test_split_args_with_at_file() { + let content = "-Xmx4g\n-Dfoo=bar\n-jar\npaper.jar\n--port\n25565"; + let (path, _cleanup) = create_temp_arg_file("split_at", content); + let args = vec!["paperclip".to_string(), format!("@{}", path.to_str().unwrap())]; + let res = split_args(args).unwrap().unwrap(); + assert_eq!(res.jar, "paper.jar"); + assert_eq!(res.jvm_args, vec!["-Xmx4g", "-Dfoo=bar"]); + assert_eq!(res.app_args, vec!["--port", "25565"]); + } + + #[test] + fn test_split_args_disable_at_files() { + let content1 = "-Xmx1g"; + let content2 = "-Xmx2g"; + let (path1, _c1) = create_temp_arg_file("dis_at1", content1); + let (path2, _c2) = create_temp_arg_file("dis_at2", content2); + let arg2_str = format!("@{}", path2.to_str().unwrap()); + + let args = vec![ + "paperclip".to_string(), + format!("@{}", path1.to_str().unwrap()), + "--disable-@files".to_string(), + arg2_str.clone(), + "-jar".to_string(), + "paper.jar".to_string(), + ]; + let res = split_args(args).unwrap().unwrap(); + assert_eq!(res.jar, "paper.jar"); + assert_eq!(res.jvm_args, vec!["-Xmx1g".to_string(), arg2_str]); + } + + + #[test] + fn test_split_args_no_jar_error() { + let args = vec!["paperclip".to_string(), "-Xmx1g".to_string()]; + assert!(split_args(args).is_err()); + } + + #[test] + fn test_split_args_duplicate_jar_error() { + let args = vec![ + "paperclip".to_string(), + "-jar".to_string(), + "a.jar".to_string(), + "-jar".to_string(), + "b.jar".to_string(), + ]; + assert!(split_args(args).is_err()); + } +} + diff --git a/native/src/classpath.rs b/native/src/classpath.rs new file mode 100644 index 0000000..43d38cb --- /dev/null +++ b/native/src/classpath.rs @@ -0,0 +1,547 @@ +use crate::errors::{Error, ErrorLoc}; +use crate::util::{ + bytes_matches_hash, create_file, extract_zip_entry, file_matches_hash, find_zip_entry, + open_zip, parse_hex_named, read_zip_entry, read_zip_entry_text, require_zip_entry, +}; +use crate::{err, generic, l}; +use qbsdiff::Bspatch; +use std::ffi::OsString; +use std::fmt::Debug; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use zip::ZipArchive; +use zip::result::ZipError; + +pub fn repo_dir() -> PathBuf { + let repo_dir = std::env::var_os("PAPERCLIP_BUNDLER_REPO_DIR"); + let repo_dir = repo_dir.as_ref().map(Path::new).unwrap_or(Path::new("")); + repo_dir.to_owned() +} + +pub fn setup_classpath( + dir: &Path, + jar_file: &Path, +) -> Result<(Vec, PaperclipMeta), Error> { + let mut paperclip_jar = open_zip(jar_file)?; + let meta = extract_metadata(&mut paperclip_jar)?; + + if !meta.patches.is_empty() && meta.download_context.is_none() { + return generic!( + "Patches found without a corresponding original-url in {}", + jar_file.display() + ); + } + + let base_file = if let Some(download_context) = &meta.download_context { + Some(err! { + download_context.download(dir) + => format!("Failed to download file: {}", download_context.file_name) + }?) + } else { + None + }; + + let mut classpath = extract_and_apply_patches(dir, &mut paperclip_jar, base_file, &meta)?; + let mut res = Vec::with_capacity(classpath.versions.len() + classpath.libraries.len()); + res.append(&mut classpath.versions); + res.append(&mut classpath.libraries); + Ok((res, meta)) +} + +pub struct PaperclipMeta { + pub patches: Vec, + pub versions: Vec, + pub libraries: Vec, + pub download_context: Option, + pub main_class: String, +} + +pub fn extract_metadata(jar: &mut ZipArchive) -> Result { + let patches_list = { + let entry = l!(find_zip_entry(jar, "META-INF/patches.list"))?; + if let Some(mut entry) = entry { + Some(l!(read_zip_entry_text(&mut entry))?) + } else { + None + } + }; + let versions_list = { + let mut entry = require_zip_entry(jar, "META-INF/versions.list")?; + l!(read_zip_entry_text(&mut entry))? + }; + let libraries_list = { + let mut entry = require_zip_entry(jar, "META-INF/libraries.list")?; + l!(read_zip_entry_text(&mut entry))? + }; + let download_context = { + let entry = l!(find_zip_entry(jar, "META-INF/download-context"))?; + if let Some(mut entry) = entry { + Some(l!(read_zip_entry_text(&mut entry))?) + } else { + None + } + }; + let main_class = { + let mut entry = require_zip_entry(jar, "META-INF/main-class")?; + l!(read_zip_entry_text(&mut entry))? + }; + + let patches_list = if let Some(patches_list) = patches_list { + Some(l!(PatchEntry::parse(&patches_list))?) + } else { + None + }; + + let versions_list = l!(FileEntry::parse("versions.list", &versions_list))?; + let libraries_list = l!(FileEntry::parse("libraries.list", &libraries_list))?; + + let download_context = if let Some(download_context) = download_context { + Some(l!(DownloadContext::parse(&download_context))?) + } else { + None + }; + + Ok(PaperclipMeta { + patches: patches_list.unwrap_or_default(), + versions: versions_list, + libraries: libraries_list, + download_context, + main_class, + }) +} + +fn extract_and_apply_patches + Debug>( + dir: &Path, + paperlcip_jar: &mut ZipArchive, + original_jar_file: Option

, + meta: &PaperclipMeta, +) -> Result { + let mut original_jar = err! { + try { + original_jar_file + .as_ref() + .map(|j| ZipArchive::new(File::open(j)?) ) + .transpose().loc(l!())? + } => format!("Failed to open jar: {:?}", original_jar_file) + }?; + + let mut classpath = Classpath { + versions: vec![], + libraries: vec![], + }; + err! { + try { + extract_files( + meta, + Location::Versions, + dir, + paperlcip_jar, + &mut original_jar, + &meta.versions, + &mut classpath, + )? + } => "Failed to extract versions" + }?; + err! { + try { + extract_files( + meta, + Location::Libraries, + dir, + paperlcip_jar, + &mut original_jar, + &meta.libraries, + &mut classpath, + )? + } => "Failed to extract libraries" + }?; + + err! { apply_patches(meta, dir, paperlcip_jar, &mut original_jar, &mut classpath) => + "Failed to apply patches" + }?; + + Ok(classpath) +} + +fn extract_files( + meta: &PaperclipMeta, + location: Location, + dir: &Path, + paperclip_jar: &mut ZipArchive, + original_jar: &mut Option>, + entries: &[FileEntry], + classpath: &mut Classpath, +) -> Result<(), Error> { + if entries.is_empty() { + return Ok(()); + } + + let jar_path = format!("META-INF/{}", location.name()); + let target_path = dir.join(location.name()); + for entry in entries { + err! { + entry.extract(FileEntryArgs { + meta, + location, + target_base_path: &target_path, + paperclip_jar, + original_jar, + jar_base_path: &jar_path, + classpath + }) + => format!("Failed to extract files from {}/{}: {}/{}", jar_path, entry.path, target_path.display(), entry.path) + }?; + } + + Ok(()) +} + +fn apply_patches( + meta: &PaperclipMeta, + dir: &Path, + paperclip_jar: &mut ZipArchive, + original_jar: &mut Option>, + classpath: &mut Classpath, +) -> Result<(), Error> { + if meta.patches.is_empty() { + return Ok(()); + } + if original_jar.is_none() { + return generic!("Patches provided without patch target"); + } + let original_jar = original_jar.as_mut().unwrap(); + + let mut announced = false; + for patch_entry in &meta.patches { + err! { + try { + announced |= patch_entry.apply_patch(dir, paperclip_jar, original_jar, announced, classpath)? + } => format!("Failed to apply patch: {}/{}", patch_entry.location, patch_entry.patch_path) + }?; + } + + Ok(()) +} + +#[derive(Copy, Clone)] +enum Location { + Versions, + Libraries, +} + +impl Location { + fn from_name(name: &str) -> Self { + match name { + "versions" => Self::Versions, + "libraries" => Self::Libraries, + _ => panic!("Invalid location: {name}"), + } + } + + fn name(&self) -> &'static str { + match self { + Self::Versions => "versions", + Self::Libraries => "libraries", + } + } +} + +#[derive(Debug)] +pub struct FileEntry { + pub hash: [u8; 32], + pub id: String, + pub path: String, +} + +struct FileEntryArgs<'a> { + meta: &'a PaperclipMeta, + location: Location, + target_base_path: &'a Path, + paperclip_jar: &'a mut ZipArchive, + original_jar: &'a mut Option>, + jar_base_path: &'a str, + classpath: &'a mut Classpath, +} + +impl FileEntry { + fn extract(&self, args: FileEntryArgs) -> Result<(), Error> { + for patch in &args.meta.patches { + if patch.location == args.location.name() && patch.output_path == self.path { + // This file will be created from a patch + return Ok(()); + } + } + + let output_path = args.target_base_path.join(&self.path); + if output_path.exists() && file_matches_hash(&output_path, &self.hash)? { + args.classpath + .push(args.location, output_path.into_os_string()); + return Ok(()); + } + + // The file may either be in the paperclip jar, or the original jar + let entry_path = format!("{}/{}", args.jar_base_path, self.path); + + if let Some(mut entry) = find_zip_entry(args.paperclip_jar, &entry_path)? { + err! { + extract_zip_entry(&mut entry, &entry_path, &output_path) + => format!("Failed to extract file from paperclip jar: {}/{}", args.jar_base_path, self.path) + }? + } else { + if let Some(original_jar) = args.original_jar { + if let Some(mut entry) = find_zip_entry(original_jar, &entry_path)? { + err! { + extract_zip_entry(&mut entry, &entry_path, &output_path) + => format!("Failed to extract file from original jar: {}/{}", args.jar_base_path, self.path) + }? + } else { + return generic!( + "{} not found in either paperclip jar or original jar", + self.path + ); + } + } else { + return generic!( + "{} not found in paperclip jar, and no original jar provided", + self.path + ); + } + } + + if !file_matches_hash(&output_path, &self.hash).loc(l!())? { + return generic!("Hash check failed for extracted file {}", self.path); + } + + args.classpath + .push(args.location, output_path.into_os_string()); + Ok(()) + } + + pub fn parse(file: &str, text: &str) -> Result, Error> { + let mut entries = Vec::::new(); + for line in text.lines() { + let parts: Vec<&str> = line.split("\t").collect(); + if parts.len() != 3 { + return generic!("Invalid line in {file}: {line}"); + } + + let hash = l!(parse_hex_named("hash", parts[0]))?; + let id = parts[1]; + let path = parts[2]; + + entries.push(FileEntry { + hash, + id: id.to_string(), + path: path.to_string(), + }); + } + + Ok(entries) + } +} + +#[derive(Debug)] +pub struct PatchEntry { + pub location: String, + pub original_hash: [u8; 32], + pub patch_hash: [u8; 32], + pub output_hash: [u8; 32], + pub original_path: String, + pub patch_path: String, + pub output_path: String, +} + +impl PatchEntry { + fn apply_patch( + &self, + repo_dir: &Path, + paperclip_jar: &mut ZipArchive, + original_jar: &mut ZipArchive, + announced: bool, + classpath: &mut Classpath, + ) -> Result { + let location = Location::from_name(&self.location); + let jar_path = format!("META-INF/{}/{}", self.location, self.original_path); + let output_file = repo_dir.join(&self.location).join(&self.output_path); + + // Short-cut if the patch is already applied + if output_file.exists() && file_matches_hash(&output_file, &self.output_hash).loc(l!())? { + classpath.push(location, output_file.into_os_string()); + return Ok(false); + } + + if !announced { + println!("Applying patches"); + } + + let mut jar_entry = match original_jar.by_name(jar_path.as_str()) { + Ok(entry) => entry, + Err(ZipError::FileNotFound) => { + return generic!("Input file not found in original jar {jar_path}"); + } + Err(e) => return l!(Err(Error::from(e))), + }; + + let mut input_file_data = Vec::::new(); + l!(jar_entry.read_to_end(&mut input_file_data))?; + + if !bytes_matches_hash(&input_file_data, &self.original_hash) { + return generic!("Hash check of input file failed for {jar_path}"); + } + + // Get and verify patch data is correct + let patch_jar_path = format!("META-INF/{}/{}", self.location, self.patch_path); + let mut patch_entry = match find_zip_entry(paperclip_jar, &patch_jar_path)? { + Some(entry) => entry, + None => { + return generic!("Patch file not found in paperclip jar: {}", &patch_jar_path); + } + }; + let patch_data = l!(read_zip_entry(&mut patch_entry))?; + + if !bytes_matches_hash(&patch_data, &self.patch_hash) { + return generic!("Hash check of patch file failed for {}", self.patch_path); + } + + err! { + try { + if let Some(parent) = output_file.parent() { + err! { + std::fs::create_dir_all(parent) + => format!("Failed to create directory: {}", parent.display()) + }?; + } + let mut target_file = create_file(&output_file).loc(l!())?; + + let patcher = Bspatch::new(&patch_data).loc(l!())?; + patcher.apply(&input_file_data, &mut target_file).loc(l!())?; + } => "Error executing bsdiff patch" + }?; + + if !file_matches_hash(&output_file, &self.output_hash).loc(l!())? { + return generic!("Patch not applied correctly for {}", self.output_path); + } + + classpath.push(location, output_file.into_os_string()); + Ok(true) + } + + fn parse(text: &str) -> Result, Error> { + let mut patches = Vec::::new(); + for line in text.lines() { + let parts: Vec<&str> = line.split("\t").collect(); + if parts.len() != 7 { + return generic!("Invalid line in patches.list: {}", line); + } + + let location = parts[0]; + let original_hash = l!(parse_hex_named("original_hash", parts[1]))?; + let patch_hash = l!(parse_hex_named("patch_hash", parts[2]))?; + let output_hash = l!(parse_hex_named("output_hash", parts[3]))?; + let original_path = parts[4]; + let patch_path = parts[5]; + let output_path = parts[6]; + + patches.push(PatchEntry { + location: location.to_string(), + original_hash, + patch_hash, + output_hash, + original_path: original_path.to_string(), + patch_path: patch_path.to_string(), + output_path: output_path.to_string(), + }); + } + + Ok(patches) + } +} + +#[derive(Debug)] +pub struct Config { + pub download_context: Option, + pub main_class: &'static str, + pub version: &'static str, + pub version_json: &'static str, +} + +#[derive(Debug)] +pub struct DownloadContext { + pub hash: [u8; 32], + pub url: String, + pub file_name: String, +} + +impl DownloadContext { + pub fn download(&self, repo_dir: &Path) -> Result { + let target_file = err! { self.create_output_target(repo_dir) => + format!("Failed to create directory: {}", repo_dir.display()) + }?; + if target_file.exists() && file_matches_hash(&target_file, &self.hash)? { + return Ok(target_file); + } + + println!("Downloading {}", self.file_name); + + let mut output_file = create_file(&target_file).loc(l!())?; + err! { + try { + let mut downloader = nyquest::blocking::get(self.url.to_string())?.into_read(); + std::io::copy(&mut downloader, &mut output_file).map_err(Into::into)? + } => format!("Failed to download: {}", self.file_name) + }?; + + if !file_matches_hash(&target_file, &self.hash).loc(l!())? { + return generic!( + "Hash check failed for downloaded file {}", + target_file.display() + ); + } + + Ok(target_file) + } + + fn create_output_target(&self, repo_dir: &Path) -> Result { + let target_path = repo_dir.join(".paper").join("cache").join(&self.file_name); + if let Some(parent) = target_path.parent() { + err! { std::fs::create_dir_all(parent) => + format!("Failed to create output directory {}", parent.display()) + }?; + } + Ok(target_path) + } + + fn parse(text: &str) -> Result { + let parts: Vec<&str> = text.split("\t").collect(); + if parts.len() != 3 { + return generic!("Invalid download-context: {}", text); + } + + let hash = l!(parse_hex_named("hash", parts[0]))?; + let url = parts[1]; + let file_name = parts[2]; + + Ok(DownloadContext { + hash, + url: url.to_string(), + file_name: file_name.to_string(), + }) + } +} + +#[derive(Debug)] +struct Classpath { + versions: Vec, + libraries: Vec, +} + +impl Classpath { + fn push(&mut self, location: Location, file_name: OsString) { + match location { + Location::Versions => &mut self.versions, + Location::Libraries => &mut self.libraries, + } + .push(file_name); + } +} diff --git a/native/src/errors.rs b/native/src/errors.rs new file mode 100644 index 0000000..9f87cad --- /dev/null +++ b/native/src/errors.rs @@ -0,0 +1,133 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("JNI error: {0:?}")] + Jni(#[from] jni::errors::Error), + #[error("JVM error: {0:?}")] + JVM(#[from] jni::JvmError), + #[error("JVM start error: {0:?}")] + StartJvm(#[from] jni::errors::StartJvmError), + #[error("Failed to find Java installation: {0:?}")] + JavaLoc(#[from] java_locator::errors::JavaLocatorError), + #[error("IO error: {0:?}")] + Io(#[from] std::io::Error), + #[error("Time error: {0:?}")] + Time(#[from] std::time::SystemTimeError), + #[error("Download error: {0:?}")] + Net(#[from] nyquest::Error), + #[error("Zip error: {0:?}")] + Zip(#[from] zip::result::ZipError), + #[error("UTF-8 error: {0:?}")] + Utf(#[from] std::string::FromUtf8Error), + #[error("Hex error: {0:?}")] + Hex(#[from] hex::FromHexError), + #[error("Parse error: {0:?}")] + Int(#[from] std::num::ParseIntError), + #[error("Serialization error: {0:?}")] + Postcard(#[from] postcard::Error), + #[error("{msg}\nCaused by: {cause}")] + Wrapped { + msg: String, + #[source] + cause: Box, + }, + #[error("{0}")] + Generic(String), + #[error("{file}:{line}: {error}")] + Loc { + file: &'static str, + line: u32, + #[source] + error: Box, + }, + #[error("Exiting with code {0}")] + Exit(i32), +} + +pub trait ErrorLoc { + type Output; + + fn loc(self, loc: Loc) -> Self::Output; +} + +impl> ErrorLoc for Result { + type Output = Result; + + fn loc(self, loc: Loc) -> Self::Output { + match self { + Ok(r) => Ok(r), + Err(e) => Err(Error::loc(loc.file, loc.line, e)), + } + } +} + +pub struct Loc { + pub file: &'static str, + pub line: u32, +} + +#[macro_export] +macro_rules! err { + ($res:expr => $msg:expr) => { + match $crate::l!($res) { + Ok(r) => Ok(r), + Err(e) => Err($crate::errors::Error::wrap($msg, e)), + } + }; +} + +#[macro_export] +macro_rules! l { + () => { + $crate::errors::Loc { + file: file!(), + line: line!(), + } + }; + ($res:expr) => { + match $res.map_err($crate::errors::Error::from) { + Ok(r) => Ok(r), + Err(Error::Loc { + file: _, + line: _, + error, + }) => Err(Error::loc(file!(), line!(), error)), // Reset loc info + Err(e) => Err(Error::loc(file!(), line!(), e)), + } + }; +} + +#[macro_export] +macro_rules! generic { + ($($arg:tt)*) => { + $crate::l!(Err($crate::errors::Error::generic(format!($($arg)*)))) + }; +} + +impl Error { + pub fn wrap(msg: impl Into, cause: impl Into) -> Self { + Error::Wrapped { + msg: msg.into(), + cause: Box::from(cause.into()), + } + } + + pub fn generic(s: impl Into) -> Self { + Error::Generic(s.into()) + } + + pub fn loc(file: &'static str, line: u32, cause: impl Into) -> Self { + Error::Loc { + file, + line, + error: Box::from(cause.into()), + } + } +} + +impl From> for Error { + fn from(value: Box) -> Self { + *value + } +} diff --git a/native/src/jni.rs b/native/src/jni.rs new file mode 100644 index 0000000..ad0d1db --- /dev/null +++ b/native/src/jni.rs @@ -0,0 +1,140 @@ +use crate::errors::Error; +use crate::{err, generic, l}; +use jni::objects::{JPrimitiveArray, TypeArray}; +use jni::strings::JNIStr; +use jni::{AttachConfig, AttachGuard, Env, JavaVM, ScopeToken}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub fn jni_attach_thread<'scope>( + jvm: &JavaVM, + scope: &'scope mut ScopeToken, + thread_name: &JNIStr, +) -> jni::errors::Result> { + unsafe { + jvm.attach_current_thread_guard( + || { + AttachConfig::default() + .scoped(true) + .thread_name(thread_name) + }, + scope, + ) + } +} + +pub fn as_primitive_array<'a: 'b, 'b, T: TypeArray>( + env: &mut Env<'a>, + array: &[T], +) -> Result, Error> { + let java_array = l!(JPrimitiveArray::::new(env, array.len()))?; + l!(java_array.set_region(env, 0, array))?; + Ok(java_array) +} + +#[macro_export] +macro_rules! null { + ($class:literal, $method:literal) => { + $crate::generic!("{}::{} returned 'null'", $class, $method) + }; +} + +pub fn java_bin(base: &Path) -> PathBuf { + let mut path = base.to_path_buf(); + path.push("bin"); + path.push("java"); + #[cfg(windows)] + { + path.set_extension("exe"); + } + path +} + +pub fn check_java_version(java_home: &str) -> Result<(), Error> { + let java_home = Path::new(java_home); + let java = java_bin(java_home); + + let version_text = err! { + Command::new(&java).arg("-version").output() + => format!("Failed to execute 'java -version' command ({})", java.display()) + }? + .stderr; + let version_text = String::from_utf8_lossy(&version_text); + let version_line = match version_text.lines().next() { + Some(l) => l, + None => { + return generic!("Failed to parse 'java -version' (no output)"); + } + }; + + let start = match version_line.chars().position(|c| c == '"') { + Some(i) => i + 1, // skip the quote + None => { + return generic!("Failed to parse 'java -version' (no version number found)"); + } + }; + + let version_line = &version_line[start..]; + let version = match version_line.chars().position(|c| c == '"') { + Some(i) => &version_line[..i], + None => return generic!("Failed to parse 'java -version' (no version number found)"), + }; + + let version_parts = version.split('.').take(3).collect::>(); + let major_version = l!(version_part(&version_parts, 0))?; + let minor_version = l!(version_part(&version_parts, 1))?; + let patch_version = l!(version_part(&version_parts, 2))?; + + let version_err = "Unsupported Java version: Java 25.0.4 or higher is required, found: "; + + // We require >25.0.4 + if major_version.is_none() || major_version.unwrap() < 25 { + let mut err_msg = format!( + "{}{}", + version_err, + major_version + .map(|v| v.to_string()) + .unwrap_or_else(|| "unknown".to_string()) + ); + if let (Some(minor_version), Some(patch_version)) = (minor_version, patch_version) { + err_msg.push_str(&format!(".{}.{}", minor_version, patch_version)); + } + eprintln!("{err_msg}"); + return Err(Error::Exit(1)); + } + let major_version = major_version.unwrap(); + if minor_version.is_none() || patch_version.is_none() { + // We can't verify it's 25.0.4, but we tried + return Ok(()); + } + let minor_version = minor_version.unwrap(); + let patch_version = patch_version.unwrap(); + if minor_version > 0 || patch_version >= 4 { + return Ok(()); + } + + eprintln!( + "{}{}.{}.{}", + version_err, major_version, minor_version, patch_version + ); + Err(Error::Exit(1)) +} + +fn version_part(parts: &[&str], index: usize) -> Result, Error> { + if parts.len() <= index { + return Ok(None); + } + let num = match parts[index].parse::() { + Ok(v) => v, + Err(e) => { + return l!(Err(Error::wrap( + format!( + "Failed to parse 'java -version' (invalid version number): {}", + parts[index] + ), + e, + ))); + } + }; + Ok(Some(num)) +} diff --git a/native/src/main.rs b/native/src/main.rs new file mode 100644 index 0000000..5af361c --- /dev/null +++ b/native/src/main.rs @@ -0,0 +1,311 @@ +#![feature(try_blocks)] + +pub mod aot; +pub mod args; +pub mod classpath; +pub mod errors; +pub mod jni; +pub mod util; + +use crate::aot::{AotCacheAction, AotMeta, check_aot_opt, setup_auto_recording}; +use crate::args::split_args; +use crate::classpath::{repo_dir, setup_classpath}; +use crate::errors::Error; +use crate::jni::check_java_version; +use crate::util::{JoinHandleRes, classpath_sep, copy_owned}; +use ::jni::objects::{JObjectArray, JString}; +use ::jni::strings::JNIString; +use ::jni::{AttachConfig, Env, InitArgsBuilder, JNIVersion, JavaVM, jni_sig, jni_str}; +use std::ffi::OsString; +use std::path::Path; +use std::sync::Arc; +use std::thread::JoinHandle; + +include!(concat!(env!("OUT_DIR"), "/config.rs")); + +pub const ONLY_USE_AOT_FAILED_EXIT_CODE: i32 = 33; + +fn main() { + nyquest_preset::register(); + std::process::exit(run()); +} + +fn run() -> i32 { + let args: Vec = std::env::args().collect(); + let arg_opts = match split_args(args) { + Ok(Some(arg_opts)) => arg_opts, + Ok(None) => return 0, + Err(Error::Exit(code)) => return code, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + + let jar = Path::new(&arg_opts.jar); + if !jar.exists() { + eprintln!("Jar file does not exist: {}", jar.display()); + return 1; + } + + let repo_dir = repo_dir(); + let jvm_args = arg_opts.jvm_args; + let app_args = arg_opts.app_args; + + let java_home = match java_locator::locate_java_home() { + Ok(j) => j, + Err(e) => { + eprintln!("Failed to locate Java home: {}", e); + return 1; + } + }; + + match check_java_version(&java_home) { + Ok(()) => {} + Err(Error::Exit(code)) => return code, + Err(e) => { + eprintln!("{}", e); + return 1; + } + }; + + let (classpath, meta) = match setup_classpath(&repo_dir, jar) { + Ok(r) => r, + Err(e) => { + eprintln!("{}", Error::wrap("Failed to setup classpath", e)); + return 1; + } + }; + + // We either need to start the JVM with -XX:AOTCacheOutput= (record) or -XX:AOTCache= (use) + let aot_action = match check_aot_opt( + &repo_dir, + arg_opts.record, + &java_home, + &classpath, + &jvm_args, + &app_args, + ) { + Ok(a) => a, + Err(Error::Exit(code)) => return code, + Err(e) => { + eprintln!("{}", Error::wrap("Failed to check AOT cache", e)); + return 1; + } + }; + + if let AotCacheAction::Record { .. } = aot_action { + // If we're recording, we need to delete any existing AOT cache files + let meta_file = AotMeta::aot_meta_file(&repo_dir); + if meta_file.exists() { + std::fs::remove_file(&meta_file).unwrap(); + } + let cache_file = AotMeta::aot_cache_file(&repo_dir); + if cache_file.exists() { + std::fs::remove_file(&cache_file).unwrap(); + } + } + + let jvm_args = copy_owned(&jvm_args); + let app_args = copy_owned(&app_args); + let jvm_thread = std::thread::spawn(move || { + let _hold = JvmThreadDrop; // Run drop() on this whenever this thread finishes + + let jvm = match create_jvm(&jvm_args, &classpath, &aot_action) { + Ok(jvm) => jvm, + Err(Error::Exit(code)) => return code, + Err(e) => { + eprintln!("{}", Error::wrap("Failed to create JVM", e)); + return 1; + } + }; + let jvm = Arc::new(jvm); + + if let AotCacheAction::Record { .. } = aot_action { + println!( + "Beginning AOT cache recording (This may cause slowdowns while the JVM is recording)..." + ); + } + let server_thread = start_jvm_thread(jvm.clone(), &meta.main_class, &app_args); + let server_thread_res = server_thread.join_res(); + + let meta_thread = setup_auto_recording( + jvm.clone(), + &java_home, + &classpath, + &jvm_args, + &app_args, + arg_opts.record, + ); + let meta_thread_res = meta_thread.map(|h| h.join_res()); + + unsafe { + if let Err(e) = jvm.destroy() { + eprintln!("Error during JVM shutdown: {:?}", e); + } + } + drop(jvm); + + if let Some(Err(e)) = meta_thread_res { + eprintln!("Error during AOT recording: {e}"); + } + + match server_thread_res { + Ok(_) => 0, + Err(e) => { + eprintln!("Error during server thread: {e}"); + 1 + } + } + }); + + // Run the native macOS event loop on the main thread + // This keeps the main thread unblocked and processes AWT window events + #[cfg(target_os = "macos")] + { + use core_foundation::runloop::CFRunLoopRun; + unsafe { + // This will block and process UI events until canceled + CFRunLoopRun(); + } + } + + jvm_thread.join().unwrap_or(1) +} + +struct JvmThreadDrop; +impl Drop for JvmThreadDrop { + fn drop(&mut self) { + #[cfg(target_os = "macos")] + { + use core_foundation::runloop::{CFRunLoopGetMain, CFRunLoopStop}; + unsafe { + CFRunLoopStop(CFRunLoopGetMain()); + } + } + } +} + +fn start_jvm_thread( + jvm: Arc, + main_class: &str, + app_args: &[String], +) -> JoinHandle> { + let app_args = app_args + .iter() + .map(|s| s.to_string()) + .collect::>(); + let main_class = main_class.to_string(); + + std::thread::spawn(move || { + jvm.attach_current_thread_with_config( + || { + AttachConfig::default() + .scoped(true) + .thread_name(jni_str!("main")) + }, + None, + |env| exec_jvm(env, &main_class, &app_args), + ) + }) +} + +fn create_jvm( + jvm_args: &[String], + classpath: &[OsString], + aot_action: &AotCacheAction, +) -> Result { + // Exit if user has set `PAPERCLIP_PATCHONLY` env variable to `true` + if let Some(prop) = std::env::var_os("PAPERCLIP_PATCHONLY") + && prop.eq_ignore_ascii_case("true") + { + return Err(Error::Exit(0)); + } + + init_jvm(jvm_args, classpath, aot_action) +} + +fn init_jvm( + jvm_args: &[String], + classpath: &[OsString], + aot_action: &AotCacheAction, +) -> Result { + let mut init_args = InitArgsBuilder::new().version(JNIVersion::V21); + init_args = init_args + .option("--enable-native-access=ALL-UNNAMED") + .option("--sun-misc-unsafe-memory-access=allow"); + + match aot_action { + AotCacheAction::Use { aot_cache_file } | AotCacheAction::Record { aot_cache_file } => { + match aot_cache_file.to_str() { + Some(p) => match aot_action { + AotCacheAction::Use { .. } => { + init_args = init_args + .option(format!("-XX:AOTCache={p}")) + .option("-Xlog:aot*=error") + } + AotCacheAction::Record { .. } => { + init_args = init_args + .option(format!("-XX:AOTCacheOutput={p}",)) + .option("-Xlog:aot*=error") + } + AotCacheAction::None => unreachable!(), + }, + None => { + return generic!( + "Failed to convert AOT cache file path to string: {}", + aot_cache_file.display() + ); + } + }; + } + AotCacheAction::None => {} + } + + let classpath_text = classpath.join(&classpath_sep()); + let classpath_text = match classpath_text.into_string() { + Ok(t) => t, + Err(e) => { + return generic!("Failed to convert classpath to string: {}", e.display()); + } + }; + init_args = init_args.option(format!("-Djava.class.path={classpath_text}")); + + for arg in jvm_args { + init_args = init_args.option(arg); + } + + let init_args = err! { + init_args.build() + => "Failed to initialize JVM" + }?; + err! { + JavaVM::new(init_args) + => "Failed to start JVM" + } +} + +fn exec_jvm(env: &mut Env, main_class: &str, args: &[String]) -> Result<(), Error> { + let args_array = l!(JObjectArray::::new( + env, + args.len(), + JString::null() + ))?; + for (i, arg) in args.iter().enumerate() { + let arg = l!(JString::new(env, arg.clone()))?; + l!(args_array.set_element(env, i, arg))?; + } + + let class_name = JNIString::new(main_class.replace(".", "/")); + let psvm_name = jni_str!("main"); + let psvm_desc = jni_sig!("([Ljava/lang/String;)V"); + l!(env.call_static_method(class_name, psvm_name, psvm_desc, &[(&args_array).into()]))?; + + if env.exception_check() { + env.exception_describe(); // Prints the stack trace to stderr + env.exception_clear(); + // Don't return any error here, the error message has already been printed + } + + Ok(()) +} diff --git a/native/src/util.rs b/native/src/util.rs new file mode 100644 index 0000000..e1e03fa --- /dev/null +++ b/native/src/util.rs @@ -0,0 +1,232 @@ +use crate::errors::{Error, ErrorLoc}; +use crate::{err, generic, l}; +use digest_io::IoWrapper; +use sha2::{Digest, Sha256}; +use std::ffi::OsString; +use std::fs::File; +use std::io::Read; +use std::path::Path; +use std::thread::JoinHandle; +use zip::ZipArchive; +use zip::read::ZipFile; +use zip::result::ZipError; + +pub fn copy_owned(slice: &[S]) -> Vec { + let mut res = Vec::with_capacity(slice.len()); + for item in slice { + res.push(item.to_string()); + } + res +} + +pub fn parse_hex(s: &str) -> Result<[u8; 32], Error> { + let decoded = l!(hex::decode(s))?; + if decoded.len() != 32 { + return generic!("Invalid hex string (not 32 bytes long): {s}"); + } + let mut res = [0u8; 32]; + res.copy_from_slice(&decoded); + Ok(res) +} + +pub fn parse_hex_named(name: &str, s: &str) -> Result<[u8; 32], Error> { + err! { + parse_hex(s) + => format!("Failed to parse hex string '{name}': {s}") + } +} + +pub fn file_hash(path: &Path) -> Result<[u8; 32], Error> { + err! { + try { + let mut file = open_file(path).loc(l!())?; + let mut digest = IoWrapper(Sha256::new()); + + std::io::copy(&mut file, &mut digest).loc(l!())?; + let mut hash: [u8; 32] = [0u8; 32]; + hash.copy_from_slice(&digest.0.finalize().0); + Ok(hash) + } => format!("Failed to hash file {}", path.display()) + }? +} + +pub fn file_matches_hash(path: &Path, expected_hash: &[u8]) -> Result { + err! { + try { + let mut file = open_file(path).loc(l!())?; + let mut digest = IoWrapper(Sha256::new()); + + std::io::copy(&mut file, &mut digest).loc(l!())?; + let actual_hash = digest.0.finalize(); + + Ok(expected_hash == actual_hash.0) + } => "Failed to hash file" + }? +} + +pub fn bytes_matches_hash(data: &[u8], expected_hash: &[u8]) -> bool { + let mut digest = Sha256::new(); + digest.update(data); + + let actual_hash = digest.finalize(); + expected_hash == actual_hash.0 +} + +pub fn extract_zip_entry( + entry: &mut ZipFile, + internal_path: &str, + destination: &Path, +) -> Result<(), Error> { + if entry.is_dir() { + create_directory(destination).loc(l!())?; + return Ok(()); + } + + if let Some(parent) = destination.parent() + && !parent.exists() + { + create_directory(parent).loc(l!())?; + } + + let mut out_file = create_file(destination).loc(l!())?; + err! { + std::io::copy(entry, &mut out_file) + => format!("Failed to extract {} from zip to {}", internal_path, destination.display()) + }?; + + Ok(()) +} + +pub trait JoinHandleRes { + type Output; + fn join_res(self) -> Self::Output; +} + +impl JoinHandleRes for JoinHandle> { + type Output = Result; + + fn join_res(self) -> Self::Output { + self.join().unwrap_or_else(|_| generic!("Thread error")) + } +} + +pub struct ComposingIterator { + base: Box>, + layer: Option>>, +} + +impl ComposingIterator { + pub fn new(base: Box>) -> Self { + Self { base, layer: None } + } + + pub fn push_layer(&mut self, layer: Box>) { + self.layer = Some(layer); + } + + pub fn is_nested(&self) -> bool { + self.layer.is_some() + } +} + +impl Iterator for ComposingIterator { + type Item = I; + + fn next(&mut self) -> Option { + if let Some(layer) = &mut self.layer { + match layer.next() { + Some(n) => return Some(n), + None => self.layer = None, + } + } + self.base.next() + } +} + +// File operations + +pub fn create_directory(path: &Path) -> Result<(), Error> { + err! { + std::fs::create_dir_all(path) + => format!("Failed to create directory: {}", path.display()) + } +} + +pub fn write_file(path: &Path, data: &[u8]) -> Result<(), Error> { + err! { + std::fs::write(path, data) + => format!("Failed to write file: {}", path.display()) + } +} + +pub fn create_file(path: &Path) -> Result { + err! { + File::create(path) + => format!("Failed to create file: {}", path.display()) + } +} + +pub fn open_file(path: &Path) -> Result { + err! { + File::open(path) + => format!("Failed to open file: {}", path.display()) + } +} + +pub fn open_zip(path: &Path) -> Result, Error> { + err! { + ZipArchive::new(open_file(path).loc(l!())?) + => format!("Failed to open zip: {}", path.display()) + } +} + +pub fn find_zip_entry<'a>( + archive: &'a mut ZipArchive, + name: &str, +) -> Result>, Error> { + let entry = archive.by_name(name); + err! { + match entry { + Ok(entry) => Ok::>, Error>(Some(entry)), + Err(ZipError::FileNotFound) => Ok(None), + Err(e) => return l!(Err(Error::from(e))), + } + => format!("Failed to find entry in zip: {}", name) + } +} + +pub fn require_zip_entry<'a>( + archive: &'a mut ZipArchive, + name: &str, +) -> Result, Error> { + match find_zip_entry(archive, name)? { + Some(entry) => Ok(entry), + None => generic!("Failed to find entry in zip: {name}"), + } +} + +pub fn read_zip_entry(entry: &mut ZipFile) -> Result, Error> { + let mut data = Vec::new(); + err! { + entry.read_to_end(&mut data) + => format!("Failed to read entry: {}", entry.name()) + }?; + Ok(data) +} + +pub fn read_zip_entry_text(entry: &mut ZipFile) -> Result { + let data = l!(read_zip_entry(entry))?; + err! { + String::from_utf8(data) + => format!("Invalid UTF-8 in {}", entry.name()) + } +} + +#[cfg(not(target_os = "windows"))] +pub fn classpath_sep() -> OsString { + ":".into() +} +#[cfg(target_os = "windows")] +pub fn classpath_sep() -> OsString { + ";".into() +} diff --git a/readme.md b/readme.md index f4ae3a6..8a4e3c3 100644 --- a/readme.md +++ b/readme.md @@ -2,13 +2,6 @@ Paperclip ========= A binary patch distribution system for Paper. -Paperclip is the launcher for the Paper Minecraft server. It uses a [bsdiff](http://www.daemonology.net/bsdiff/) patch -between the vanilla Minecraft server and the modified Paper server to generate the Paper Minecraft server immediately -upon first run. Once the Paper server is generated it loads the patched jar into Paperclip's own class loader, and runs -the main class. - -This avoids the legal problems of the GPL's linking clause. - The patching overhead is avoided if a valid patched jar is found in the cache directory. It checks via sha256 so any modification to those jars (or updated launcher) will cause a repatch. @@ -18,3 +11,163 @@ Building Building Paperclip creates a runnable jar, but the jar will not contain the Paperclip config file or patch data. This project consists simply of the launcher itself, the [paperweight Gradle plugin](https://github.com/PaperMC/paperweight) generates the patch and config file and inserts it into the jar provided by this project, creating a working runnable jar. + +Rusty Paperclip +--------------- + +The `native/` directory contains a Rust reimplementation of Paperlip. This native binary can be downloaded for all major +platforms as a build artifact from the [releases page](https://github.com/PaperMC/Paperclip/releases). + +The Rusty Paperclip binary is not standalone, it requires a separate bundler Paperclip jar to run. The native binary is +generally compatible with any version of Paperclip using the bundler scheme (bundler jars were introduced with Minecraft +1.18). Usage is as simple as replacing the `java` command you normally use with the `paperclip` binary. + +### Usage + +When running the native binary you must pass the Paperclip jar you want to run in via the `-jar` argument, which should +mirror how you already are running the Paperclip jar via `java`. All JVM and application arguments, including `@` +arguments are supported the same as `java`. + +```shell +java @aikars.flags -Dpaper.disableOldApiSupport=true -jar paper.jar nogui +``` +becomes: +```shell +./paperclip @aikars.flags -Dpaper.disableOldApiSupport=true -jar paper.jar nogui +``` + +The `paperclip` binary may be placed somewhere on the `PATH` to allow using it as a system-wide command, if you want. + +See `paperclip --help` for full usage instructions. + +#### AOT Management + +For technical details on what the AOT cache is, see the Ahead-of-Time Cache section below. This will just talk about +using AOT features in Paperclip. + +There are seven run modes: + +| **Mode** | **Argument** | **Behavior** | +|------------------|------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Default** | `` | Rusty Paperclip will record an AOT cache if it is missing or it does not match the current setup. If the cache file matches the current setup it will be used instead. This is the default, simply do not provide any of the following other arguments to use it. | +| **Check AOT** | `--check-aot` | Check the status of the current AOT cache file. If it matches, Paperclip will immediately return with exit code `0`. If it does not match (or does not exist), it will return with exit code `1`. This can be useful as a check in an automated script. | +| **Only Use AOT** | `--only-use-aot` | Paperclip will require a valid AOT cache file to run. If one is not present, or the existing file is incompatible, it will refuse to start the server and return with exit code `33` instead. | +| **No Record** | `--no-record` | Paperclip will use a valid AOT cache file if one is present. If one is not present, or the existing file is incompatible, it will skip using the AOT cache for this run. This can be useful if you want the server to startup normally (not incurring the startup time penalty of recording a new cache) if the cache becomes invalid. | +| **Only Record** | `--only-record` | Paperclip will record a new AOT cache file if one is missing or the existing cache file is invalid. If the existing cache file is valid Paperclip will immediately return with exist code `0`. If Paperclip does record a new AOT cache it will immediately shut the server down once it has fully started and finished writing the AOT cache. | +| **Force Record** | `--force-record` | This option behaves the same as `--only-record`, except that there is no AOT cache validity check. It will always record a new AOT cache file, and it will always immediately stop the server once the AOT cache file has been fully written. | +| **No AOT** | `--no-aot` | All AOT features will be completely disabled. This is essentially no different from running the Paperclip jar directly with `java`. | + +Only one of these options may be used at any given time. To use one of these arguments it **MUST** be the first argument +presented on the command line, right after `paperclip` (e.g. `paperclip --check-aot`). + +When using Paperclip with the default setting, on the first run Paperclip will initiate the AOT cache recording +automatically. Once the server has completed startup, it will write the cache to disk and write the associated metadata +file with it that allows for cache integrity checks. Any future server runs with the same configuration will use and +benefit from that AOT cache file. + +> [!CAUTION] +> We cannot verify AOT cache compatibility with plugins, as we have no control over plugin behavior. We have run into at +> least one instance of a plugin causing the AOT cache recording process to fail. We recommend recording the AOT cache +> file without any plugins, starting the server up standalone. You will still receive the benefits described here, as +> the core Minecraft server accounts for the vast majority of classes loaded during the startup process. The AOT cache +> generally isn't going to speed up heavy plugin initialization workloads anyways (it speeds up class loading, not +> class execution). + +> [!IMPORTANT] +> In order for Paperclip to use the AOT cache file the full run setup must be _identical_ to the run that recorded it. +> This includes: +> * The full classpath (including): +> * The server jar +> * All library jars +> * The SHA-256 hash of all classpath jars +> * The last modified timestamp of all classpath jars +> * The complete JVM argument list (order matters) +> * The complete application argument list (order matters) +> * The SHA-256 hash of the AOT cache file itself +> If any of these values don't match, the AOT cache will be marked invalid and will need to be re-recorded. + +### JRE Compatibility + +The minimum Java version you can run with Rusty Paperclip is strictly Java 25.0.4. Unfortunately the .4 is necessary, as +Rusty Papercilp uses a JRE API that was added in Java 26 and backported to Java 25.0.4. Any version of Java 26 and +higher, or of Java 25 after 25.0.4 will work as well. + +Paperclip finds the JRE using the standard Java locator mechanisms for each platform. The highest priority is always +given to wherever the `JAVA_HOME` environment variable points to. If that environment variable is not present, the +location of the `java` command on the `PATH` may also be used. Other platforms like Windows may also use standard +registry lookups where applicable. It's generally recommended to always set `JAVA_HOME` to be sure you're using the JRE +you expect. + +### Ahead-of-Time Cache + +The reason Rusty Paperclip exists is to provide a simple-to-use wrapper around +[Project Leyden](https://openjdk.org/projects/leyden/) Ahead-of-Time (or AOT, as it will be referred to from here on) +cache files. These caches provide a significant reduction in server startup time by recording and re-using class loading +data from previous server runs. The Paperclip binary provides utilities for easily managing and using AOT files with +Paper servers. **This is ideally used in applications where Paper servers are stopped and started frequently.** Examples +may include development environments or minigame servers. + +#### AOT: Why Rust? + +A common question we've gotten is "But why Rust? Couldn't you do this in Java?" or "Why Rust, why not X language +instead?" The answer to the second question is easy: Rust was chosen because it was a language that solved the problem +at hand. Other languages can also effectively solve the same issues; this really has nothing to do with programming +langauge flame wars. Any given project needs _some_ langauge, and Rust is what we picked for this one. + +

+Why Rust is a good fit + +As for _why_ Rust is a good fit here: we generally want to use as few system resources as possible for something like +this. CLI programs should ideally be small and fast, so a native application is usually the right call. + +Consider what Paperclip has to do: +1. Verify & download the original jar +2. Apply patches to the original jar and any library jars +3. Extract all library jars to build out the classpath +4. Load the classpath into the JVM, and start the server + +The Rust code does the exact same process, but by the time we get to step 4 in regular Paperclip, we have a big problem: +The JVM is already running! We can't modify JVM arguments to manage AOT cache files unless we then start +_another_ Java VM as a sub-process. While that is inefficient the real problem there is JVM arguments, when you start +Paperclip with `-Xmx` and other arguments configuring the heap and GC, those wouldn't apply to the sub process unless we +copied them down. That can cause other problems since `-Xms.. and -XX:+AlwaysPreTouch` are commonly recommended when +running Minecraft servers. This means now instead of the one JVM you expected to be running, possibly taking up all of +your system resources, now you have two of them. So copying down JVM arguments isn't a workable idea. + +By implementing this tool in any natively compiled language (which we happened to choose Rust in this case) works around +this issue. No JVM is started, and we can manage JVM arguments and other JVM settings directly without additional +overhead. We also get the added benefit of being able to use the JNI API to interact with the JVM directly from our +native code, allowing us to inspect and control the AOT cache recording process. +
+ +#### AOT: What benefits does it provide? + +In general, the AOT cache will cut the server startup time in half. This has been remarkably consistent across a variety +of platforms, operating systems, and devices. The reasoning is straightforward: the Minecraft server needs to load a +_lot_ of classes. By pre-computing and saving information about these classes the JVM needs to do dramatically less work +on startup before the server is in a fully started state. + +Here are some charts to help visualize where the AOT cache helps. + +#### AOT: CPU Utilization +![JVM CPU Utilization](native/doc/aot_profile_cpu_util.png) + +CPU utilization drops off much quicker when using AOT. Since there is less work to do, the full boot process of the +server startup period finishes halfway through the regular startup time. Part of this is the JVM's built-in class +profiling information (which helps the JIT compiler make decisions about how to compile the bytecode) is re-used from +AOT cache, so that work doesn't need to be re-done. + +#### AOT: Thread-Time Cost +![Cumulative Thread-Time Cost (Boot Phase)](native/doc/aot_profile_thread_time_cost.png) + +To help put a finer point on the CPU utilization, this chart shows the dramatic reduction in both class loading and JIT +compilation. The primary benefit the AOT cache provides is during class loading, which you can see is almost entirely +eliminated with the AOT cache. But the JIT compilation improvements are significant too, with the AOT cache run doing +roughly 33% less JIT compilation work than the regular run. + +#### AOT: JIT Intensity +![JIT Compilation Intensity Over Time](native/doc/aot_profile_jit_intensity.png) + +Finally, to combine the findings of the two charts into one, this chart shows the amount of runtime the JVM is spending +towards JIT compilation of the classes during the class loading process. This may be the most dramatic of the three +charts, as the drop off of the orange AOT run almost perfectly drops to 0 halfway through the non-AOT run.