diff --git a/.nanvix/docker.py b/.nanvix/docker.py index 870580c99a7231..abee84074b7cbd 100644 --- a/.nanvix/docker.py +++ b/.nanvix/docker.py @@ -21,6 +21,7 @@ from _loader import load_sibling config = load_sibling("config", __file__) +setup_local_mod = load_sibling("setup_local", __file__) def _workspace_id(workspace: Path) -> str: @@ -282,27 +283,23 @@ def clean_volume(workspace: Path) -> None: def _generate_setup_local_cmd() -> str: - """Shell command to generate Modules/Setup.local inside the container.""" + """Shell command to generate Modules/Setup.local inside the container. + + Rendered from .nanvix/setup_local.py (single source of truth shared + with the host build path .nanvix/lxml.py::generate_setup_local). + The rendered file body is emitted via a single ``printf '%s\\n' ...`` + invocation with each line single-quoted for the container shell. + """ sysroot = config.DOCKER_SYSROOT_PATH ws = config.DOCKER_WORKSPACE_PATH - return ( - f"printf '%s\\n' " - f"'# Auto-generated by .nanvix/docker.py -- do not edit manually.' " - f"'' " - f"'# Statically-linked extension modules for Nanvix builds.' " - f"'*static*' " - f"'# Nanvix OS interface module (snapshot, host-mount).' " - f"'_nanvix _nanvixmodule.c' " - f"'# lxml C extension modules (statically linked via pre-built archives).' " - f"'_lxml_etree lxml_etree_builtin.c -L{sysroot}/lib -llxml_etree -lxslt -lexslt -lxml2 -lz' " - f"'_lxml_elementpath lxml_elementpath_builtin.c -L{sysroot}/lib -llxml_elementpath -lxml2 -lz' " - f"'' " - f"'# Phase 0 of the .a -> .so migration: array as proof-of-concept shared module.' " - f"'# See nanvix-todo/cpython-static-to-shared-migration.md section 4.' " - f"'*shared*' " - f"'array arraymodule.c' " - f"> {ws}/Modules/Setup.local" + rendered = setup_local_mod.render_setup_local( + sysroot=str(sysroot), + header_comment="Auto-generated by .nanvix/docker.py -- do not edit manually.", + ) + quoted = " ".join( + "'" + line.replace("'", "'\"'\"'") + "'" for line in rendered.split("\n") ) + return f"printf '%s\\n' {quoted} > {ws}/Modules/Setup.local" def _inner_make_cmd( diff --git a/.nanvix/lxml.py b/.nanvix/lxml.py index 5211ad67aaa80f..4148935049611f 100644 --- a/.nanvix/lxml.py +++ b/.nanvix/lxml.py @@ -11,34 +11,22 @@ from _loader import load_sibling config = load_sibling("config", __file__) - -_SETUP_LOCAL_TEMPLATE = """\ -# Auto-generated by .nanvix/lxml.py -- do not edit manually. - -# Statically-linked extension modules for Nanvix builds. -*static* -# Nanvix OS interface module (snapshot, host-mount). -_nanvix _nanvixmodule.c -# lxml C extension modules (statically linked via pre-built archives). -_lxml_etree lxml_etree_builtin.c -L{sysroot}/lib -llxml_etree -lxslt -lexslt -lxml2 -lz -_lxml_elementpath lxml_elementpath_builtin.c -L{sysroot}/lib -llxml_elementpath -lxml2 -lz - -# Phase 0 of the .a -> .so migration: array as proof-of-concept shared module. -# See nanvix-todo/cpython-static-to-shared-migration.md section 4. -# Listed BEFORE Setup.stdlib's static declaration so makesetup's -# "first rule wins" semantics make this shared variant take precedence. -*shared* -array arraymodule.c -""" +setup_local_mod = load_sibling("setup_local", __file__) def generate_setup_local(repo_root: Path, sysroot: Path) -> None: - """Generate Modules/Setup.local with statically-linked module definitions. + """Generate Modules/Setup.local from .nanvix/setup_local.py. - Includes both the _nanvix OS interface module and lxml C extensions. + Single source of truth shared with the Docker build path + (.nanvix/docker.py::_generate_setup_local_cmd). The {sysroot} + placeholder in entry tokens is substituted with the host build's + sysroot path here. """ setup_local = repo_root / "Modules" / "Setup.local" - content = _SETUP_LOCAL_TEMPLATE.format(sysroot=sysroot) + content = setup_local_mod.render_setup_local( + sysroot=str(sysroot), + header_comment="Auto-generated by .nanvix/lxml.py -- do not edit manually.", + ) setup_local.write_text(content, encoding="utf-8") print(f"[lxml] Generated {setup_local}") diff --git a/.nanvix/setup_local.py b/.nanvix/setup_local.py new file mode 100644 index 00000000000000..14189598fae6fe --- /dev/null +++ b/.nanvix/setup_local.py @@ -0,0 +1,347 @@ +# Copyright(c) The Maintainers of Nanvix. +# Licensed under the MIT License. + +"""Single source of truth for Modules/Setup.local on Nanvix builds. + +The host-build path (.nanvix/lxml.py::generate_setup_local) and the +Docker-build path (.nanvix/docker.py::_generate_setup_local_cmd) both +consume SETUP_LOCAL_ENTRIES and render the same file body via +render_setup_local(). + +Module ordering matters: makesetup applies "first rule wins" semantics +when the same module is declared both here and in the upstream +Modules/Setup.stdlib. *static* entries must precede *shared* entries +within a section; declaring a *shared* duplicate before the upstream +*static* default is how each migrated stdlib module switches link mode +to a runtime-loaded .so. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Iterable, NamedTuple, Sequence + + +class Linkage(Enum): + STATIC = "*static*" + SHARED = "*shared*" + + +class SetupEntry(NamedTuple): + """One line of Modules/Setup.local plus optional surrounding comments. + + The rendered line is:: + + ... + + Tokens may contain the literal substring ``{sysroot}``; the renderer + substitutes it for the actual sysroot path. No other formatting is + applied. + + ``comment`` (if set) is emitted as a single ``# ...`` line directly + above the entry. ``section_header`` (if set) is emitted as a block + of ``# ...`` lines immediately above the section's linkage marker + the FIRST time an entry contributes to a new section grouping; it + is intended as a separator between groups of related modules. + """ + + name: str + linkage: Linkage + tokens: Sequence[str] = () + comment: str = "" + section_header: str = "" + + +# --------------------------------------------------------------------------- +# The table +# --------------------------------------------------------------------------- +# +# Section order (enforced by the renderer based on Linkage transitions): +# +# *static* -- the _nanvix OS-interface module (only). +# +# *shared* -- every extension module built as a runtime-loaded .so. +# For modules with bundled-in-cpython C deps (_decimal, +# pyexpat, _elementtree, _sha2), each .so bundles its own +# vendored .a -- same behavior as upstream cpython's +# default ./configure run. mpdec / expat / HACL hashing +# are stateless C APIs with at most one consumer each +# (pyexpat exposes a PyCapsule that _elementtree calls +# through, so libexpat lives in pyexpat.so only); there +# is no need to hoist their code into python.elf for +# cross-module sharing. + +SETUP_LOCAL_ENTRIES: tuple[SetupEntry, ...] = ( + # ---------------- *static* ----------------------------------------- + SetupEntry( + name="_nanvix", + linkage=Linkage.STATIC, + tokens=("_nanvixmodule.c",), + comment="Nanvix OS interface module (snapshot, host-mount).", + ), + # ---------------- *shared* ----------------------------------------- + SetupEntry( + name="array", + linkage=Linkage.SHARED, + tokens=("arraymodule.c",), + section_header=( + "`array` as the proof-of-concept shared module. Listed " + "BEFORE Setup.stdlib's static declaration so makesetup's " + '"first rule wins" semantics make this shared variant ' + "take precedence." + ), + ), + SetupEntry( + name="_lxml_etree", + linkage=Linkage.SHARED, + tokens=( + "lxml_etree_builtin.c", + "-L{sysroot}/lib", + "-llxml_etree", + "-lxslt", + "-lexslt", + "-lxml2", + "-lz", + ), + section_header=("lxml C extension modules (linked via pre-built archives)."), + ), + SetupEntry( + name="_lxml_elementpath", + linkage=Linkage.SHARED, + tokens=( + "lxml_elementpath_builtin.c", + "-L{sysroot}/lib", + "-llxml_elementpath", + "-lxml2", + "-lz", + ), + ), + # ---------------- Data primitives (no external deps) ---------------- + *( + SetupEntry( + name=name, + linkage=Linkage.SHARED, + tokens=(src,), + section_header=(header if i == 0 else ""), + ) + for i, (name, src) in enumerate( + ( + ("_bisect", "_bisectmodule.c"), + ("_heapq", "_heapqmodule.c"), + ("_struct", "_struct.c"), + ("_random", "_randommodule.c"), + ("_opcode", "_opcode.c"), + ("_queue", "_queuemodule.c"), + ("_csv", "_csv.c"), + ("binascii", "binascii.c"), + ("_json", "_json.c"), + ("_pickle", "_pickle.c"), + ("_zoneinfo", "_zoneinfo.c"), + ) + ) + for header in ("Data primitives (pure C, no external deps).",) + ), + # ---------------- Math + memory (libm via python.elf) -------------- + *( + SetupEntry( + name=name, + linkage=Linkage.SHARED, + tokens=(src,), + section_header=(header if i == 0 else ""), + ) + for i, (name, src) in enumerate( + ( + ("math", "mathmodule.c"), + ("cmath", "cmathmodule.c"), + ("_statistics", "_statisticsmodule.c"), + ("mmap", "mmapmodule.c"), + ("_contextvars", "_contextvarsmodule.c"), + ) + ) + for header in ( + "Math and memory modules (libm symbols pulled from python.elf via --whole-archive).", + ) + ), + # ---------------- Text codecs (no external deps) ------------------- + *( + SetupEntry( + name=name, + linkage=Linkage.SHARED, + tokens=(src,), + section_header=(header if i == 0 else ""), + ) + for i, (name, src) in enumerate( + ( + ("unicodedata", "unicodedata.c"), + ("_multibytecodec", "cjkcodecs/multibytecodec.c"), + ("_codecs_cn", "cjkcodecs/_codecs_cn.c"), + ("_codecs_hk", "cjkcodecs/_codecs_hk.c"), + ("_codecs_iso2022", "cjkcodecs/_codecs_iso2022.c"), + ("_codecs_jp", "cjkcodecs/_codecs_jp.c"), + ("_codecs_kr", "cjkcodecs/_codecs_kr.c"), + ("_codecs_tw", "cjkcodecs/_codecs_tw.c"), + ) + ) + for header in ("Text codecs (pure C, no external deps).",) + ), + # ---------------- Modules with bundled-in-cpython C deps ----------- + # + # Each .so module bundles its own copy of the vendored .a -- the + # exact same behavior as upstream cpython's default ./configure + # run on Linux without --with-system-*. cpython's configure sets + # MODULE__DECIMAL_LDFLAGS=-lm $(LIBMPDEC_A) and + # MODULE_PYEXPAT_LDFLAGS=-lm $(LIBEXPAT_A) automatically, so the + # bare `_decimal _decimal/_decimal.c` and `pyexpat pyexpat.c` + # entries below pick up libmpdec / libexpat via cpython's normal + # MODULE_*_LDFLAGS machinery. _sha2 hardcodes the libHacl_Hash_SHA2.a + # path explicitly, matching upstream Modules/Setup.stdlib.in (HACL + # has no LIBHACL_LDFLAGS configure knob since there is no system-mode + # equivalent for vendored verified-crypto code). + # + # The other HACL hashes (_md5, _sha1, _sha3, _blake2) inline-compile + # their own _hacl translation units rather than linking a shared + # libHacl_Hash_*.a -- same shape as upstream. + SetupEntry( + name="_asyncio", + linkage=Linkage.SHARED, + tokens=("_asynciomodule.c",), + section_header=( + "Modules with bundled-in-cpython C deps. Each .so bundles " + "its own vendored .a, matching upstream cpython's default " + "./configure behavior." + ), + ), + SetupEntry(name="_datetime", linkage=Linkage.SHARED, tokens=("_datetimemodule.c",)), + SetupEntry( + name="_decimal", linkage=Linkage.SHARED, tokens=("_decimal/_decimal.c",) + ), + SetupEntry(name="pyexpat", linkage=Linkage.SHARED, tokens=("pyexpat.c",)), + SetupEntry(name="_elementtree", linkage=Linkage.SHARED, tokens=("_elementtree.c",)), + SetupEntry( + name="_md5", + linkage=Linkage.SHARED, + tokens=( + "md5module.c", + "-I$(srcdir)/Modules/_hacl/include", + "_hacl/Hacl_Hash_MD5.c", + "-D_BSD_SOURCE", + "-D_DEFAULT_SOURCE", + ), + ), + SetupEntry( + name="_sha1", + linkage=Linkage.SHARED, + tokens=( + "sha1module.c", + "-I$(srcdir)/Modules/_hacl/include", + "_hacl/Hacl_Hash_SHA1.c", + "-D_BSD_SOURCE", + "-D_DEFAULT_SOURCE", + ), + ), + SetupEntry( + name="_sha2", + linkage=Linkage.SHARED, + tokens=( + "sha2module.c", + "-I$(srcdir)/Modules/_hacl/include", + "Modules/_hacl/libHacl_Hash_SHA2.a", + ), + ), + SetupEntry( + name="_sha3", + linkage=Linkage.SHARED, + tokens=( + "sha3module.c", + "-I$(srcdir)/Modules/_hacl/include", + "_hacl/Hacl_Hash_SHA3.c", + "-D_BSD_SOURCE", + "-D_DEFAULT_SOURCE", + ), + ), + SetupEntry( + name="_blake2", + linkage=Linkage.SHARED, + tokens=( + "_blake2/blake2module.c", + "_blake2/blake2b_impl.c", + "_blake2/blake2s_impl.c", + ), + ), + SetupEntry(name="select", linkage=Linkage.SHARED, tokens=("selectmodule.c",)), + SetupEntry(name="_socket", linkage=Linkage.SHARED, tokens=("socketmodule.c",)), + SetupEntry( + name="_posixsubprocess", linkage=Linkage.SHARED, tokens=("_posixsubprocess.c",) + ), + SetupEntry(name="fcntl", linkage=Linkage.SHARED, tokens=("fcntlmodule.c",)), + SetupEntry(name="termios", linkage=Linkage.SHARED, tokens=("termios.c",)), +) + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +def _wrap_comment(text: str, *, width: int = 78) -> list[str]: + """Wrap a comment body to ``width`` columns, returning '# '-prefixed + lines suitable for direct emission into Setup.local. Empty input + yields an empty list.""" + if not text: + return [] + import textwrap + + wrapped = textwrap.wrap(text, width=width - 2) # account for '# ' + return [f"# {line}" for line in wrapped] + + +def render_setup_local( + entries: Iterable[SetupEntry] = SETUP_LOCAL_ENTRIES, + *, + sysroot: str, + header_comment: str, +) -> str: + """Render the entries to a complete Modules/Setup.local file body. + + ``header_comment`` becomes the first line so the file self-identifies + its generator (host vs Docker). ``sysroot`` substitutes every literal + ``{sysroot}`` token in any entry. + """ + lines: list[str] = [] + lines.append(f"# {header_comment}") + lines.append("") + + current_linkage: Linkage | None = None + for entry in entries: + new_section = entry.linkage is not current_linkage + if new_section: + if current_linkage is not None: + lines.append("") + if entry.section_header: + lines.extend(_wrap_comment(entry.section_header)) + else: + # No header for the very first section -- emit a minimal + # marker comment so the file remains self-documenting. + if current_linkage is None and entry.linkage is Linkage.STATIC: + lines.append( + "# Statically-linked extension modules for Nanvix builds." + ) + lines.append(entry.linkage.value) + current_linkage = entry.linkage + elif entry.section_header: + # Mid-section group separator (e.g. moving from lxml to the + # data-primitive modules without changing linkage). Emit as + # a blank-line-separated comment block before the entry. + lines.append("") + lines.extend(_wrap_comment(entry.section_header)) + + if entry.comment: + lines.extend(_wrap_comment(entry.comment)) + tokens = " ".join(entry.tokens).replace("{sysroot}", sysroot) + if tokens: + lines.append(f"{entry.name} {tokens}") + else: + lines.append(entry.name) + + lines.append("") # trailing newline + return "\n".join(lines) diff --git a/.nanvix/test.py b/.nanvix/test.py index ddc8efa3d26c65..0c99c793d9061a 100644 --- a/.nanvix/test.py +++ b/.nanvix/test.py @@ -35,6 +35,110 @@ ramfs_mod = load_sibling("ramfs", __file__) +# --------------------------------------------------------------------------- +# .so sanity checks per module group +# --------------------------------------------------------------------------- +# +# Single source of truth for the smoke-test snippets that exercise every +# stdlib extension migrated from .a to .so. Each entry maps a log tag +# (used as the line prefix CPYTHON_TEST_:) to a list of +# (module_name, check_expr) tuples; check_expr is a Python expression +# evaluated with the imported module bound to `m` and must return truthy. + +_SO_MODULE_SANITY_CHECKS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = ( + ( + "CPYTHON_TEST_DATA_PRIMITIVES", + ( + ("_bisect", "m.bisect_left([1, 3, 5], 4) == 2"), + ("_heapq", "m.heappush([], 1) is None"), + ("_struct", "m.pack('i', 42) == b'\\x2a\\x00\\x00\\x00'"), + ("_random", "hasattr(m, 'Random')"), + ("_opcode", "hasattr(m, 'stack_effect')"), + ("_queue", "hasattr(m, 'SimpleQueue')"), + ("_csv", "hasattr(m, 'reader')"), + ("binascii", "m.hexlify(b'\\xab') == b'ab'"), + ("_json", "hasattr(m, 'encode_basestring_ascii')"), + ("_pickle", "hasattr(m, 'Pickler')"), + ("_zoneinfo", "hasattr(m, 'ZoneInfo')"), + ), + ), + ( + "CPYTHON_TEST_MATH", + ( + ("math", "abs(m.sqrt(4.0) - 2.0) < 1e-9"), + ("cmath", "abs(m.sqrt(complex(-1)) - complex(0, 1)) < 1e-9"), + ("_statistics", "hasattr(m, '_normal_dist_inv_cdf')"), + ("mmap", "hasattr(m, 'mmap')"), + ("_contextvars", "hasattr(m, 'ContextVar')"), + ), + ), + ( + "CPYTHON_TEST_CODECS", + ( + ("unicodedata", "m.lookup('LATIN SMALL LETTER A') == 'a'"), + ("_multibytecodec", "hasattr(m, '__create_codec')"), + ("_codecs_cn", "hasattr(m, 'getcodec')"), + ("_codecs_hk", "hasattr(m, 'getcodec')"), + ("_codecs_iso2022", "hasattr(m, 'getcodec')"), + ("_codecs_jp", "hasattr(m, 'getcodec')"), + ("_codecs_kr", "hasattr(m, 'getcodec')"), + ("_codecs_tw", "hasattr(m, 'getcodec')"), + ), + ), + ( + "CPYTHON_TEST_BUNDLED_DEPS", + ( + ("_asyncio", "hasattr(m, 'Future')"), + ("_datetime", "hasattr(m, 'datetime_CAPI')"), + ("_decimal", "m.Decimal('1.1') + m.Decimal('2.2') == m.Decimal('3.3')"), + ("pyexpat", "hasattr(m, 'ParserCreate')"), + ("_elementtree", "hasattr(m, 'XMLParser')"), + ("_md5", "hasattr(m, 'md5')"), + ("_sha1", "hasattr(m, 'sha1')"), + ("_sha2", "hasattr(m, 'sha256')"), + ("_sha3", "hasattr(m, 'sha3_256')"), + ("_blake2", "hasattr(m, 'blake2b')"), + ("select", "hasattr(m, 'select')"), + ("_socket", "hasattr(m, 'socket')"), + ("_posixsubprocess", "hasattr(m, 'fork_exec')"), + ("fcntl", "hasattr(m, 'fcntl')"), + ("termios", "hasattr(m, 'tcgetattr')"), + ), + ), +) + + +def _render_so_sanity_snippets( + checks: tuple[ + tuple[str, tuple[tuple[str, str], ...]], ... + ] = _SO_MODULE_SANITY_CHECKS, +) -> str: + """Emit the Python source that exercises every (module, check_expr) + pair in ``checks`` and prints ``: loaded via + dlopen from `` for each. Each module is asserted NOT to be in + ``sys.builtin_module_names`` so a silent built-in fallback fails + the test (which would mean Setup.local's *shared* declaration was + ignored). + """ + parts: list[str] = [] + for log_tag, modules in checks: + items = ",\n".join( + f" ({name!r}, lambda m: {check})" for name, check in modules + ) + parts.append( + f"_so_checks = [\n{items},\n]\n" + "for _name, _check in _so_checks:\n" + " _mod = __import__(_name)\n" + " assert _name not in sys.builtin_module_names, " + "f'{_name} still built-in!'\n" + " assert _check(_mod), f'{_name} sanity check failed'\n" + f" print(f'{log_tag}: " + "{_name} loaded via dlopen from " + "{_mod.__file__}')\n" + ) + return "".join(parts) + + # --------------------------------------------------------------------------- # Initrd creation helper (standalone mode) # --------------------------------------------------------------------------- @@ -481,6 +585,7 @@ def stage( "print('CPYTHON_TEST_HELLO: Hello from Python', sys.version_info[:2])\n" "print('CPYTHON_TEST_PLATFORM:', sys.platform)\n" + array_snippet + + _render_so_sanity_snippets() + (lxml_snippet if standalone else ""), ) diff --git a/Makefile.nanvix b/Makefile.nanvix index 55636235b10d82..2071a278fdb294 100644 --- a/Makefile.nanvix +++ b/Makefile.nanvix @@ -219,7 +219,7 @@ CONFIGURE_OPTS = \ --with-build-python="$(BUILD_PYTHON)" \ $(if $(filter yes,$(NANVIX_RELEASE)),--disable-test-modules,) \ --with-libc="$(LIBC)" \ - --with-libm="$(LIBM)" \ + --with-libm= \ --prefix="$(INSTALL_PREFIX)" \ --exec-prefix="$(INSTALL_PREFIX)" \ --with-ensurepip=no \ diff --git a/configure b/configure index 01c74597ad05a2..4fbc7ae072f660 100755 --- a/configure +++ b/configure @@ -14038,7 +14038,7 @@ then : else $as_nop LIBEXPAT_CFLAGS="-I\$(srcdir)/Modules/expat" - LIBEXPAT_LDFLAGS="-lm \$(LIBEXPAT_A)" + LIBEXPAT_LDFLAGS="\$(LIBM) \$(LIBEXPAT_A)" LIBEXPAT_INTERNAL="\$(LIBEXPAT_HEADERS) \$(LIBEXPAT_A)" fi @@ -14522,7 +14522,7 @@ then : else $as_nop LIBMPDEC_CFLAGS="-I\$(srcdir)/Modules/_decimal/libmpdec" - LIBMPDEC_LDFLAGS="-lm \$(LIBMPDEC_A)" + LIBMPDEC_LDFLAGS="\$(LIBM) \$(LIBMPDEC_A)" LIBMPDEC_INTERNAL="\$(LIBMPDEC_HEADERS) \$(LIBMPDEC_A)" if test "x$with_pydebug" = xyes diff --git a/configure.ac b/configure.ac index f4bb1dfd3049b7..1b73eee646cf6a 100644 --- a/configure.ac +++ b/configure.ac @@ -3831,7 +3831,7 @@ AS_VAR_IF([with_system_expat], [yes], [ LIBEXPAT_INTERNAL= ], [ LIBEXPAT_CFLAGS="-I\$(srcdir)/Modules/expat" - LIBEXPAT_LDFLAGS="-lm \$(LIBEXPAT_A)" + LIBEXPAT_LDFLAGS="\$(LIBM) \$(LIBEXPAT_A)" LIBEXPAT_INTERNAL="\$(LIBEXPAT_HEADERS) \$(LIBEXPAT_A)" ]) @@ -3915,7 +3915,7 @@ AS_VAR_IF([with_system_libmpdec], [yes], [ LIBMPDEC_INTERNAL= ], [ LIBMPDEC_CFLAGS="-I\$(srcdir)/Modules/_decimal/libmpdec" - LIBMPDEC_LDFLAGS="-lm \$(LIBMPDEC_A)" + LIBMPDEC_LDFLAGS="\$(LIBM) \$(LIBMPDEC_A)" LIBMPDEC_INTERNAL="\$(LIBMPDEC_HEADERS) \$(LIBMPDEC_A)" dnl Disable forced inlining in debug builds, see GH-94847