diff --git a/src/cmd/build.rs b/src/cmd/build.rs index 4f4792e8e..0369dde18 100644 --- a/src/cmd/build.rs +++ b/src/cmd/build.rs @@ -19,6 +19,7 @@ use crate::{ maybe_println, util, validate_wasm, + wasm_opt::WasmOptHandler, workspace::{ Manifest, ManifestPath, @@ -49,12 +50,9 @@ use parity_wasm::elements::{ Module, Section, }; -use regex::Regex; use semver::Version; use std::{ convert::TryFrom, - ffi::OsStr, - fs::metadata, path::{ Path, PathBuf, @@ -536,212 +534,6 @@ fn post_process_wasm(crate_metadata: &CrateMetadata) -> Result<()> { Ok(()) } -/// Attempts to perform optional Wasm optimization using `binaryen`. -/// -/// The intention is to reduce the size of bloated Wasm binaries as a result of missing -/// optimizations (or bugs?) between Rust and Wasm. -fn optimize_wasm( - crate_metadata: &CrateMetadata, - optimization_passes: OptimizationPasses, - keep_debug_symbols: bool, -) -> Result { - let mut dest_optimized = crate_metadata.dest_wasm.clone(); - dest_optimized.set_file_name(format!( - "{}-opt.wasm", - crate_metadata.contract_artifact_name - )); - do_optimization( - crate_metadata.dest_wasm.as_os_str(), - dest_optimized.as_os_str(), - optimization_passes, - keep_debug_symbols, - )?; - - if !dest_optimized.exists() { - return Err(anyhow::anyhow!( - "Optimization failed, optimized wasm output file `{}` not found.", - dest_optimized.display() - )) - } - - let original_size = metadata(&crate_metadata.dest_wasm)?.len() as f64 / 1000.0; - let optimized_size = metadata(&dest_optimized)?.len() as f64 / 1000.0; - - // overwrite existing destination wasm file with the optimised version - std::fs::rename(&dest_optimized, &crate_metadata.dest_wasm)?; - Ok(OptimizationResult { - dest_wasm: crate_metadata.dest_wasm.clone(), - original_size, - optimized_size, - }) -} - -/// Optimizes the Wasm supplied as `crate_metadata.dest_wasm` using -/// the `wasm-opt` binary. -/// -/// The supplied `optimization_level` denotes the number of optimization passes, -/// resulting in potentially a lot of time spent optimizing. -/// -/// If successful, the optimized Wasm is written to `dest_optimized`. -fn do_optimization( - dest_wasm: &OsStr, - dest_optimized: &OsStr, - optimization_level: OptimizationPasses, - keep_debug_symbols: bool, -) -> Result<()> { - // check `wasm-opt` is installed - let which = which::which("wasm-opt"); - if which.is_err() { - anyhow::bail!( - "wasm-opt not found! Make sure the binary is in your PATH environment.\n\n\ - We use this tool to optimize the size of your contract's Wasm binary.\n\n\ - wasm-opt is part of the binaryen package. You can find detailed\n\ - installation instructions on https://github.com/WebAssembly/binaryen#tools.\n\n\ - There are ready-to-install packages for many platforms:\n\ - * Debian/Ubuntu: apt-get install binaryen\n\ - * Homebrew: brew install binaryen\n\ - * Arch Linux: pacman -S binaryen\n\ - * Windows: binary releases at https://github.com/WebAssembly/binaryen/releases" - .to_string() - .bright_yellow() - ); - } - let wasm_opt_path = which - .as_ref() - .expect("we just checked if `which` returned an err; qed") - .as_path(); - tracing::info!("Path to wasm-opt executable: {}", wasm_opt_path.display()); - - check_wasm_opt_version_compatibility(wasm_opt_path)?; - - tracing::info!( - "Optimization level passed to wasm-opt: {}", - optimization_level - ); - let mut command = Command::new(wasm_opt_path); - command - .arg(dest_wasm) - .arg(format!("-O{}", optimization_level)) - .arg("-o") - .arg(dest_optimized) - // the memory in our module is imported, `wasm-opt` needs to be told that - // the memory is initialized to zeroes, otherwise it won't run the - // memory-packing pre-pass. - .arg("--zero-filled-memory"); - if keep_debug_symbols { - command.arg("-g"); - } - tracing::info!("Invoking wasm-opt with {:?}", command); - let output = command.output().map_err(|err| { - anyhow::anyhow!( - "Executing {} failed with {:?}", - wasm_opt_path.display(), - err - ) - })?; - - if !output.status.success() { - let err = str::from_utf8(&output.stderr) - .expect("Cannot convert stderr output of wasm-opt to string") - .trim(); - anyhow::bail!( - "The wasm-opt optimization failed.\n\n\ - The error which wasm-opt returned was: \n{}", - err - ); - } - Ok(()) -} - -/// Checks if the `wasm-opt` binary under `wasm_opt_path` returns a version -/// compatible with `cargo-contract`. -/// -/// Currently this must be a version >= 99. -fn check_wasm_opt_version_compatibility(wasm_opt_path: &Path) -> Result<()> { - let mut cmd_res = Command::new(wasm_opt_path).arg("--version").output(); - - // The following condition is a workaround for a spurious CI failure: - // ``` - // Executing `"/tmp/cargo-contract.test.GGnC0p/wasm-opt-mocked" --version` failed with - // Os { code: 26, kind: ExecutableFileBusy, message: "Text file busy" } - // ``` - if cmd_res.is_err() && format!("{:?}", cmd_res).contains("ExecutableFileBusy") { - std::thread::sleep(std::time::Duration::from_secs(1)); - cmd_res = Command::new(wasm_opt_path).arg("--version").output(); - } - - let res = cmd_res.map_err(|err| { - anyhow::anyhow!( - "Executing `{:?} --version` failed with {:?}", - wasm_opt_path.display(), - err - ) - })?; - if !res.status.success() { - let err = str::from_utf8(&res.stderr) - .expect("Cannot convert stderr output of wasm-opt to string") - .trim(); - anyhow::bail!( - "Getting version information from wasm-opt failed.\n\ - The error which wasm-opt returned was: \n{}", - err - ); - } - - // ```sh - // $ wasm-opt --version - // wasm-opt version 99 (version_99-79-gc12cc3f50) - // ``` - let github_note = "\n\n\ - If you tried installing from your system package manager the best\n\ - way forward is to download a recent binary release directly:\n\n\ - https://github.com/WebAssembly/binaryen/releases\n\n\ - Make sure that the `wasm-opt` file from that release is in your `PATH`."; - let version_stdout = str::from_utf8(&res.stdout) - .expect("Cannot convert stdout output of wasm-opt to string") - .trim(); - let re = Regex::new(r"wasm-opt version (\d+)").expect("invalid regex"); - let captures = re.captures(version_stdout).ok_or_else(|| { - anyhow::anyhow!( - "Unable to extract version information from '{}'.\n\ - Your wasm-opt version is most probably too old. Make sure you use a version >= 99.{}", - version_stdout, - github_note, - ) - })?; - let version_number: u32 = captures - .get(1) // first capture group is at index 1 - .ok_or_else(|| { - anyhow::anyhow!( - "Unable to extract version number from '{:?}'", - version_stdout - ) - })? - .as_str() - .parse() - .map_err(|err| { - anyhow::anyhow!( - "Parsing version number failed with '{:?}' for '{:?}'", - err, - version_stdout - ) - })?; - - tracing::info!( - "The wasm-opt version output is '{}', which was parsed to '{}'", - version_stdout, - version_number - ); - if version_number < 99 { - anyhow::bail!( - "Your wasm-opt version is {}, but we require a version >= 99.{}", - version_number, - github_note, - ); - } - Ok(()) -} - /// Asserts that the contract's dependencies are compatible to the ones used in ink!. /// /// This function utilizes `cargo tree`, which takes semver into consideration. @@ -856,8 +648,12 @@ pub(crate) fn execute(args: ExecuteArgs) -> Result { format!("[4/{}]", build_artifact.steps()).bold(), "Optimizing wasm file".bright_green().bold() ); - let optimization_result = - optimize_wasm(&crate_metadata, optimization_passes, keep_debug_symbols)?; + + let handler = WasmOptHandler::new(optimization_passes, keep_debug_symbols)?; + let optimization_result = handler.optimize( + &crate_metadata.dest_wasm, + &crate_metadata.contract_artifact_name, + )?; Ok(optimization_result) }; @@ -935,7 +731,6 @@ mod tests_ci_only { use super::{ assert_compatible_ink_dependencies, assert_debug_mode_supported, - check_wasm_opt_version_compatibility, }; use crate::{ cmd::{ @@ -943,8 +738,8 @@ mod tests_ci_only { BuildCommand, }, util::tests::{ + create_executable, with_new_contract_project, - with_tmp_dir, }, workspace::Manifest, BuildArtifacts, @@ -957,15 +752,9 @@ mod tests_ci_only { VerbosityFlags, }; use semver::Version; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; use std::{ ffi::OsStr, - io::Write, - path::{ - Path, - PathBuf, - }, + path::Path, }; /// Modifies the `Cargo.toml` under the supplied `cargo_toml_path` by @@ -993,34 +782,6 @@ mod tests_ci_only { .any(|e| e.name() == "name") } - /// Creates an executable file at `path` with the content `content`. - /// - /// Currently works only on `unix`. - #[cfg(unix)] - fn create_executable(path: &Path, content: &str) { - { - let mut file = std::fs::File::create(&path).unwrap(); - file.write_all(content.as_bytes()) - .expect("writing of executable failed"); - } - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o777)) - .expect("setting permissions failed"); - } - - /// Creates an executable `wasm-opt-mocked` file which outputs - /// "wasm-opt version `version`". - /// - /// Returns the path to this file. - /// - /// Currently works only on `unix`. - #[cfg(unix)] - fn mock_wasm_opt_version(tmp_dir: &Path, version: &str) -> PathBuf { - let path = tmp_dir.join("wasm-opt-mocked"); - let content = format!("#!/bin/sh\necho \"wasm-opt version {}\"", version); - create_executable(&path, &content); - path - } - #[test] fn build_code_only() { with_new_contract_project(|manifest_path| { @@ -1214,87 +975,6 @@ mod tests_ci_only { }) } - #[cfg(unix)] - #[test] - fn incompatible_wasm_opt_version_must_be_detected_if_built_from_repo() { - with_tmp_dir(|path| { - // given - let path = mock_wasm_opt_version(path, "98 (version_13-79-gc12cc3f50)"); - - // when - let res = check_wasm_opt_version_compatibility(&path); - - // then - assert!(res.is_err()); - assert!( - format!("{:?}", res).starts_with( - "Err(Your wasm-opt version is 98, but we require a version >= 99." - ), - "Expected a different output, found {:?}", - res - ); - - Ok(()) - }) - } - - #[cfg(unix)] - #[test] - fn compatible_wasm_opt_version_must_be_detected_if_built_from_repo() { - with_tmp_dir(|path| { - // given - let path = mock_wasm_opt_version(path, "99 (version_99-79-gc12cc3f50"); - - // when - let res = check_wasm_opt_version_compatibility(&path); - - // then - assert!(res.is_ok()); - - Ok(()) - }) - } - - #[cfg(unix)] - #[test] - fn incompatible_wasm_opt_version_must_be_detected_if_installed_as_package() { - with_tmp_dir(|path| { - // given - let path = mock_wasm_opt_version(path, "98"); - - // when - let res = check_wasm_opt_version_compatibility(&path); - - // then - assert!(res.is_err()); - - // this println is here to debug a spuriously failing CI at the following assert. - eprintln!("error: {:?}", res); - assert!(format!("{:?}", res).starts_with( - "Err(Your wasm-opt version is 98, but we require a version >= 99." - )); - - Ok(()) - }) - } - - #[cfg(unix)] - #[test] - fn compatible_wasm_opt_version_must_be_detected_if_installed_as_package() { - with_tmp_dir(|path| { - // given - let path = mock_wasm_opt_version(path, "99"); - - // when - let res = check_wasm_opt_version_compatibility(&path); - - // then - assert!(res.is_ok()); - - Ok(()) - }) - } - #[test] fn contract_lib_name_different_from_package_name_must_build() { with_new_contract_project(|manifest_path| { diff --git a/src/main.rs b/src/main.rs index aa091c06f..3230b411c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod cmd; mod crate_metadata; mod util; mod validate_wasm; +mod wasm_opt; mod workspace; use self::{ diff --git a/src/util.rs b/src/util.rs index 49a7bcd28..f18a01935 100644 --- a/src/util.rs +++ b/src/util.rs @@ -234,6 +234,23 @@ pub mod tests { f(manifest_path) }) } + + /// Creates an executable file at `path` with the content `content`. + /// + /// Currently works only on `unix`. + #[cfg(unix)] + pub fn create_executable(path: &Path, content: &str) { + use std::io::Write; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + { + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all(content.as_bytes()) + .expect("writing of executable failed"); + } + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o777)) + .expect("setting permissions failed"); + } } // Unzips the file at `template` to `out_dir`. diff --git a/src/wasm_opt.rs b/src/wasm_opt.rs new file mode 100644 index 000000000..70655e22d --- /dev/null +++ b/src/wasm_opt.rs @@ -0,0 +1,356 @@ +// Copyright 2018-2022 Parity Technologies (UK) Ltd. +// This file is part of cargo-contract. +// +// cargo-contract is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// cargo-contract is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with cargo-contract. If not, see . + +use crate::{ + OptimizationPasses, + OptimizationResult, +}; + +use anyhow::Result; +use colored::Colorize; +use regex::Regex; + +use std::{ + fs::metadata, + path::{ + Path, + PathBuf, + }, + process::Command, + str, +}; + +const WASM_OPT_INSTALLATION_SUGGESTION: &str = + "wasm-opt not found! Make sure the binary is in your PATH environment.\n\n\ + We use this tool to optimize the size of your contract's Wasm binary.\n\n\ + wasm-opt is part of the binaryen package. You can find detailed\n\ + installation instructions on https://github.com/WebAssembly/binaryen#tools.\n\n\ + There are ready-to-install packages for many platforms:\n\ + * Debian/Ubuntu: apt-get install binaryen\n\ + * Homebrew: brew install binaryen\n\ + * Arch Linux: pacman -S binaryen\n\ + * Windows: binary releases at https://github.com/WebAssembly/binaryen/releases"; + +/// A helpful struct for interacting with Binaryen's `wasm-opt` tool. +pub struct WasmOptHandler { + /// The path to the `wasm-opt` binary. + wasm_opt_path: PathBuf, + /// The optimization level that should be used when optimizing the Wasm binary. + optimization_level: OptimizationPasses, + /// Whether or not to keep debugging information in the final Wasm binary. + keep_debug_symbols: bool, + /// The version number of the `wasm-opt` binary being executed. + _version: u32, +} + +impl WasmOptHandler { + /// Generate a new instance of the handler. + /// + /// Fails if the `wasm-opt` binary is not installed on the system, or if an outdated `wasm-opt` + /// binary is used (currently a version >= 99 is required). + pub fn new( + optimization_level: OptimizationPasses, + keep_debug_symbols: bool, + ) -> Result { + let which = which::which("wasm-opt"); + if which.is_err() { + anyhow::bail!(WASM_OPT_INSTALLATION_SUGGESTION.to_string().bright_yellow()); + } + + let wasm_opt_path = + which.expect("we just checked if `which` returned an err; qed"); + tracing::info!("Path to wasm-opt executable: {}", wasm_opt_path.display()); + + let version = + Self::check_wasm_opt_version_compatibility(wasm_opt_path.as_path())?; + + Ok(Self { + wasm_opt_path, + optimization_level, + keep_debug_symbols, + _version: version, + }) + } + + /// Attempts to perform optional Wasm optimization using Binaryen's `wasm-opt` tool. + /// + /// If successful, the optimized Wasm binary is written to `dest_wasm`. + pub fn optimize( + &self, + dest_wasm: &PathBuf, + contract_artifact_name: &String, + ) -> Result { + // We'll create a temporary file for our optimized Wasm binary. Note that we'll later + // overwrite this with the original path of the Wasm binary. + let mut dest_optimized = dest_wasm.clone(); + dest_optimized.set_file_name(format!("{}-opt.wasm", contract_artifact_name)); + + tracing::info!( + "Optimization level passed to wasm-opt: {}", + self.optimization_level + ); + + let mut command = Command::new(self.wasm_opt_path.as_path()); + command + .arg(dest_wasm.as_os_str()) + .arg(format!("-O{}", self.optimization_level)) + .arg("-o") + .arg(dest_optimized.as_os_str()) + // the memory in our module is imported, `wasm-opt` needs to be told that + // the memory is initialized to zeroes, otherwise it won't run the + // memory-packing pre-pass. + .arg("--zero-filled-memory"); + + if self.keep_debug_symbols { + command.arg("-g"); + } + + tracing::info!("Invoking wasm-opt with {:?}", command); + + let output = command.output().map_err(|err| { + anyhow::anyhow!( + "Executing {} failed with {:?}", + self.wasm_opt_path.display(), + err + ) + })?; + + if !output.status.success() { + let err = str::from_utf8(&output.stderr) + .expect("Cannot convert stderr output of wasm-opt to string") + .trim(); + anyhow::bail!( + "The wasm-opt optimization failed.\n\n\ + The error which wasm-opt returned was: \n{}", + err + ); + } + + if !dest_optimized.exists() { + return Err(anyhow::anyhow!( + "Optimization failed, optimized wasm output file `{}` not found.", + dest_optimized.display() + )) + } + + let original_size = metadata(&dest_wasm)?.len() as f64 / 1000.0; + let optimized_size = metadata(&dest_optimized)?.len() as f64 / 1000.0; + + // Overwrite existing destination wasm file with the optimised version + std::fs::rename(&dest_optimized, &dest_wasm)?; + Ok(OptimizationResult { + dest_wasm: dest_wasm.clone(), + original_size, + optimized_size, + }) + } + + /// Checks if the `wasm-opt` binary under `wasm_opt_path` returns a version + /// compatible with `cargo-contract`. + /// + /// Currently this must be a version >= 99. + fn check_wasm_opt_version_compatibility(wasm_opt_path: &Path) -> Result { + let mut cmd_res = Command::new(wasm_opt_path).arg("--version").output(); + + // The following condition is a workaround for a spurious CI failure: + // ``` + // Executing `"/tmp/cargo-contract.test.GGnC0p/wasm-opt-mocked" --version` failed with + // Os { code: 26, kind: ExecutableFileBusy, message: "Text file busy" } + // ``` + if cmd_res.is_err() && format!("{:?}", cmd_res).contains("ExecutableFileBusy") { + std::thread::sleep(std::time::Duration::from_secs(1)); + cmd_res = Command::new(wasm_opt_path).arg("--version").output(); + } + + let res = cmd_res.map_err(|err| { + anyhow::anyhow!( + "Executing `{:?} --version` failed with {:?}", + wasm_opt_path.display(), + err + ) + })?; + if !res.status.success() { + let err = str::from_utf8(&res.stderr) + .expect("Cannot convert stderr output of wasm-opt to string") + .trim(); + anyhow::bail!( + "Getting version information from wasm-opt failed.\n\ + The error which wasm-opt returned was: \n{}", + err + ); + } + + // ```sh + // $ wasm-opt --version + // wasm-opt version 99 (version_99-79-gc12cc3f50) + // ``` + let github_note = "\n\n\ + If you tried installing from your system package manager the best\n\ + way forward is to download a recent binary release directly:\n\n\ + https://github.com/WebAssembly/binaryen/releases\n\n\ + Make sure that the `wasm-opt` file from that release is in your `PATH`."; + let version_stdout = str::from_utf8(&res.stdout) + .expect("Cannot convert stdout output of wasm-opt to string") + .trim(); + let re = Regex::new(r"wasm-opt version (\d+)").expect("invalid regex"); + let captures = re.captures(version_stdout).ok_or_else(|| { + anyhow::anyhow!( + "Unable to extract version information from '{}'.\n\ + Your wasm-opt version is most probably too old. Make sure you use a version >= 99.{}", + version_stdout, + github_note, + ) + })?; + let version_number: u32 = captures + .get(1) // first capture group is at index 1 + .ok_or_else(|| { + anyhow::anyhow!( + "Unable to extract version number from '{:?}'", + version_stdout + ) + })? + .as_str() + .parse() + .map_err(|err| { + anyhow::anyhow!( + "Parsing version number failed with '{:?}' for '{:?}'", + err, + version_stdout + ) + })?; + + tracing::info!( + "The wasm-opt version output is '{}', which was parsed to '{}'", + version_stdout, + version_number + ); + if version_number < 99 { + anyhow::bail!( + "Your wasm-opt version is {}, but we require a version >= 99.{}", + version_number, + github_note, + ); + } + + Ok(version_number) + } +} + +#[cfg(feature = "test-ci-only")] +#[cfg(test)] +mod tests_ci_only { + use super::*; + + use crate::util::tests::{ + create_executable, + with_tmp_dir, + }; + + /// Creates an executable `wasm-opt-mocked` file which outputs + /// "wasm-opt version `version`". + /// + /// Returns the path to this file. + /// + /// Currently works only on `unix`. + #[cfg(unix)] + fn mock_wasm_opt_version(tmp_dir: &Path, version: &str) -> PathBuf { + let path = tmp_dir.join("wasm-opt-mocked"); + let content = format!("#!/bin/sh\necho \"wasm-opt version {}\"", version); + create_executable(&path, &content); + path + } + + #[cfg(unix)] + #[test] + fn incompatible_wasm_opt_version_must_be_detected_if_built_from_repo() { + with_tmp_dir(|path| { + // given + let path = mock_wasm_opt_version(path, "98 (version_13-79-gc12cc3f50)"); + + // when + let res = WasmOptHandler::check_wasm_opt_version_compatibility(&path); + + // then + assert!(res.is_err()); + assert!( + format!("{:?}", res).starts_with( + "Err(Your wasm-opt version is 98, but we require a version >= 99." + ), + "Expected a different output, found {:?}", + res + ); + + Ok(()) + }) + } + + #[cfg(unix)] + #[test] + fn compatible_wasm_opt_version_must_be_detected_if_built_from_repo() { + with_tmp_dir(|path| { + // given + let path = mock_wasm_opt_version(path, "99 (version_99-79-gc12cc3f50"); + + // when + let res = WasmOptHandler::check_wasm_opt_version_compatibility(&path); + + // then + assert!(res.is_ok()); + + Ok(()) + }) + } + + #[cfg(unix)] + #[test] + fn incompatible_wasm_opt_version_must_be_detected_if_installed_as_package() { + with_tmp_dir(|path| { + // given + let path = mock_wasm_opt_version(path, "98"); + + // when + let res = WasmOptHandler::check_wasm_opt_version_compatibility(&path); + + // then + assert!(res.is_err()); + + // this println is here to debug a spuriously failing CI at the following assert. + eprintln!("error: {:?}", res); + assert!(format!("{:?}", res).starts_with( + "Err(Your wasm-opt version is 98, but we require a version >= 99." + )); + + Ok(()) + }) + } + + #[cfg(unix)] + #[test] + fn compatible_wasm_opt_version_must_be_detected_if_installed_as_package() { + with_tmp_dir(|path| { + // given + let path = mock_wasm_opt_version(path, "99"); + + // when + let res = WasmOptHandler::check_wasm_opt_version_compatibility(&path); + + // then + assert!(res.is_ok()); + + Ok(()) + }) + } +}