|
| 1 | +# V8 10.3 → 14.9 migration notes (Android) |
| 2 | + |
| 3 | +Pinned version: **14.9.207.39** (`branch-heads/14.9`). |
| 4 | +The libraries are built by [NativeScript/v8-buildscripts](https://github.com/NativeScript/v8-buildscripts) |
| 5 | +and installed here by `download_v8.sh` from the release pinned in `V8_RELEASE`. The iOS runtime moved to the same |
| 6 | +version; where the two runtimes hit the same API change the notes are kept in |
| 7 | +sync. |
| 8 | + |
| 9 | +## What ships |
| 10 | + |
| 11 | +`libv8_monolith.a` per ABI under `test-app/runtime/src/main/libs/<abi>/`, plus |
| 12 | +the public headers under `test-app/runtime/src/main/cpp/include/` and the |
| 13 | +vendored V8 internals under `test-app/runtime/src/main/cpp/v8_inspector/`. |
| 14 | +All three come from the same release artifact; they are a matched set and |
| 15 | +must never be updated separately. |
| 16 | + |
| 17 | +## Build configuration |
| 18 | + |
| 19 | +The gn args reproduce the 10.3 build: JIT and WebAssembly on, i18n off. What is |
| 20 | +new, and why: |
| 21 | + |
| 22 | +- **`use_allocator_shim=false`** — the shim interposes on `malloc` through |
| 23 | + linker `--wrap` flags that the embedder would also have to pass. Without it |
| 24 | + the link fails on `__real_realpath` / `__real_getcwd`. PartitionAlloc itself |
| 25 | + stays enabled (V8 depends on the target); it just no longer replaces malloc. |
| 26 | +- **`use_thin_lto=false`** — `is_official_build` now turns ThinLTO on, and a |
| 27 | + ThinLTO build emits LLVM bitcode rather than object code, which the app's NDK |
| 28 | + cannot link. |
| 29 | +- **`chrome_pgo_phase=0`** — `is_official_build` also turns PGO on, and |
| 30 | + standalone V8 has no `tools/update_pgo_profiles.py`. |
| 31 | +- **`v8_enable_temporal_support=false`** — Temporal is implemented in Rust and |
| 32 | + pulls in a Rust sysroot this build does not link. 10.3 had no Temporal. |
| 33 | +- **`v8_array_buffer_internal_field_count=2`** and |
| 34 | + **`v8_array_buffer_view_internal_field_count=2`** — both default to `0` in |
| 35 | + 14.9 and defaulted to `2` in 10.3. When an `ArrayBuffer`/`SharedArrayBuffer`/ |
| 36 | + typed array is marshalled to a Java NIO buffer, |
| 37 | + `JSToJavaObjectsConverter` calls `ObjectManager::Link` on the buffer object |
| 38 | + itself, and `Link` stores its `JSInstanceInfo` in internal field 0. With zero |
| 39 | + internal fields every such conversion throws *"Trying to link invalid 'this' |
| 40 | + to a Java object"*. `v8-array-buffer.h` still falls back to `2` when the macro |
| 41 | + is undefined, which is what the runtime compiles against, so leaving the gn |
| 42 | + default in place also puts the two sides out of agreement. |
| 43 | +- **`v8_enable_sandbox=false`** — pinned rather than left to default. It would |
| 44 | + otherwise follow pointer compression (on for 64-bit) and change the object |
| 45 | + layout the runtime compiles against, which is a much larger change than a |
| 46 | + version bump. |
| 47 | +- **`android_ndk_root`** pinned to the NDK the runtime is built with — see |
| 48 | + below. |
| 49 | + |
| 50 | +The build deletes its output directory before each run. ninja never |
| 51 | +removes outputs orphaned by a config change, so reusing one across V8 versions |
| 52 | +silently keeps stale objects, and the packaging step would vendor them. |
| 53 | + |
| 54 | +### The NDK has to match on both sides |
| 55 | + |
| 56 | +V8's bundled NDK (CIPD `30.0.14608247`) is newer than any released one. Its |
| 57 | +libc++ exports symbols the runtime's `libc++_static.a` does not — the link fails |
| 58 | +on `std::__ndk1::__hash_memory`, referenced from `liveedit.cc`. `libc++` is only |
| 59 | +ABI-compatible with itself across a static link, so V8 is built against the same |
| 60 | +NDK the runtime uses, via the `android_ndk_root` gn arg (made overridable by |
| 61 | +`android_build.patch`). |
| 62 | + |
| 63 | +**The runtime moved from NDK r27d to r29.** This is forced, not optional: V8 |
| 64 | +14.9's `src/base/atomicops.h` uses `std::atomic_ref` unconditionally, and |
| 65 | +r27d ships libc++ 18, which does not implement it (`__cpp_lib_atomic_ref` is |
| 66 | +commented out in its `<version>`). The runtime compiles those headers because |
| 67 | +`v8_inspector` vendors V8 internals. r29 is the first released NDK with a |
| 68 | +libc++ new enough. `minSdk` is unchanged at 21. |
| 69 | + |
| 70 | +### Building on macOS |
| 71 | + |
| 72 | +Chromium asserts a Linux host for Android targets. Everything below that assert |
| 73 | +still handles macOS — the host-arch block maps arm64 hosts to the |
| 74 | +`darwin-x86_64` NDK tag deliberately, and `android_toolchain_root` is only read |
| 75 | +for the (host-independent) sysroot — so `android_build.patch` relaxes |
| 76 | +the assert. It also lowers `min_supported_sdk_version` from 23 to 21; that floor |
| 77 | +exists for Java/dex tooling and this build produces only the native |
| 78 | +`v8_monolith` target. |
| 79 | + |
| 80 | +Two things the macOS clang package does not carry, both handled by buildscripts' `fetch_v8.sh`: |
| 81 | + |
| 82 | +- the Android **compiler-rt builtins** (`libclang_rt.builtins-*-android.a`), |
| 83 | + which only the Linux clang package bundles — they are extracted from it; |
| 84 | +- a `darwin-x86_64` directory in the CIPD NDK, which only ships `linux-x86_64` — |
| 85 | + symlinked (only relevant when `android_ndk_root` is left at its default). |
| 86 | + |
| 87 | +Because not every ABI can be rebuilt on every host, the gradle builds accept |
| 88 | +`-Pabis=arm64-v8a,x86_64` to restrict `abiFilters` to the ones that have a |
| 89 | +current `libv8_monolith.a`. |
| 90 | + |
| 91 | +**The 32-bit ABIs cannot be built on an Apple Silicon host.** `armeabi-v7a` and |
| 92 | +`x86` need mksnapshot to run V8's simulator for a 32-bit target, and |
| 93 | +`v8config.h` hard-errors with *"Target architecture arm is only supported on arm |
| 94 | +and ia32 host"*. The Linux x64 path is unaffected — it builds mksnapshot as a |
| 95 | +32-bit x86 host binary — so those two ABIs have to come from a Linux x64 |
| 96 | +builder, which is where CI builds them anyway. |
| 97 | + |
| 98 | +## API changes applied to the runtime |
| 99 | + |
| 100 | +| Change | Sites | Migration | |
| 101 | +|---|---|---| |
| 102 | +| `Context/Object/Function/Promise/Message::GetIsolate()` removed | 46 | `v8::Isolate::GetCurrent()` | |
| 103 | +| `External::New` / `External::Value()` take a type tag | 47 | `v8::kExternalPointerTypeTagDefault` | |
| 104 | +| `PropertyCallbackInfo::This()` removed | 25 | `Holder()`, or a function-backed accessor — see below | |
| 105 | +| `Object/ObjectTemplate/Function::SetAccessor` | 21 | `SetNativeDataProperty` / `SetAccessorProperty` | |
| 106 | +| Accessor callbacks take `Local<Name>` | 23 | was `Local<String>` | |
| 107 | +| `Object::CreationContext()` | 7 | `GetCreationContext(isolate).ToLocalChecked()` | |
| 108 | +| `ScriptOrigin` no longer takes an `Isolate*` | 8 | drop the first argument | |
| 109 | +| `AccessControl` removed | 5 | drop the argument | |
| 110 | +| `GetInternalField` returns `Local<Data>` | 4 | `.As<v8::Value>()` | |
| 111 | +| Interceptor callbacks return `v8::Intercepted` | 2 | see below | |
| 112 | +| `SetIndexedPropertyHandler` | 1 | `SetHandler(IndexedPropertyHandlerConfiguration(...))` | |
| 113 | +| `V8Inspector::connect` needs a trust level | 3 | `kFullyTrusted` | |
| 114 | +| `V8ConsoleMessage::createForConsoleAPI` takes a span | 2 | `{args.data(), args.size()}` | |
| 115 | +| `FunctionCallbackInfo` is no longer copyable | 1 | `ArgsWrapper` holds a reference | |
| 116 | + |
| 117 | +`unistd.h` also has to be included explicitly in `ModuleInternal.cpp` and |
| 118 | +`WorkerWrapper.cpp`; `usleep`/`read` used to arrive transitively through headers |
| 119 | +that no longer pull it in. |
| 120 | + |
| 121 | +### Accessors that are inherited need a real accessor pair |
| 122 | + |
| 123 | +`PropertyCallbackInfo` no longer exposes the receiver at all, and |
| 124 | +`SetNativeDataProperty` is not a drop-in replacement for `SetAccessor` on |
| 125 | +anything that is inherited from. Six accessors in `MetadataNode` are installed |
| 126 | +on an object other than the one they are read through: |
| 127 | + |
| 128 | +- on the **constructor function**, which derived constructors inherit: |
| 129 | + `class`, `nullObject`, and static fields; |
| 130 | +- on the **implementation object**, which instances inherit: `super`; |
| 131 | +- on the **prototype template**, which instances inherit: instance fields and |
| 132 | + properties. |
| 133 | + |
| 134 | +All six are now `SetAccessorProperty` with `FunctionTemplate`-backed |
| 135 | +getter/setter, whose `FunctionCallbackInfo::This()` still returns the receiver. |
| 136 | +Static fields matter most: they have a setter, and `SetNativeDataProperty` |
| 137 | +installs something data-like, so `Derived.baseField = x` would shadow the base's |
| 138 | +property with an own data property and never reach the native setter. |
| 139 | + |
| 140 | +The accessors that stay `SetNativeDataProperty` are the ones installed as own |
| 141 | +properties on the object they are read through — the array wrapper's `length`, |
| 142 | +the package object's children, inner types on a constructor, and the `URL*` |
| 143 | +instance templates. For those `Holder() == This()`. |
| 144 | + |
| 145 | +The rule of thumb: if an accessor lives on anything that is inherited from, use |
| 146 | +`SetAccessorProperty` with function-backed callbacks. `SetNativeDataProperty` is |
| 147 | +only safe where nothing inherits it. |
| 148 | + |
| 149 | +This costs an allocation the old API did not: each converted accessor now needs |
| 150 | +a real `Function` object rather than an `AccessorInfo`. On the prototype |
| 151 | +template that is deferred to instantiation, but static fields are materialised |
| 152 | +eagerly, so a class with many static fields pays for them when its constructor |
| 153 | +function is first built. |
| 154 | + |
| 155 | +#### One guard could not be translated directly |
| 156 | + |
| 157 | +`FieldAccessorGetter/SetterCallback` used `thiz->StrictEquals(info.Holder())` to |
| 158 | +detect an instance field being read straight off the prototype. Function-backed |
| 159 | +accessors have no holder, so the check is now |
| 160 | +`!objectManager->IsJsRuntimeObject(thiz)` — being a runtime-managed object is |
| 161 | +the property that actually distinguishes an instance from the prototype. The |
| 162 | +two agree for every receiver the old check could see; the new one additionally |
| 163 | +returns `undefined` (rather than reaching `GetJavaField` with a non-instance) |
| 164 | +for something like `Object.create(SomeClass.prototype).field`. |
| 165 | + |
| 166 | +### Interceptors |
| 167 | + |
| 168 | +Only one interceptor pair exists here — the array wrapper's indexed |
| 169 | +getter/setter. Both handle the access completely, so both return |
| 170 | +`Intercepted::kYes`; neither ever fell through to the ordinary lookup. The |
| 171 | +setter's `PropertyCallbackInfo` also changed from `<Value>` to `<Boolean>`, so |
| 172 | +its old `GetReturnValue().Set(value)` is dropped — the return value is now the |
| 173 | +strict-mode success flag, not the stored value. |
| 174 | + |
| 175 | +The conversion rule in general: a path that set a return value or threw becomes |
| 176 | +`kYes`; a path that returned without setting one, **including falling off the |
| 177 | +end**, becomes `kNo`. Getting it backwards is silent in both directions. |
| 178 | + |
| 179 | +### V8 flags must be set before `V8::Initialize()` |
| 180 | + |
| 181 | +`V8::Initialize()` calls `FlagList::FreezeFlags()`, and changing a flag |
| 182 | +afterwards aborts the process. `PrepareV8Runtime` used to apply |
| 183 | +`Constants::V8_STARTUP_FLAGS` per isolate, after initialization; it now happens |
| 184 | +once in `InitializeV8()`, before `V8::Initialize()`. `Runtime::Init` has already |
| 185 | +read the flags out of the Java config by then. |
| 186 | + |
| 187 | +## Things that did *not* need changing |
| 188 | + |
| 189 | +- **Resurrecting finalizers.** `ObjectManager` uses |
| 190 | + `WeakCallbackType::kFinalizer` in four places. Upstream removed it right after |
| 191 | + 10.3.22; buildscripts' `v8_resurrecting_finalizers.patch` restores it. See |
| 192 | + the iOS runtime's `docs/knowledge/v8-resurrecting-finalizers.md` for the patch |
| 193 | + design. |
| 194 | + |
| 195 | + From `v8-14.9.207.39-2` that patch also lifts the |
| 196 | + `DisallowJavascriptExecution` scope `Heap::CollectGarbage` now holds across |
| 197 | + the whole collection, because entering JS from a GC callback is a |
| 198 | + `GRACEFUL_FATAL` in 14.9 where 10.3 allowed it. This runtime does not depend |
| 199 | + on the lift — `JSObjectFinalizer` makes one runtime-internal JNI call |
| 200 | + (`makeInstanceWeakAndCheckIfAlive`) and Java has no synchronous destructor |
| 201 | + that could re-enter JS — but it shares the patch, so pin a release that |
| 202 | + carries it. |
| 203 | +- **Teardown disposal.** The runtime never used |
| 204 | + `Isolate::VisitHandlesWithClassIds` or `SetWrapperClassId`, so the registry |
| 205 | + the iOS runtime had to grow is not needed here. |
| 206 | + |
| 207 | +## Test status |
| 208 | + |
| 209 | +The runtime test suite on an API 35 arm64 emulator: **594 tests, 0 failures, |
| 210 | +0 errors, 5 skipped.** All five skips are pre-existing `xit()` in the checked-in |
| 211 | +suite (`testNativeModules`, `exceptionHandlingTests` SIGABRT, `testArrays` |
| 212 | +memory leak, `TNS require` index.json, `TNS Workers` circular postMessage). |
| 213 | + |
| 214 | +### Inspector |
| 215 | + |
| 216 | +Verified end to end against the debug build over the runtime's WebSocket |
| 217 | +(`<package>-inspectorServer`, an abstract local socket): `Runtime.enable` |
| 218 | +(execution context creation and replay of stored console messages, with stack |
| 219 | +traces), `Runtime.evaluate` for arithmetic, object serialisation and Java |
| 220 | +interop, `Debugger.enable`, and exception reporting through `exceptionDetails`. |
| 221 | +This is the part most exposed to a stale `v8_inspector` tree, since it compiles |
| 222 | +against V8 internals rather than the public API. |
| 223 | + |
| 224 | +## Known follow-ups |
| 225 | + |
| 226 | +- All four ABIs now come from the pinned release, but only buildscripts' CI can |
| 227 | + produce the 32-bit two: they need an ia32-capable Linux x64 host (see above), |
| 228 | + so a local macOS build cannot regenerate them. |
| 229 | +- **`V8_STATIC_ROOTS` is deliberately not defined by the runtime.** V8 is built |
| 230 | + with it, and it would let `Value::IsUndefined()`/`IsNull()` and friends use |
| 231 | + the inline static-root comparison instead of reading the map. It is left off |
| 232 | + because the root addresses are hardcoded constants in `v8-internal.h` that |
| 233 | + must match the library exactly (`kBuildDependentTheHoleValue` even varies with |
| 234 | + `V8_ENABLE_WEBASSEMBLY`), and 10.3 had no such fast path either — so this is |
| 235 | + parity, and enabling it is a measurable but separate change. |
| 236 | +- On-device compiled-code caches written by 10.3 are stale. `TryLoadScriptCache` |
| 237 | + validates them only by comparing mtimes with the `.js` file, so an app update |
| 238 | + (which rewrites the scripts) discards them and they are regenerated. If the |
| 239 | + mtimes did happen to match, V8 rejects the data by its own version hash and |
| 240 | + recompiles from source — correct, but `SaveScriptCache` is only called on the |
| 241 | + no-cache path, so that file would never be refreshed. Pre-existing behaviour, |
| 242 | + newly reachable on a version bump. |
| 243 | +- Maglev is enabled by default in 14.9 and did not exist in 10.3, so the |
| 244 | + monolith carries a whole extra compiler tier. `v8_enable_maglev=false` is the |
| 245 | + lever if the size matters more than the warm-up performance. |
0 commit comments