Flags: How V8 Is Configured at Compile Time and at Runtime
Every V8 flag is one line in one file that is included five times with different macros, which is how a single declaration becomes a struct field, a default, a parser entry and an implication.
TL;DR: There is no code generator.
src/flags/flag-definitions.his a hand-written file ofDEFINE_*macros that is#included with five differentFLAG_MODE_*macros defined, and each mode turns the same declaration into a struct field, a default constant, a parser table entry, an implication, or a metadata row. Read-only flags (DEFINE_BOOL_READONLY, and everyDEFINE_DEBUG_BOOLin a release build) becomestatic constexprconstants that cannot be changed at all, and afterV8::Initializethe whole flag page ismprotected read-only.
V8 has well over a thousand flags. They control everything from which tiers a function is compiled at to whether the sandbox is enabled to whether the optimizing compiler is allowed to run at all. They are configured from the command line, from the embedding application, and - for a few of them — at runtime after V8 has already started.
The interesting thing is not that V8 has flags. It is how a thousand flags can live in one place without a code generator, without a build step, and without the flag parser and the flag struct drifting apart.
The answer is a file that is included six times, in five different modes.
One file, five modes
The file is src/flags/flag-definitions.h. It starts by explaining
itself:
// src/flags/flag-definitions.h:5
// This file defines all of the flags. It is separated into different section,
// for Debug, Release, Logging and Profiling, etc. To add a new flag, find the
// correct section, and use one of the DEFINE_ macros, without a trailing ';'.
//
// This include does not have a guard, because it is a template-style include,
// which can be included multiple times in different modes. It expects to have
// a mode defined before it's included. The modes are FLAG_MODE_... below:
//
// PRESUBMIT_INTENTIONALLY_MISSING_INCLUDE_GUARD
No include guard, on purpose. Each inclusion sees a different set of
FLAG_MODE_* macros and produces different code from the same text.
The first mode builds the struct that actually holds the values:
// src/flags/flags.h:66
// Declare a struct to hold all of our flags.
struct alignas(kMinimumOSPageSize) FlagValues {
FlagValues() = default;
// No copying, moving, or assigning. This is a singleton struct.
FlagValues(const FlagValues&) = delete;
FlagValues(FlagValues&&) = delete;
FlagValues& operator=(const FlagValues&) = delete;
FlagValues& operator=(FlagValues&&) = delete;
#define FLAG_MODE_DECLARE
#include "src/flags/flag-definitions.h" // NOLINT(build/include)
#undef FLAG_MODE_DECLARE
};and in that mode, the two primitive macros do this:
// src/flags/flag-definitions.h:40
// With FLAG_MODE_DECLARE we declare the fields in the {FlagValues} struct.
// Read-only flags are static constants instead of fields.
#if defined(FLAG_MODE_DECLARE)
#define FLAG_FULL(ftype, ctype, nam, def, cmt) FlagValue<ctype> nam{def};
#define FLAG_READONLY(ftype, ctype, nam, def, cmt) \
static constexpr FlagValue<ctype> nam{def};There it is: a normal flag is a field, a read-only flag is a
static constexpr constant. Not a field that happens to be checked
at runtime — a compile-time constant. There is no memory to write, no
pointer to chase, and the compiler can constant-fold every use of it.
The second mode computes the defaults, so the parser can compare a value against its default:
// src/flags/flag-definitions.h:50
#elif defined(FLAG_MODE_DEFINE_DEFAULTS)
#define FLAG_FULL(ftype, ctype, nam, def, cmt) \
static constexpr ctype FLAGDEFAULT_##nam{def};
#define FLAG_READONLY(ftype, ctype, nam, def, cmt) \
static constexpr ctype FLAGDEFAULT_##nam{def};The third mode builds the metadata table — the array the parser looks flags up in:
// src/flags/flag-definitions.h:58
#elif defined(FLAG_MODE_META)
#define FLAG_FULL(ftype, ctype, nam, def, cmt) \
{Flag::TYPE_##ftype, &v8_flags.nam, &FLAGDEFAULT_##nam, false},
// Readonly flags don't pass the value pointer since the struct expects a
// mutable value. That's okay since the value always equals the default.
#define FLAG_READONLY(ftype, ctype, nam, def, cmt) \
{Flag::TYPE_##ftype, nullptr, &FLAGDEFAULT_##nam, false},A nullptr value pointer is how “read-only” is encoded at runtime.
Flag::IsReadOnly() is literally valptr_ == nullptr, and the getter
short-circuits:
// src/flags/flags-impl.h:227
template <FlagType flag_type, typename T>
T GetValue() const {
DCHECK_EQ(flag_type, type_);
if (IsReadOnly()) return GetDefaultValue<T>();
return *reinterpret_cast<const FlagValue<T>*>(valptr_);
}So a read-only flag always reads as its default, no matter what you do. Setting it is either a no-op or an error, depending on the flag processing mode.
The table itself is one array:
// src/flags/flags.cc:330
Flag flags[] = {
#define FLAG_MODE_META
#include "src/flags/flag-definitions.h" // NOLINT(build/include)
#undef FLAG_MODE_META
};
constexpr size_t kNumFlags = arraysize(flags);The fourth mode generates the implication code, the fifth generates metadata rows (including aliases), and the sixth is a generic “apply this macro to every flag” escape hatch.
The macro families
The DEFINE_ macros are thin wrappers around two primitives, FLAG
and FLAG_READONLY:
// src/flags/flag-definitions.h:257
#define DEFINE_BOOL(nam, def, cmt) FLAG(BOOL, bool, nam, def, cmt)
#define DEFINE_BOOL_READONLY(nam, def, cmt) \
FLAG_READONLY(BOOL, bool, nam, def, cmt)
#define DEFINE_MAYBE_BOOL(nam, cmt) \
FLAG(MAYBE_BOOL, std::optional<bool>, nam, std::nullopt, cmt)
#define DEFINE_INT(nam, def, cmt) FLAG(INT, int, nam, def, cmt)
#define DEFINE_UINT(nam, def, cmt) FLAG(UINT, unsigned int, nam, def, cmt)
#define DEFINE_UINT_READONLY(nam, def, cmt) \
FLAG_READONLY(UINT, unsigned int, nam, def, cmt)
#define DEFINE_UINT64(nam, def, cmt) FLAG(UINT64, uint64_t, nam, def, cmt)
#define DEFINE_FLOAT(nam, def, cmt) FLAG(FLOAT, double, nam, def, cmt)
#define DEFINE_SIZE_T(nam, def, cmt) FLAG(SIZE_T, size_t, nam, def, cmt)
#define DEFINE_STRING(nam, def, cmt) FLAG(STRING, const char*, nam, def, cmt)The MAYBE_BOOL type is interesting: it is a tri-state (unset /
true / false) flag, used where “the user did not say” is different
from “the user said false”.
There is also a family of macros that are not about types but about
policy. DEFINE_DEBUG_BOOL is the most consequential:
// src/flags/flag-definitions.h:286
#ifdef DEBUG
#define DEFINE_DEBUG_BOOL DEFINE_BOOL
#else
#define DEFINE_DEBUG_BOOL DEFINE_BOOL_READONLY
#endifIn a debug build, --trace-wasm-lazy-compilation is a real flag you
can flip. In a release build it is a static constexpr false. There
are fifteen DEFINE_DEBUG_BOOL declarations and they are all tracing
or diagnostic switches, so a release V8 does not contain the code that
implements them at all.
Similarly:
// src/flags/flag-definitions.h:350
// Developer features: These flags expose features that are only meant to be
// used by developers for diagnosing purposes. These features should be robust
// but crashes will not be considered security problems as they do not affect
// users in production.
DEFINE_BOOL(
developer_only_features, false,
"Indicates that V8 is running with developer-only features enabled.")
DEFINE_BOOL(disallow_developer_only_features, false,
"Disallow developer-only-features.")
#define DEFINE_DEVELOPER_FLAG(name, comment) \
DEFINE_BOOL(name, false, comment " (developer-only)") \
DEFINE_IMPLICATION(name, developer_only_features) \
DEFINE_NEG_IMPLICATION(disallow_developer_only_features, name)DEFINE_DEVELOPER_FLAG implies --developer-only-features, so V8 can
tell at a glance that it is running in a configuration that is not
production. There are 183 of them.
And the unsafe ones, which are the ones the fuzzer infrastructure cares about most:
// src/flags/flag-definitions.h:316
// Test-only and unsafe flags: Flags that expose unsafe and/or unsupported
// configurations. Defining a flag with DEFINE_UNSAFE_FLAG or marking an
// existing flag with DISALLOW_UNSAFE_FLAG / DISALLOW_UNSAFE_FLAG_IF will:
// 1. Imply --test-only-unsafe when the flag is set to an unsafe value (which
// causes d8 to print a warning).
// 2. Prohibit/reset the flag when --disallow-unsafe-flags is set.
DEFINE_BOOL(test_only_unsafe, false, ...)
DEFINE_BOOL(disallow_unsafe_flags, false, ...)Implications: flags that set other flags
The second big idea is that flags are not independent. --jitless
implies --no-liftoff implies --no-turbofan implies
--no-maglev, and about a hundred other things. The implication
macros are defined once and expanded per flag:
// src/flags/flag-definitions.h:68
// We produce the code to set flags when it is implied by another flag.
#elif defined(FLAG_MODE_DEFINE_IMPLICATIONS)
// Enforces that `thenflag` is set to `value` if `cond` holds.
#define DEFINE_VALUE_IMPLICATION(cond, thenflag, value) \
changed |= TriggerImplication(cond, #cond, &v8_flags.thenflag, #thenflag, \
value, kStrongImplication);
// Enforces that `thenflag` is weakly set to `value` if `cond` holds. A weak
// implication is overwritten by a normal implication or by an explicit flag.
#define DEFINE_WEAK_VALUE_IMPLICATION(cond, thenflag, value) \
changed |= TriggerImplication(cond, #cond, &v8_flags.thenflag, #thenflag, \
value, kWeakImplication);The distinction between strong and weak is the interesting bit. A
strong implication overrides an explicit command-line setting. A weak
one does not — it only fills in a value that nothing else has set.
--wasm-staging uses weak implications so that you can still turn a
single staged feature off by naming it explicitly:
// src/flags/flag-definitions.h:2010
#define DECL_STAGED_JS_FLAG(feature_name, description) \
DEFINE_BOOL(feature_name, false, "enable " description " for JS") \
DEFINE_WEAK_IMPLICATION(js_staging, feature_name) \
DEFINE_WEAK_IMPLICATION(harmony, feature_name)There are also implications that are not booleans at all:
// src/flags/flag-definitions.h:108
// Enforces that `flag` is at least `min_value` (sets `flag = min_value` if
// `flag < min_value`).
#define DEFINE_MIN_VALUE_IMPLICATION(flag, min_value) \
changed |= TriggerImplication(v8_flags.flag < min_value, \
#flag "<" #min_value, &v8_flags.flag, #flag, \
min_value, kStrongImplication);which is how --wasm-wrapper-tiering-budget is clamped to at least 1.
Implications are resolved by a fixpoint loop, and the loop is bounded so that a cycle cannot hang V8 at startup:
// src/flags/flags.cc:1560
void FlagList::EnforceFlagImplications() {
for (ImplicationProcessor proc; proc.EnforceImplications();) {
// Continue processing (recursive) implications. The processor has an
// internal limit to avoid endless recursion.
}
}Contradictions
Two flags that disagree are a “contradiction”, and V8 has three policies for what to do about them:
// src/flags/flags.h:84
enum class FlagProcessingMode {
// Flag errors lead to abnormal termination (via Abort). This is typically
// the default behavior as it clearly indicates flag misconfigurations.
kAbortOnError,
// Flag errors lead to termination via Exit(-1). This is useful for automated
// bug detection systems that may set custom flags. This way, flag
// misconfigurations are not treated as crashes but only as failed executions.
kExitOnError,
// Flag contradictions are explicitly ignored. This can be useful when
// fuzzing with random flags that are likely to contradict each other. In
// these cases, the testcase will still be executed and the last
// specification of each flag will be used.
kIgnoreContradictions
};The default is ignore, which surprises people:
// src/flags/flags.cc:102
FlagProcessingMode FlagList::GetFlagProcessingMode() {
// The default processing mode is "ignore-contradictions" (mostly for
// historical reasons). However, certain testing tools like d8 and
// inspector-test explicitly set it to "abort-on-error".
and the mode is selected by a string flag:
// src/flags/flag-definitions.h:374
DEFINE_STRING(flag_processing_mode, nullptr,
"Control behavior for flag errors. Possible values: "
"abort-on-error, exit-on-error, ignore-contradictions.")Contradiction detection is not free, so it is off by default and V8 only pays for it when it is on:
// src/flags/flags-impl.h:213
static bool ShouldCheckFlagContradictions();// src/flags/flags.cc:132
bool Flag::ShouldCheckFlagContradictions() {
return FlagList::GetFlagProcessingMode() !=
FlagProcessingMode::kIgnoreContradictions;
}Setting flags at runtime
The parser is shared between the command line and the string API. The
command-line parser handles the --no- prefix, the = form, and the
space-separated form:
// src/flags/flags.cc:796
// Parse arguments.
for (int i = 1; i < *argc;) {
int j = i; // j > 0
const char* arg = argv[i++];
if (arg == nullptr) continue;
// Stop processing flags on '--'.
if (arg[0] == '-' && arg[1] == '-' && arg[2] == '\0') break;
// Split arg into flag components.
char buffer[1 * KB];
const char* name;
const char* value;
bool negated;
SplitArgument(arg, buffer, sizeof buffer, &name, &value, &negated);and the string API is a thin wrapper that splits a string into an argv-like array and calls the same parser:
// src/flags/flags.cc:967
// static
int FlagList::SetFlagsFromString(const char* str, size_t len) {
// Make a 0-terminated copy of str.
std::unique_ptr<char[]> copy0{NewArray<char>(len + 1)};
MemCopy(copy0.get(), str, len);
copy0[len] = '\0';
...
return SetFlagsFromCommandLine(&argc, argv.begin(), false);
}which is what the public API calls:
// src/api/api.cc:471
void V8::SetFlagsFromString(const char* str) {
SetFlagsFromString(str, strlen(str));
}
void V8::SetFlagsFromString(const char* str, size_t length) {
i::FlagList::SetFlagsFromString(str, length);
}A note on the JavaScript side: the old v8.setFlagsFromString(...)
debugging function is not part of d8 any more. d8’s global object
exposes version, print, write, load and friends, and the flag
API is now C++-only. If you want to flip a flag from a script, you
have to use --allow-natives-syntax and a % intrinsic, or you have to
embed V8 yourself.
And there is no --flagfile. V8 has no mechanism for reading flags out
of a file at startup; the flags come from the command line, from the
embedding application’s V8::SetFlagsFromCommandLine, or from
V8::SetFlagsFromString. (Chrome has its own mechanisms for turning a
file into a command line, but that is not V8.)
Freezing
After V8::Initialize runs, the flags stop being changeable:
// src/init/v8.cc:190
// Before initializing internals, freeze the flags such that further changes
// are not allowed. Global initialization of the Isolate or the WasmEngine
// already reads flags, so they should not be changed afterwards.
if (v8_flags.freeze_flags_after_init) FlagList::FreezeFlags();and freezing is not just a boolean — it is mprotect:
// src/flags/flags.cc:999
// static
void FlagList::FreezeFlags() {
// Disallow changes via the API by setting {flags_frozen}.
flags_frozen.store(true, std::memory_order_relaxed);
// Also memory-protect the memory that holds the flag values. This makes it
// impossible for attackers to overwrite values, except if they find a way to
// first unprotect the memory again.
// Note that for string flags we only protect the pointer itself, but not the
// string storage. TODO(12887): Fix this.
base::OS::SetDataReadOnly(&v8_flags, sizeof(v8_flags));
}This is why the flag struct is page-aligned:
// src/flags/flags.cc:45
// {v8_flags} needs to be aligned to a memory page, and the size needs to be a
// multiple of a page size. This is required for memory-protection of the memory
// holding the {v8_flags} struct.
// Both is guaranteed by the {alignas(kMinimumOSPageSize)} annotation on
// {FlagValues}.
static_assert(alignof(FlagValues) == kMinimumOSPageSize);
static_assert(sizeof(FlagValues) % kMinimumOSPageSize == 0);A flag value that a sandbox escape overwrites is a flag value that turns off the sandbox. Making the page read-only after startup means that bug is a crash rather than a compromise. (The TODO is honest about the gap: string flags protect the pointer, not the string it points at.)
--freeze-flags-after-init defaults to true, except on AIX component
builds where mprotect does not work on shared memory:
// src/flags/flag-definitions.h:3794
#if defined(V8_OS_AIX) && defined(COMPONENT_BUILD)
// FreezeFlags relies on mprotect() method, which does not work by default on
// shared mem: https://www.ibm.com/docs/en/aix/7.2?topic=m-mprotect-subroutine
DEFINE_BOOL(freeze_flags_after_init, false,
"Disallow changes to flag values after initializing V8")
#else
DEFINE_BOOL(freeze_flags_after_init, true,
"Disallow changes to flag values after initializing V8")
#endif // defined(V8_OS_AIX) && defined(COMPONENT_BUILD)
The flag hash: why flags invalidate code caches
Here is the payoff of the whole system. V8 hashes the flags that are not at their default value, and that hash goes into the header of every serialized code cache:
// src/flags/flags.cc:609
uint32_t ComputeFlagListHash() {
std::ostringstream modified_args_as_string;
if (COMPRESS_POINTERS_BOOL) modified_args_as_string << "ptr-compr";
if (DEBUG_BOOL) modified_args_as_string << "debug";
if (V8_X64_16BYTE_STACK_ALIGNMENT_BOOL) {
modified_args_as_string << "x64-stack-16";
}
...
for (const Flag& flag : flags) {
if (flag.IsDefault()) continue;
...
// We want to be able to flip --profile-deserialization without
// causing the code cache to get invalidated by this hash.
if (flag.PointsTo(&v8_flags.profile_deserialization)) continue;
// Skip v8_flags.random_seed and v8_flags.predictable to allow predictable
// code caching.
if (flag.PointsTo(&v8_flags.random_seed)) continue;
if (flag.PointsTo(&v8_flags.predictable)) continue;The exclusions are deliberate and each one is a policy decision:
--profile-deserialization is about reading a cache, not producing
one, so flipping it must not invalidate anything. --random-seed and
--predictable exist precisely so that people can get reproducible
results, and that only works if they do not change the hash.
The hash is cached and invalidated lazily:
// src/flags/flags.cc:1567
// static
uint32_t FlagList::Hash() {
if (uint32_t hash = flag_hash.load(std::memory_order_relaxed)) return hash;
uint32_t hash = ComputeFlagListHash();
flag_hash.store(hash, std::memory_order_relaxed);
return hash;
}and FlagValue::operator= resets it whenever a value actually changes:
// src/flags/flags.h:188
template <typename T>
FlagValue<T>& FlagValue<T>::operator=(T new_value) {
if (new_value != value_) {
FlagList::ResetFlagHash();
value_ = new_value;
}
return *this;
}So: any flag change invalidates every serialized code cache in the
process. That is why the wasm serializer writes FlagList::Hash()
into its header, and why the snapshot and the code cache agree on when
they are stale.
The launch process, encoded as flags
The last thing worth knowing is that some of V8’s flags are not really
flags — they are a release process. src/flags/feature-flags.h is a
separate file that lists every in-flight feature by launch stage:
// src/flags/feature-flags.h:8
// Any V8 feature or large scale change that follows the launch process should
// be tracked here for transparency. This ensures that the requirements for
// each step of the process are met, e.g. enabling it for fuzzers, opening it
// to the VRP, etc.
// For each step of the shipping process, there is a dedicated section below
// with a macro. The entries for each feature move from one macro to the next
// as they go through the shipping process.
Four stages — experimental, pre-staged, staged, shipped — and each one becomes a real flag with the right implications:
// src/flags/flag-definitions.h:1971
#define DECL_EXPERIMENTAL_JS_FLAG(feature_name, description) \
DEFINE_EXPERIMENTAL_FEATURE(feature_name, "enable " description " for JS")
#ifdef V8_ENABLE_WEBASSEMBLY
#define DECL_EXPERIMENTAL_WASM_FLAG(feature_name, description) \
DEFINE_EXPERIMENTAL_FEATURE(wasm_##feature_name, \
"enable " description " for Wasm")
#else
#define DECL_EXPERIMENTAL_WASM_FLAG(feature_name, description)
#endif // V8_ENABLE_WEBASSEMBLY
Note the #ifdef V8_ENABLE_WEBASSEMBLY: a wasm feature flag simply does
not exist in a build without wasm. The flag is the launch state, and
the flag’s default value is the stage.
A shipped feature is a flag that defaults to true and can only be turned off:
// src/flags/flag-definitions.h:2031
#define DECL_SHIPPED_JS_FLAG(feature_name, description) \
DEFINE_BOOL(feature_name, true, "enable " description " for JS") \
DEFINE_NEG_NEG_IMPLICATION(js_shipping, feature_name) \
DEFINE_NEG_NEG_IMPLICATION(harmony_shipping, feature_name)DEFINE_NEG_NEG_IMPLICATION means “if --js-shipping is false, set
this to false” — so --no-js-shipping turns off every shipped feature
at once, and --no-js-immutable-arraybuffer turns off one.
Trace flags
The trace_* family deserves its own note, because it is a different
mechanism from v8_flags. The v8_flags struct is a singleton,
frozen after startup, and read from every thread without atomics.
Tracing, on the other hand, wants to be toggled at runtime, so it has
its own atomics:
// src/logging/tracing-flags.h:15
// This struct contains a set of flags that can be modified from multiple
// threads at runtime unlike the normal v8_flags.-like flags which are not
// modified after V8 instance is initialized.
struct TracingFlags {
static V8_EXPORT_PRIVATE std::atomic_uint runtime_stats;
static V8_EXPORT_PRIVATE std::atomic_uint gc;
static V8_EXPORT_PRIVATE std::atomic_uint gc_stats;
static V8_EXPORT_PRIVATE std::atomic_uint ic_stats;
static V8_EXPORT_PRIVATE std::atomic_uint zone_stats;And the tracing flags themselves come in three flavours depending on how expensive they are. The expensive ones are compiled out of release builds entirely:
// src/flags/flag-definitions.h:1454
#define DEFINE_SLOW_TRACING_BOOL DEFINE_BOOL
#else
#define DEFINE_SLOW_TRACING_BOOL DEFINE_BOOL_READONLY
#endif // V8_ENABLE_SLOW_TRACING
DEFINE_SLOW_TRACING_BOOL(trace_turbo_trimming, false,
"trace TurboFan's graph trimmer")so --trace-turbo-trimming exists in a debug build and is a constant
false in a release build.
Trade-offs
What the multi-mode include buys. One declaration produces the value, the default, the parser entry, the implication and the help text. They cannot drift apart, because there is only one place to change them. Adding a flag is one line.
What it costs.
- The file is 4500 lines of preprocessor soup. Every mode has to
define every macro, and the
FLAG_MODE_*block at the top of the file is 120 lines of#define/#elifthat has to be understood before anything else makes sense. - The macros have to be
#undefed at the bottom. The file ends with fifty#undeflines, and if you add a macro and forget to undef it, you break every other inclusion. - Compile time. The file is parsed nine times over the tree (once per mode per translation unit that needs it), and the flag struct alone is one page of memory that has to be laid out in the binary.
- Read-only flags are a trap.
--trace-somethingin a release build parses fine, sets nothing, and does nothing. There is no warning.
What the freeze buys. A flag is a security boundary. --jitless,
--no-sandbox, --single-threaded and friends can each turn a
mitigation off. Making the page read-only after startup means an
attacker with a write primitive cannot simply flip --sandbox off.
The cost is that the embedding application cannot change flags after
V8::Initialize, which is why V8::SetFlagsFromString has to be
called early.
The bottom line
The mental model is: a flag is one line, and the file is included
repeatedly to turn that line into everything the flag needs to be. A
read-only flag is not a flag that happens to be checked — it is a
static constexpr constant that was never a variable. An implication
is not a runtime check — it is a fixpoint over a generated function.
And the hash of “every flag that is not at its default” is what ties
the flag system to the code cache system: change a flag, and every
piece of cached compiled code in the process becomes stale.
The practical consequences for an embedder are short. Set your flags
before V8::Initialize. Use V8::SetFlagsFromCommandLine or
V8::SetFlagsFromString, not argv surgery. And if you are caching
compiled code, include FlagList::Hash() in your cache key, or you
will serve stale code to somebody who turned on a flag you did not
turn on.
The thing people get wrong
The belief that V8’s flags are generated. They are not. There is no
tools/flags/ directory, no template, no build step. src/flags/flag-definitions.h
is a hand-written file that a human edits, and the “generation” happens
at compile time by including the same text several times. (The file
used to be called flags-def.h, and there was a generator once; the
current design deliberately does not have one.)
The second belief is that a read-only flag is a flag you cannot set at
runtime but which is otherwise a normal flag. It is not a normal flag
at all. DEFINE_BOOL_READONLY produces a static constexpr member of
the flag struct; there is no storage, no pointer in the metadata table,
and no way to read anything but the default. DEFINE_DEBUG_BOOL in a
release build is exactly this, which is why --trace-wasm-lazy-compilation
silently does nothing in a release build of V8.