From cb16db39a8c859f8f9dfd25bceaaca3dad08fcc8 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Fri, 30 Oct 2026 16:57:00 -0300 Subject: [PATCH 1/2] test: the ABI matrix shows an Aether consumer's stderr when it fails (#3481) Main's Linux / GCC leg aborts both Aether-consumer cases of the matrix since 0.861.0, and the log shows only "Aborted (core dumped)": run_app sent the program's stderr to app.err and never printed it. A failed case now prints the last lines of what the program wrote there. --- tests/integration/abi_matrix/test_abi_matrix.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/integration/abi_matrix/test_abi_matrix.sh b/tests/integration/abi_matrix/test_abi_matrix.sh index 6a7e956c6..e3f4878f0 100644 --- a/tests/integration/abi_matrix/test_abi_matrix.sh +++ b/tests/integration/abi_matrix/test_abi_matrix.sh @@ -159,8 +159,13 @@ run_app() { bad "$1: app.ae did not build" "$2/app_build.log" return fi - expect "$1" "$(cd "$2" && PATH="$2:$PATH" LD_LIBRARY_PATH="$2${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ - DYLD_LIBRARY_PATH="$2${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" ./app$exe 2>"$2/app.err" | tr -d '\r')" "$app_want" + got="$(cd "$2" && PATH="$2:$PATH" LD_LIBRARY_PATH="$2${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + DYLD_LIBRARY_PATH="$2${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" ./app$exe 2>"$2/app.err" | tr -d '\r')" + expect "$1" "$got" "$app_want" + # What the program said on stderr: an abort prints nothing on stdout. + if [ "$got" != "$app_want" ] && [ -s "$2/app.err" ]; then + echo " stderr:"; tail -12 "$2/app.err" | sed 's/^/ /' + fi } run_app "this importer, the $old_version library" "$TMP/old" "$ROOT" "$AE" run_app "the $old_version importer, this library" "$TMP/new" "$OLD_HOME" "$OLD_AE" From 8e84f77d7d920b6f6c67b5f2f6259244f65d79dd Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Fri, 30 Oct 2026 17:51:24 -0300 Subject: [PATCH 2/2] fix: a library with a runtime of its own keeps it on Linux and FreeBSD (#3481) Main's Linux / GCC leg failed the cross-version ABI matrix since 0.861.0, both Aether-consumer cases aborting. The stderr the previous commit prints: aether: the library was made for Aether runtime 0.860.0/abi1 and runs on 0.863.0/abi1: rebuild it with the Aether of the runtime it runs on aether: panic outside any try/catch or actor: ... An `ae build` program is linked -rdynamic (std.dl and script_gateway plugins resolve runtime symbols against it), so on ELF it exports its runtime, and a library's calls into the runtime it carries bound to the program's instead. The library's code ran on a runtime it was not made for; across versions the runtime ABI check of #2957 says so and refuses, and the importer ended on the panic. Equal versions (0.860.0 against 0.860.0) and a library older than the check (against 0.859.0) passed, which is why it showed only once main moved past the release. macOS (two-level namespace) and Windows (PE) keep the copies apart and pass, and docs/abi-policy.md says a self-contained library carries its own runtime. The owner chose that model for Linux too. So: - ae links a library with a runtime of its own -Bsymbolic on ELF: its references bind to what it defines, and it still exports them for a C host. A --shared-runtime library defines no runtime and is unchanged. - The import refusal of what needs one runtime (a struct holding strings, a builder using its config, #2931) and the warning that a library's actors are not drained at exit apply on every platform; both name --shared-runtime, as on macOS and Windows. - A library of another runtime ABI linked before this (no DT_SYMBOLIC or DF_SYMBOLIC in its dynamic section, read from the file) is refused at import on ELF, naming the rebuild, instead of ending the program on its first call. The reader was checked on x86_64, aarch64 and 32-bit ARM libraries built with and without -Bsymbolic. Tests: the ABI matrix checks that this ae links SYMBOLIC on ELF, expects the refusal for an old library linked without it, and still runs the old importer with this library (which now keeps its own runtime); binary_import_actors, binary_import_ownership, binary_import_structs and sandbox_nested_libraries expect on Linux and FreeBSD what they expected on macOS and Windows. Docs: emit-lib.md, abi-policy.md and ownership-model.md. Checked here: ae builds with -Werror on Windows and compiles for x86_64 Linux with zig (-Wall -Wextra -Werror); the scripts parse. Linux and FreeBSD behaviour is CI's to show. --- docs/abi-policy.md | 7 +- docs/design/ownership-model.md | 9 +- docs/emit-lib.md | 19 ++-- ...d-3481-library-keeps-its-runtime-on-elf.md | 15 +++ .../integration/abi_matrix/test_abi_matrix.sh | 39 ++++++- .../test_binary_import_actors.sh | 18 ++- .../test_binary_import_ownership.sh | 15 ++- .../binary_import_structs/app_panic.ae | 8 +- .../test_binary_import_structs.sh | 51 ++++----- .../test_sandbox_nested_libraries.sh | 28 ++--- tools/ae.c | 106 ++++++++++++++++-- 11 files changed, 221 insertions(+), 94 deletions(-) create mode 100644 new_changelogs/20261030T170000Z-fixed-3481-library-keeps-its-runtime-on-elf.md diff --git a/docs/abi-policy.md b/docs/abi-policy.md index 540bf1d03..edc84e41d 100644 --- a/docs/abi-policy.md +++ b/docs/abi-policy.md @@ -47,7 +47,10 @@ library). A newer minor whose duties it knows is read. **The runtime ABI.** `AETHER_RUNTIME_ABI_ID` (`runtime/aether_abi.h`) is the Aether version, `/abi` and `AETHER_RUNTIME_ABI_REV`, and the flags that change layouts (`+debug-mailbox`). A self-contained library carries -its own runtime, so this ABI never crosses its edge. A `--shared-runtime` +its own runtime, so this ABI never crosses its edge: on ELF `ae` links it +`-Bsymbolic`, so its runtime calls bind to its own copy, not to the one a +program linked `-rdynamic` exports, as macOS and Windows keep them apart by +themselves (#3481). A `--shared-runtime` library, and C emitted with `--emit=csrc` or `--emit=obj`, must run on the runtime it was made for. The shared runtime reports its ID (`aether_runtime_abi()`), and the catalog of a shared-runtime library @@ -87,7 +90,7 @@ catalogs and libraries compiled for a runtime of another ABI. | Combination | Supported | |---|---| | A C host and a self-contained library of any version | Yes, using the functions of the library's level (read `host_abi`); a host built with older headers uses its own level's | -| An Aether program importing a self-contained library | Yes, when the importer knows every duty the catalog requires; a library of an older version is read by its minor | +| An Aether program importing a self-contained library | Yes, when the importer knows every duty the catalog requires; a library of an older version is read by its minor. On ELF, a library of another runtime ABI linked before #3481 (without `-Bsymbolic`, so it would run on the program's runtime) is refused at import, naming the rebuild | | An importer older than schema 1.11 and a newer library | Calls that succeed work; it cannot read `requires`, so it does not check outcomes, and a failed call reads as its zero value, as with 0.856's own libraries | | Anything on `--shared-runtime` | Only on the runtime it was made for (one runtime ABI ID) | | Two self-contained libraries of different versions in one process | Yes; each runs on its own runtime, and the shared facilities are refused across layouts | diff --git a/docs/design/ownership-model.md b/docs/design/ownership-model.md index bdc3b3011..b47870531 100644 --- a/docs/design/ownership-model.md +++ b/docs/design/ownership-model.md @@ -901,10 +901,11 @@ struct holding a string has no such copy: by value, the program's drop releases the library's strings, and through a typed pointer the program's stores do. A builder that uses its config fills, grows or frees a map the program made, and a factory in the library would not help, since the -trailing block fills the config with the program's runtime. So where the -copies stay apart (macOS, Windows: no ELF interposition) `ae` refuses to -import a static-runtime library whose exports pass such a struct or whose -builders use their config, and names the rebuild with `--shared-runtime`. +trailing block fills the config with the program's runtime. So, since the +copies stay apart on every platform (#3481: a self-contained library binds +to its own runtime on ELF too), `ae` refuses to import a static-runtime +library whose exports pass such a struct or whose builders use their +config, and names the rebuild with `--shared-runtime`. Closures cross either way: an environment carries its own release, which runs in the library that made it. diff --git a/docs/emit-lib.md b/docs/emit-lib.md index b9ea3b2d7..28abff5e0 100644 --- a/docs/emit-lib.md +++ b/docs/emit-lib.md @@ -1254,11 +1254,13 @@ Scope of the current implementation: A library built the default way carries its own copy of the runtime, linked in statically: one scheduler, one set of panic frames, one config per image. That keeps a library self-contained for a C or Python host, but a program -that imports Aether libraries in binary form then runs on several runtimes. -On Linux and FreeBSD ELF symbol interposition happens to bind a library's -runtime calls to the program's copy; macOS's two-level namespace and Windows' -PE keep them apart, so a panic raised inside a library never reaches the -program's `catch` there. +that imports Aether libraries in binary form then runs on several runtimes, +on every platform: a panic raised inside a library never reaches the +program's `catch`. macOS's two-level namespace and Windows' PE keep the +copies apart by themselves; on ELF (Linux, FreeBSD) `ae` links a +self-contained library with `-Bsymbolic`, so its runtime calls bind to its +own copy, not to the one a program linked `-rdynamic` exports (#3481: they +did, and a library made for one release then ran on another's runtime). `--shared-runtime` links against the runtime as a shared library instead, `aether.dll` (with `libaether.dll.a`), `libaether.dylib` or `libaether.so`, @@ -1324,10 +1326,9 @@ ae build app.ae -o app # has none way out, `main()` drains the scheduler and joins it, so a message the library sent fire-and-forget is still delivered. - **Draining needs one runtime.** The drain reaches the library's actors - where the program and the library share one runtime: `--shared-runtime` - everywhere, or ELF interposition with the default static runtime (Linux, - FreeBSD). On macOS and Windows a static library's actors run on that - library's own scheduler. `ae` warns that messages still in flight when the + where the program and the library share one runtime: `--shared-runtime`. + A static library's actors run on that library's own scheduler, on every + platform (#3481). `ae` warns that messages still in flight when the program exits are not delivered, and names the library to rebuild with `--shared-runtime`. - **A C host** calls `scheduler_shutdown()` before it exits to deliver what diff --git a/new_changelogs/20261030T170000Z-fixed-3481-library-keeps-its-runtime-on-elf.md b/new_changelogs/20261030T170000Z-fixed-3481-library-keeps-its-runtime-on-elf.md new file mode 100644 index 000000000..2b653af0b --- /dev/null +++ b/new_changelogs/20261030T170000Z-fixed-3481-library-keeps-its-runtime-on-elf.md @@ -0,0 +1,15 @@ +- **A library with a runtime of its own keeps it on Linux and FreeBSD + (#3481).** An `ae build` program is linked `-rdynamic`, so it exports + its runtime, and on ELF a library's calls into the runtime it carries + bound to the program's instead: its code ran on a runtime it was not + made for. Across versions its runtime ABI check refused, and the program + ended on the panic ("the library was made for Aether runtime 0.860.0/abi1 + and runs on 0.863.0/abi1"), which kept the cross-version ABI matrix red + on Linux since 0.861.0. `ae` now links such a library `-Bsymbolic`, as + macOS and Windows keep the copies apart by themselves, so on every + platform a static-runtime library runs on its own runtime: `ae` refuses + an import that needs one runtime (a struct holding strings, a builder + using its config) and warns that a library's actors are not drained at + exit, naming `--shared-runtime`, as it did on macOS and Windows. A + library of another runtime ABI linked before this is refused at import + on ELF, naming the rebuild, instead of ending the program. diff --git a/tests/integration/abi_matrix/test_abi_matrix.sh b/tests/integration/abi_matrix/test_abi_matrix.sh index e3f4878f0..e38806574 100644 --- a/tests/integration/abi_matrix/test_abi_matrix.sh +++ b/tests/integration/abi_matrix/test_abi_matrix.sh @@ -13,7 +13,10 @@ # a panic in one, an account of the new one suspended while the old one # runs and resumed, both unloaded # Aether consumers (app.ae, a binary import): -# this importer, the old library; the old importer, this library +# this importer, the old library; the old importer, this library. On +# ELF this ae links a library -Bsymbolic, so it keeps its own runtime in +# a program that exports one (#3481); an old library linked without it, +# of another version, is refused at import, naming the rebuild. # The shared runtime: # this host, this library on this shared runtime the baseline # this host, the old library on the old shared runtime @@ -167,7 +170,39 @@ run_app() { echo " stderr:"; tail -12 "$2/app.err" | sed 's/^/ /' fi } -run_app "this importer, the $old_version library" "$TMP/old" "$ROOT" "$AE" +# A library with a runtime of its own linked before #3481 on ELF binds that +# runtime to the program's (an `ae build` program exports its runtime), so it +# would run on this one, of another version: this importer refuses it at +# build, naming the rebuild, instead of the program ending on its first call. +refuse_app() { + cp "$SCRIPT_DIR/app.ae" "$2/app.ae" + if (cd "$2" && AETHER_HOME="$3" "$4" build app.ae -o app > app_build.log 2>&1); then + bad "$1: app.ae built; the import was not refused" "$2/app_build.log" + elif ! grep -q "carries the Aether runtime" "$2/app_build.log" || + ! grep -q -- "--shared-runtime" "$2/app_build.log"; then + bad "$1: the refusal does not say why and name the rebuild" "$2/app_build.log" + fi +} +this_version="$(tr -d '\r\n' < "$ROOT/VERSION" 2>/dev/null)" +# This ae links a library with a runtime of its own -Bsymbolic on ELF, so it +# keeps that runtime in any program (#3481). +if [ "$ext" = so ] && command -v readelf >/dev/null 2>&1 && + ! readelf -d "$TMP/new/libmlib.$ext" 2>/dev/null | grep -q SYMBOLIC; then + bad "this ae linked libmlib.$ext without -Bsymbolic: it would run on an importing program's runtime" +fi +old_binds_own=1 +if [ "$ext" = so ] && [ "$old_version" != "$this_version" ]; then + if ! command -v readelf >/dev/null 2>&1; then + old_binds_own=unknown + elif ! readelf -d "$TMP/old/libmlib.$ext" 2>/dev/null | grep -q SYMBOLIC; then + old_binds_own=0 + fi +fi +case "$old_binds_own" in + 0) refuse_app "this importer, the $old_version library (linked before #3481)" "$TMP/old" "$ROOT" "$AE" ;; + unknown) echo " [NOTE] abi_matrix: no readelf, so this importer with the $old_version library is not run" ;; + *) run_app "this importer, the $old_version library" "$TMP/old" "$ROOT" "$AE" ;; +esac run_app "the $old_version importer, this library" "$TMP/new" "$OLD_HOME" "$OLD_AE" # The shared runtime. $1 = case dir, $2 = library dir, $3 = runtime dir, diff --git a/tests/integration/binary_import_actors/test_binary_import_actors.sh b/tests/integration/binary_import_actors/test_binary_import_actors.sh index 8aa1134ec..ecb0666d1 100644 --- a/tests/integration/binary_import_actors/test_binary_import_actors.sh +++ b/tests/integration/binary_import_actors/test_binary_import_actors.sh @@ -11,10 +11,9 @@ # way out: crowd's slow fire-and-forget message is still delivered. # # Two passes over the same sources: -# static runtime each library carries its own libaether. The actors run -# wherever the importer's calls land; the exit drain reaches -# them only where ELF interposition merges the copies -# (Linux, FreeBSD). Elsewhere `ae` warns that it cannot. +# static runtime each library carries its own libaether and runs its +# actors on its own scheduler, which the program's exit +# does not drain, on every platform (#3481); `ae` warns. # shared runtime `--shared-runtime`: one runtime, one scheduler, and the # program's exit drains it on every platform. set -e @@ -25,10 +24,9 @@ AE="$ROOT/build/ae" [ -x "$AE" ] || [ -x "$AE.exe" ] || { echo " [SKIP] binary_import_actors: ae not built"; exit 0; } case "$(uname -s)" in - MINGW*|MSYS*|CYGWIN*|Windows_NT) SO_EXT=".dll"; ELF=0; EXE=".exe" ;; - Darwin) SO_EXT=".dylib"; ELF=0; EXE="" ;; - Linux|FreeBSD) SO_EXT=".so"; ELF=1; EXE="" ;; - *) SO_EXT=".so"; ELF=0; EXE="" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) SO_EXT=".dll"; EXE=".exe" ;; + Darwin) SO_EXT=".dylib"; EXE="" ;; + *) SO_EXT=".so"; EXE="" ;; esac fail() { @@ -65,7 +63,7 @@ run_pass() { echo "$OUT" | grep -q "^OK" || { echo "$OUT"; fail "$label: the actors did not count to 300"; } if [ "$drains" = 1 ]; then echo "$OUT" | grep -q "^late 7" || { echo "$OUT"; fail "$label: the exit did not drain the library's in-flight message" run.log; } - elif [ "$ELF" = 0 ]; then + else grep -q "runs actors on its own static runtime" run.log \ || fail "$label: no warning that the static runtime's messages are not drained" run.log fi @@ -80,7 +78,7 @@ run_pass() { } # ---- static runtime ---- -run_pass "$WORK/static" "" "static runtime" "$ELF" +run_pass "$WORK/static" "" "static runtime" 0 # ---- shared runtime (where this toolchain built one) ---- if [ -d "$ROOT/build/shared" ]; then diff --git a/tests/integration/binary_import_ownership/test_binary_import_ownership.sh b/tests/integration/binary_import_ownership/test_binary_import_ownership.sh index 13500f894..990254191 100644 --- a/tests/integration/binary_import_ownership/test_binary_import_ownership.sh +++ b/tests/integration/binary_import_ownership/test_binary_import_ownership.sh @@ -11,9 +11,9 @@ # finds nothing on macOS. # 3. A struct holding a string, returned by value, and a builder that frees # its config. Neither can be copied between two copies of the runtime, so -# they need one: the shared runtime, or the static one where ELF -# interposition merges the copies (Linux, FreeBSD). There they run and -# grow 0; elsewhere the import is refused, naming the rebuild. +# they need one, the shared runtime: with the static one each library +# keeps its own copy (on every platform since #3481), and the import is +# refused, naming the rebuild. On the shared runtime they run and grow 0. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" @@ -22,10 +22,9 @@ AE="$ROOT/build/ae" CC="${CC:-cc}" case "$(uname -s)" in - MINGW*|MSYS*|CYGWIN*|Windows_NT) SO_EXT=".dll"; ELF=0; EXE=".exe" ;; - Darwin) SO_EXT=".dylib"; ELF=0; EXE="" ;; - Linux|FreeBSD) SO_EXT=".so"; ELF=1; EXE="" ;; - *) SO_EXT=".so"; ELF=0; EXE="" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) SO_EXT=".dll"; EXE=".exe" ;; + Darwin) SO_EXT=".dylib"; EXE="" ;; + *) SO_EXT=".so"; EXE="" ;; esac LEAKS=0 if [ "$(uname -s)" = Darwin ] && command -v leaks >/dev/null 2>&1; then LEAKS=1; fi @@ -102,7 +101,7 @@ run_pass() { cd "$WORK" } -run_pass "$WORK/static" "" "static runtime" "$ELF" +run_pass "$WORK/static" "" "static runtime" 0 # The C host, against the static library. cd "$WORK/static" diff --git a/tests/integration/binary_import_structs/app_panic.ae b/tests/integration/binary_import_structs/app_panic.ae index 4b9c8d614..d0e8f6eff 100644 --- a/tests/integration/binary_import_structs/app_panic.ae +++ b/tests/integration/binary_import_structs/app_panic.ae @@ -1,8 +1,8 @@ // A panic inside a binary-imported library reaches the host's catch -// (#2297). Holds where the host and the library share one runtime: on ELF -// (Linux, FreeBSD) the library's runtime symbols bind to the host's by -// interposition. The test script runs this only there; macOS and Windows -// keep a runtime per image until #2297's shared-runtime work lands. +// (#2297). Holds where the host and the library share one runtime: a +// library built with --shared-runtime, which is how the test script runs +// it. A library with a runtime of its own keeps it on every platform +// (#3481), so its panic stays in it. import engine diff --git a/tests/integration/binary_import_structs/test_binary_import_structs.sh b/tests/integration/binary_import_structs/test_binary_import_structs.sh index e082d45ea..55394234d 100644 --- a/tests/integration/binary_import_structs/test_binary_import_structs.sh +++ b/tests/integration/binary_import_structs/test_binary_import_structs.sh @@ -10,14 +10,11 @@ # and a function taking or returning a struct was not exported at all. # # Two passes over the same sources: -# static runtime each library carries its own copy of libaether. Where -# ELF interposition merges the copies (Linux, FreeBSD), a -# second binary library (script) importing the engine, and -# the host importing both, share the engine's one module -# state, and a panic in the engine reaches the host's -# catch. Elsewhere the copies stay apart and neither may -# free the other's strings, so importing the engine, whose -# *Model holds one, is refused, naming the rebuild (#2931). +# static runtime each library carries its own copy of libaether, and on +# every platform (#3481) the copies stay apart: neither +# may free the other's strings, so importing the engine, +# whose *Model holds one, is refused, naming the rebuild +# (#2931). # shared runtime `--shared-runtime`: the libraries link aether.dll / # libaether.so / libaether.dylib, their catalogs say so # (schema 1.5), and the host follows. One runtime, so the @@ -30,10 +27,9 @@ AE="$ROOT/build/ae" [ -x "$AE" ] || [ -x "$AE.exe" ] || { echo " [SKIP] binary_import_structs: ae not built"; exit 0; } case "$(uname -s)" in - MINGW*|MSYS*|CYGWIN*|Windows_NT) SO_EXT=".dll"; ELF=0; EXE=".exe" ;; - Darwin) SO_EXT=".dylib"; ELF=0; EXE="" ;; - Linux|FreeBSD) SO_EXT=".so"; ELF=1; EXE="" ;; - *) SO_EXT=".so"; ELF=0; EXE="" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) SO_EXT=".dll"; EXE=".exe" ;; + Darwin) SO_EXT=".dylib"; EXE="" ;; + *) SO_EXT=".so"; EXE="" ;; esac fail() { @@ -88,25 +84,20 @@ panic_check() { } # ---- static runtime ---- -if [ "$ELF" = 1 ]; then - run_pass "$WORK/static" "" "static runtime" - panic_check "$WORK/static" "static runtime" -else - mkdir -p "$WORK/static" - cp "$SCRIPT_DIR/engine.ae" "$SCRIPT_DIR/app.ae" "$WORK/static/" - cd "$WORK/static" - AETHER_HOME="$ROOT" "$AE" build --emit=lib engine.ae -o "libengine$SO_EXT" >engine.log 2>&1 \ - || fail "static runtime: ae build --emit=lib engine.ae" engine.log - rm -f engine.ae - if AETHER_HOME="$ROOT" "$AE" run app.ae >run.log 2>&1; then - fail "static runtime: importing a library whose *Model holds a string was not refused" run.log - fi - grep -q "model_new\` passes \*Model, which holds strings" run.log \ - || fail "static runtime: the refusal does not name model_new and *Model" run.log - grep -q "ae build --emit=lib --shared-runtime" run.log \ - || fail "static runtime: the refusal does not name the rebuild" run.log - cd "$WORK" +mkdir -p "$WORK/static" +cp "$SCRIPT_DIR/engine.ae" "$SCRIPT_DIR/app.ae" "$WORK/static/" +cd "$WORK/static" +AETHER_HOME="$ROOT" "$AE" build --emit=lib engine.ae -o "libengine$SO_EXT" >engine.log 2>&1 \ + || fail "static runtime: ae build --emit=lib engine.ae" engine.log +rm -f engine.ae +if AETHER_HOME="$ROOT" "$AE" run app.ae >run.log 2>&1; then + fail "static runtime: importing a library whose *Model holds a string was not refused" run.log fi +grep -q "model_new\` passes \*Model, which holds strings" run.log \ + || fail "static runtime: the refusal does not name model_new and *Model" run.log +grep -q "ae build --emit=lib --shared-runtime" run.log \ + || fail "static runtime: the refusal does not name the rebuild" run.log +cd "$WORK" # ---- shared runtime (where this toolchain built one) ---- if [ -d "$ROOT/build/shared" ]; then diff --git a/tests/integration/sandbox_nested_libraries/test_sandbox_nested_libraries.sh b/tests/integration/sandbox_nested_libraries/test_sandbox_nested_libraries.sh index e81c0bfda..ef5d426bb 100644 --- a/tests/integration/sandbox_nested_libraries/test_sandbox_nested_libraries.sh +++ b/tests/integration/sandbox_nested_libraries/test_sandbox_nested_libraries.sh @@ -12,10 +12,9 @@ # # 1. one runtime: both libraries' C linked into the host; # 2. each library built the default way, with its own copy of the runtime: -# on macOS and Windows the copies stay apart, so the inner one cannot read -# the outer block's grants; it refuses, and its message names -# --shared-runtime (on Linux and FreeBSD ELF interposition binds both to -# one copy: the same answers, no message); +# the copies stay apart (on every platform since #3481), so the inner one +# cannot read the outer block's grants; it refuses, and its message names +# --shared-runtime; # 2c. the outer library linked into a program with a runtime of its own # that exports nothing and looks first (#3445); # 2d. a program with the panic and no sandbox, -rdynamic on Linux, and a @@ -37,10 +36,10 @@ fail=0 want="before 1 inner 0 granted 0 outer 0 after 1" case "$(uname -s 2>/dev/null)" in - MINGW*|MSYS*|CYGWIN*|Windows_NT) ext=dll; apart=1; dl="" ;; - Darwin) ext=dylib; apart=1; dl="" ;; - Linux) ext=so; apart=0; dl="-ldl" ;; - *) ext=so; apart=0; dl="" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) ext=dll; dl="" ;; + Darwin) ext=dylib; dl="" ;; + Linux) ext=so; dl="-ldl" ;; + *) ext=so; dl="" ;; esac # 1. one runtime @@ -95,7 +94,7 @@ if [ "$got" != "$want" ]; then head -5 "$TMP/apart.err" | sed 's/^/ /' fail=1 fi -if [ "$apart" = 1 ] && ! grep -q -- "--shared-runtime" "$TMP/apart.err"; then +if ! grep -q -- "--shared-runtime" "$TMP/apart.err"; then echo " [FAIL] sandbox_nested_libraries: a runtime per library: the refusal does not name --shared-runtime" head -5 "$TMP/apart.err" | sed 's/^/ /' fail=1 @@ -136,10 +135,11 @@ fi # 2d. a program with the panic of a runtime of its own and no sandbox, # linked -rdynamic on Linux as `ae build` links one, loading a library with a # runtime of its own whose enforce block catches a panic raised in a nested -# block (#3438): under ELF interposition the library runs the program's panic -# and try frames, so its sandbox must set the program's pointers. With them -# each copy's own, the catch left the nested level in force (PATH 0) and the -# block's end the outer one (probe 0 after it). +# block (#3438). Before #3481 the library ran the program's panic and try +# frames there (ELF interposition), so its sandbox had to set the program's +# pointers; now it runs its own on every platform, and the catch must still +# leave neither the nested level (PATH 0) nor, at the block's end, the outer +# one (probe 0 after it) in force. rdyn="" case "$(uname -s 2>/dev/null)" in Linux|FreeBSD) rdyn="-rdynamic" ;; esac # shellcheck disable=SC2046,SC2086 @@ -198,6 +198,6 @@ else fi if [ "$fail" = 0 ]; then - echo " [PASS] sandbox_nested_libraries: an inner library's export inside an outer enforce is held to it, its own grant_all narrows nothing, and the outer block stays checked: one runtime, a runtime per library (refused where the copies stay apart), a program whose copy looked first and exports nothing, a panic caught inside the block of a library that runs the program's panic$shared_note" + echo " [PASS] sandbox_nested_libraries: an inner library's export inside an outer enforce is held to it, its own grant_all narrows nothing, and the outer block stays checked: one runtime, a runtime per library (refused, naming --shared-runtime), a program whose copy looked first and exports nothing, a panic caught inside the block of a library loaded by a program linked -rdynamic$shared_note" fi exit $fail diff --git a/tools/ae.c b/tools/ae.c index 9d973b559..4bcb71e8a 100644 --- a/tools/ae.c +++ b/tools/ae.c @@ -498,6 +498,62 @@ static const char* const k_ae_import_duties[] = { #define AE_CATALOG_MINOR_KNOWN 11 +#if !defined(_WIN32) && !defined(__APPLE__) +/* An unsigned field of n bytes of an ELF file, in its byte order. */ +static uint64_t ae_elf_field(const unsigned char* p, int n, int big) { + uint64_t v = 0; + for (int i = 0; i < n; i++) v |= (uint64_t)p[big ? n - 1 - i : i] << (8 * i); + return v; +} + +/* #3481: 1 when the ELF library at `path` binds the definitions it holds to + * itself (DT_SYMBOLIC, or DF_SYMBOLIC in DT_FLAGS: linked -Bsymbolic, as + * `ae` links a library with a runtime of its own since #3481), 0 when it + * does not, -1 when it cannot be read. One that does not binds the runtime + * it carries to the program's when the program exports one (an `ae build` + * program is linked -rdynamic), and so runs on the program's runtime. Read + * from the file's headers and dynamic segment. */ +static int ae_elf_binds_own(const char* path) { + FILE* f = fopen(path, "rb"); + if (!f) return -1; + unsigned char eh[64]; + int r = -1; + size_t got = fread(eh, 1, sizeof(eh), f); + if (got >= 52 && memcmp(eh, "\x7f" "ELF", 4) == 0 && (eh[4] == 1 || eh[4] == 2)) { + int is64 = eh[4] == 2, big = eh[5] == 2; + uint64_t phoff = is64 ? ae_elf_field(eh + 32, 8, big) : ae_elf_field(eh + 28, 4, big); + int phentsize = (int)ae_elf_field(eh + (is64 ? 54 : 42), 2, big); + int phnum = (int)ae_elf_field(eh + (is64 ? 56 : 44), 2, big); + unsigned char ph[56]; + for (int i = 0; i < phnum && phentsize >= (is64 ? 56 : 32) && r < 0; i++) { + if (fseek(f, (long)(phoff + (uint64_t)i * (uint64_t)phentsize), SEEK_SET) != 0 || + fread(ph, 1, is64 ? 56 : 32, f) != (size_t)(is64 ? 56 : 32)) + break; + if (ae_elf_field(ph, 4, big) != 2) continue; /* PT_DYNAMIC */ + uint64_t off = is64 ? ae_elf_field(ph + 8, 8, big) : ae_elf_field(ph + 4, 4, big); + uint64_t size = is64 ? ae_elf_field(ph + 32, 8, big) : ae_elf_field(ph + 16, 4, big); + int ent = is64 ? 16 : 8; + if (size > (1u << 20) || fseek(f, (long)off, SEEK_SET) != 0) break; + unsigned char* dyn = (unsigned char*)malloc(size ? size : 1); + if (!dyn) break; + if (fread(dyn, 1, size, f) == size) { + r = 0; + for (uint64_t k = 0; k + (uint64_t)ent <= size; k += (uint64_t)ent) { + uint64_t tag = ae_elf_field(dyn + k, ent / 2, big); + uint64_t val = ae_elf_field(dyn + k + ent / 2, ent / 2, big); + if (tag == 0) break; /* DT_NULL */ + if (tag == 16) r = 1; /* DT_SYMBOLIC */ + if (tag == 30 && (val & 0x2)) r = 1; /* DT_FLAGS, DF_SYMBOLIC */ + } + } + free(dyn); + } + } + fclose(f); + return r; +} +#endif + /* A library's catalog against this importer (#2957): 0 when it can be used; * otherwise each reason on stderr, naming what is missing, both versions and * the fix, and -1. Called before anything of the library is used. */ @@ -543,6 +599,22 @@ static int ae_lib_check_compat(const _AeLibInfoMeta* m, const char* so_path) { so_path, m->runtime_abi ? m->runtime_abi : "(none)", AETHER_RUNTIME_ABI_ID); refused = 1; } +#if !defined(_WIN32) && !defined(__APPLE__) + /* #3481: a library with a runtime of its own, linked before `ae` bound + * one to itself on ELF, binds to the program's (an `ae build` program + * exports its runtime) and so runs on a runtime of another version than + * the one it was made for: its first call would refuse, and the program + * end on the panic. Refused here instead, naming the rebuild. */ + else if (!shared && minor >= 11 && m->runtime_abi && + strcmp(m->runtime_abi, AETHER_RUNTIME_ABI_ID) != 0 && ae_elf_binds_own(so_path) == 0) { + fprintf(stderr, "Error: %s carries the Aether runtime %s, but was linked by Aether %s to run on\n" + " the program's runtime instead, and this program's is %s.\n" + " Rebuild the library with this Aether (it then keeps its own runtime), or with\n" + " `ae build --emit=lib --shared-runtime`.\n", + so_path, m->runtime_abi, built_by, AETHER_RUNTIME_ABI_ID); + refused = 1; + } +#endif return refused ? -1 : 0; } @@ -5107,7 +5179,23 @@ void build_gcc_cmd(char* cmd, size_t size, * weak C main() for whoever links the OBJECT into an executable (see * emit_lib_weak_main in codegen.c); a shared library ae links itself has * no use for one, and should not export an entry point. */ + /* #3481: a library that carries its own runtime runs on it. On ELF a + * program linked -rdynamic exports its runtime, and the library's calls + * into its own copy bound to the program's instead (interposition): the + * library's code ran on a runtime it was not made for, of another + * version across releases, which its runtime ABI check refuses. + * -Bsymbolic binds a library's references to what it defines itself, + * as macOS's two-level namespace and Windows' PE always have; it still + * exports them, for a C host. A --shared-runtime library defines no + * runtime of its own, and keeps binding to the one it shares. */ +#if !defined(__APPLE__) + const char* emit_lib_flags = (g_emit_lib && !g_emit_exe) + ? (g_shared_runtime ? "-fPIC -shared -DAETHER_NO_LIB_MAIN " + : "-fPIC -shared -Wl,-Bsymbolic -DAETHER_NO_LIB_MAIN ") + : ""; +#else const char* emit_lib_flags = (g_emit_lib && !g_emit_exe) ? "-fPIC -shared -DAETHER_NO_LIB_MAIN " : ""; +#endif // Coverage builds skip -pipe — gcov works fine with it, but it // adds nothing when -O0 -g is already forced. Keeping the flag // string short helps the cmd-buffer size budget. @@ -5753,11 +5841,11 @@ static const char* ae_signature_string_struct(const _AeLibInfoMeta* m, int minor * values. A string crosses as a copy; a struct holding strings (by value or * through a typed pointer, where the program's stores release the * library's strings) and a builder's config the library uses have no copy, - * so where the library keeps its own runtime (no ELF interposition) such an - * import is refused, naming the rebuild that gives both one runtime. */ + * so a library that keeps its own runtime (every platform since #3481: on + * ELF it no longer binds to the program's) has such an import refused, + * naming the rebuild that gives both one runtime. */ static void ae_refuse_split_ownership(const _AeLibInfoMeta* m, int minor, const char* module, const char* so_path) { -#if defined(_WIN32) || defined(__APPLE__) char ty[200]; int refused = 0; for (int i = 0; i < m->function_count && m->functions; i++) { @@ -5799,9 +5887,6 @@ static void ae_refuse_split_ownership(const _AeLibInfoMeta* m, int minor, const "on the program's runtime.\n"); exit(1); } -#else - (void)m; (void)minor; (void)module; (void)so_path; -#endif } /* `module` NULL: the whole catalog (a bare import of a one-module @@ -6492,10 +6577,10 @@ static void prepare_binary_imports(const char* main_file) { g_binimport_static_names, g_binimport_static_rt > 1 ? "were" : "was", g_binimport_static_rt > 1 ? "them" : "it"); } -#if defined(_WIN32) || defined(__APPLE__) - /* Without ELF interposition, a static-runtime library's actors run on - * the library's own scheduler, which the program's exit does not drain: - * a message still in flight when main() returns is not delivered. */ + /* A static-runtime library's actors run on the library's own scheduler + * (on every platform since #3481), which the program's exit does not + * drain: a message still in flight when main() returns is not + * delivered. */ if (g_binimport_static_actor_names[0]) { fprintf(stderr, "Warning: %s runs actors on its own static runtime, so messages still in flight\n" @@ -6503,7 +6588,6 @@ static void prepare_binary_imports(const char* main_file) { " `ae build --emit=lib --shared-runtime` to run them on the program's.\n", g_binimport_static_actor_names); } -#endif } /* #2297: Windows has no rpath. After a link that names DLLs (binary imports,