diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a42c2987ac..83f35d35d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: build: name: ${{ matrix.config.variant }} ${{ matrix.config.target }} ${{ matrix.config.v8_enable_pointer_compression && 'ptrcomp' || '' }} ${{ matrix.config.simdutf && 'simdutf' || '' }} runs-on: ${{ matrix.config.os }} - timeout-minutes: 180 + timeout-minutes: ${{ matrix.config.target == 'aarch64-unknown-linux-gnu' && matrix.config.variant == 'release' && matrix.config.simdutf && 240 || 180 }} strategy: # Always run main branch builds to completion. This allows the cache to # stay mostly up-to-date in situations where a single job fails due to diff --git a/.gn b/.gn index 5f9df22a68..6374522dd4 100644 --- a/.gn +++ b/.gn @@ -78,9 +78,17 @@ default_args = { use_relative_vtables_abi = false - v8_depend_on_icu_data_file = false - icu_copy_icudata_to_root_build_dir = false - icu_use_data_file = false + # Build with external ICU data instead of linking the ~11 MiB ICU common + # data into the static library. Embedders provide a valid ICU `.dat` package + # at runtime via `v8::icu::set_common_data_77_from_file` (see README). + # + # `icu_use_data_file` keeps the data out of the static library; the other two + # flags only feed the build-time `mksnapshot` step a copy of `icudtl.dat` + # (mksnapshot must initialize ICU to generate the snapshot). They do not + # relink ICU data into the published library. + icu_use_data_file = true + icu_copy_icudata_to_root_build_dir = true + v8_depend_on_icu_data_file = true # TODO: third_party/compiler-rt missing? use_llvm_libatomic = false diff --git a/README.md b/README.md index bd17bc29a4..af83c0fc10 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,48 @@ this shared-library-compatible TLS mode. Env vars used in when building from source: `SCCACHE`, `CCACHE`, `GN`, `NINJA`, `CLANG_BASE_PATH`, `GN_ARGS` +### ICU data + +rusty_v8 builds V8 with internationalization support enabled. By default it +builds with external ICU data rather than linking Chromium's ~11 MiB ICU common +data into the static library, keeping the binary small: + +```gn +v8_enable_i18n_support=true +icu_use_data_file=true +``` + +At startup, before creating isolates, load a valid ICU `.dat` package and keep +the returned guard alive for as long as ICU can use it: + +```rust +let icu_data = + v8::icu::set_common_data_77_from_file("icudtl.dat")?; +``` + +Embedders that prefer the full ICU common data linked into the static library +(so no runtime data file is needed) can opt out of external data when building +from source: + +```bash +GN_ARGS='icu_use_data_file=false' V8_FROM_SOURCE=1 cargo build +``` + +The data file must be valid for the ICU major version linked into rusty_v8. +Chromium vendors several ICU 77 data bundles in this checkout, trading size for +locale coverage: + +| Bundle (`third_party/icu/.../icudtl.dat`) | Size | Locale coverage | +| --- | --- | --- | +| `common` | ~10.3 MiB | full (~1400 locales) | +| `cast` | ~5.0 MiB | ~250 locales | +| `flutter_desktop` | ~1.7 MiB | reduced | +| `flutter` | ~0.82 MiB | English + root | + +Smaller bundles still expose the full `Intl` API; non-English locales fall back +toward root behavior, similar to Node's `small-icu` mode. The bundle to ship is +an embedder decision — pick one that covers the locales your application needs. + ## FAQ **Building V8 takes over 30 minutes, this is too slow for me to use this crate. diff --git a/src/icu.rs b/src/icu.rs index 7737f1aaca..21526cd1c9 100644 --- a/src/icu.rs +++ b/src/icu.rs @@ -1,6 +1,11 @@ use crate::support::char; use std::ffi::CString; +use std::fmt; +use std::fs; +use std::io; +use std::path::Path; +use std::slice; unsafe extern "C" { fn icu_get_default_locale(output: *mut char, output_len: usize) -> usize; @@ -40,9 +45,93 @@ unsafe extern "C" { /// /// This function has no effect on application (non ICU) data. See udata_setAppData() for similar /// functionality for application data. -// TODO(ry) Map error code to something useful. +#[repr(align(16))] +#[allow(dead_code)] +struct Align16([u8; 16]); + +/// Owned ICU common data with an address that is aligned for +/// [`set_common_data_77`]. +/// +/// Keep this value alive for as long as ICU may use the data. +pub struct CommonData { + storage: Box<[Align16]>, + len: usize, +} + +impl CommonData { + /// Copies ICU common data into owned 16-byte-aligned storage. + pub fn from_bytes(data: &[u8]) -> Self { + let chunk_count = data.len().div_ceil(16); + let mut storage = Vec::with_capacity(chunk_count); + storage.resize_with(chunk_count, || Align16([0; 16])); + let mut common_data = Self { + storage: storage.into_boxed_slice(), + len: data.len(), + }; + common_data.as_bytes_mut()[..data.len()].copy_from_slice(data); + common_data + } + + /// Loads ICU common data from a file into owned 16-byte-aligned storage. + pub fn from_file(path: impl AsRef) -> io::Result { + fs::read(path).map(|data| Self::from_bytes(&data)) + } + + /// Returns the aligned ICU common data bytes. + pub fn as_bytes(&self) -> &[u8] { + unsafe { + slice::from_raw_parts(self.storage.as_ptr() as *const u8, self.len) + } + } + + fn as_bytes_mut(&mut self) -> &mut [u8] { + unsafe { + slice::from_raw_parts_mut( + self.storage.as_mut_ptr() as *mut u8, + self.storage.len() * 16, + ) + } + } +} + +/// Error returned by [`set_common_data_77_from_file`]. +#[derive(Debug)] +pub enum SetCommonDataError { + /// The ICU common data file could not be read. + Io(io::Error), + /// ICU rejected the common data package. + Icu(i32), +} + +impl fmt::Display for SetCommonDataError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "failed to read ICU common data: {err}"), + Self::Icu(error_code) => { + write!(f, "ICU rejected common data with error code {error_code}") + } + } + } +} + +impl std::error::Error for SetCommonDataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + Self::Icu(_) => None, + } + } +} + +impl From for SetCommonDataError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + +// TODO(ry) Map ICU error code to something useful. #[inline(always)] -pub fn set_common_data_77(data: &'static [u8]) -> Result<(), i32> { +fn set_common_data_77_impl(data: &[u8]) -> Result<(), i32> { let mut error_code = 0i32; unsafe { udata_setCommonData_77(data.as_ptr(), &mut error_code); @@ -54,6 +143,28 @@ pub fn set_common_data_77(data: &'static [u8]) -> Result<(), i32> { } } +/// Set ICU common data from static aligned bytes. +/// +/// For runtime-selected data, use [`set_common_data_77_from_file`] so the data +/// storage stays alive and aligned. +#[inline(always)] +pub fn set_common_data_77(data: &'static [u8]) -> Result<(), i32> { + set_common_data_77_impl(data) +} + +/// Loads and sets ICU common data from a runtime-selected `.dat` file. +/// +/// Keep the returned [`CommonData`] value alive for as long as ICU may use the +/// data. The data file must match the ICU major version used to build +/// rusty_v8. +pub fn set_common_data_77_from_file( + path: impl AsRef, +) -> Result { + let data = CommonData::from_file(path)?; + set_common_data_77_impl(data.as_bytes()).map_err(SetCommonDataError::Icu)?; + Ok(data) +} + /// Returns BCP47 language tag. pub fn get_language_tag() -> String { let mut output = [0u8; 1024]; diff --git a/tests/test_api.rs b/tests/test_api.rs index bb3ae8d3ea..824f4f7474 100644 --- a/tests/test_api.rs +++ b/tests/test_api.rs @@ -9812,6 +9812,26 @@ fn icu_set_common_data_fail() { ); } +#[test] +fn icu_common_data_from_bytes_is_aligned() { + let data = v8::icu::CommonData::from_bytes(&[1, 2, 3]); + assert_eq!(data.as_bytes(), &[1, 2, 3]); + assert_eq!(data.as_bytes().as_ptr() as usize % 16, 0); +} + +#[test] +fn icu_set_common_data_from_file_fail() { + let path = std::env::temp_dir().join(format!( + "rusty_v8_invalid_icu_{}_{}.dat", + std::process::id(), + line!(), + )); + std::fs::write(&path, [1, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0]).unwrap(); + let result = v8::icu::set_common_data_77_from_file(&path); + std::fs::remove_file(path).unwrap(); + assert!(matches!(result, Err(v8::icu::SetCommonDataError::Icu(_)))); +} + #[test] fn icu_format() { let _setup_guard = setup::parallel_test();