Size the alternate signal stack from the kernel minimum, and stop discarding the caller's (#495) - #496
Conversation
The macro takes `cond` but stringizes the literal token `exp`, so every one
of its call sites produces the same text regardless of what failed:
system_error produced by: exp: Out of memory
All six uses in execution_monitor.ipp are sigaction/sigaltstack/signal calls,
and the message identifies none of them. Stringize the parameter.
The documentation says the flag defaults to false; execution_monitor's constructor initialises it to true. The true default is why the alternate stack is installed on paths that never set the property.
BOOST_TEST_ALT_STACK_SIZE is SIGSTKSZ. glibc 2.34+ made that dynamic --
sysconf(_SC_SIGSTKSZ) -- so it tracks what the running kernel requires.
Other C libraries still define it as a compile time constant: musl uses
8192.
The kernel derives its real minimum from the CPU's XSAVE area and
publishes it as auxv AT_MINSIGSTKSZ, and that is the figure sigaltstack(2)
validates against. Measured, it tracks the XSAVE size plus ~940 bytes of
signal frame:
Xeon E5-2687W v2 (AVX) XSAVE 832 -> AT_MINSIGSTKSZ 1776
Core i9-13900K (AVX2) XSAVE 2696 -> AT_MINSIGSTKSZ 3632
AMX-capable host -> AT_MINSIGSTKSZ 11952
~11 KB is reached once AMX tile state (8192 bytes) sits on top of
AVX-512's ~2.7 KB, i.e. Sapphire Rapids and later. On such a host under
musl the kernel minimum exceeds the hardcoded 8192, sigaltstack fails with
ENOMEM, and the test binary aborts before running anything:
Test setup error: system_error produced by: exp: Out of memory
Prefer AT_MINSIGSTKSZ where it is available, and never go below
MINSIGSTKSZ. Verified under musl in an Alpine container that getauxval is
present and returns the expected value; musl also defines AT_MINSIGSTKSZ
itself, so the fallback define is only for libcs that do not.
No effect where SIGSTKSZ is already dynamic: on glibc the value chosen is
identical before and after, because SIGSTKSZ there already exceeds the
kernel minimum.
The install is guarded twice: it happens only when alt_stack is non-null,
and only when no alternate stack is currently active. The teardown is not
guarded at all -- it runs whenever BOOST_TEST_USE_ALT_STACK is compiled in,
and unconditionally disables whatever is installed.
So a program that installs its own alternate signal stack before invoking
Boost.Test loses it at the end of the first monitored scope, and never gets
it back. That is caller state the library did not create.
Demonstrated with a test that installs a 64 KiB stack before main() and
checks it from a test case:
before this change ss_size 14528, i.e. Boost's own (check fails)
after ss_size 65536, the caller's (check passes)
The sequence is: the caller installs; Boost's first handler queries, sees
an active stack and correctly skips its install; the matching destructor
then disables it anyway; every later scope finds nothing active and gets
Boost's own.
Record whether this handler installed the stack, and restore what was
there rather than blanket-disabling. Where Boost did install, the saved
value is a disabled stack in every case reachable today -- the install
only runs when SS_DISABLE was set -- so behaviour is unchanged for
programs that install nothing.
mborland
left a comment
There was a problem hiding this comment.
On the whole this looks ok to me. I have approved the CI run.
Review feedback on boostorg#496: the fallback '#define AT_MINSIGSTKSZ 51' was a magic number with no provenance. It is kernel ABI, from linux/auxvec.h, but it does not need restating here at all. Both glibc and musl expose the constant through <sys/auxv.h>, which this code already includes -- checked on glibc 2.42 and on musl in an alpine:edge container, both report 51 from that header alone. Alpine does not even ship linux/auxvec.h with musl-dev, and does not need to. So gate the feature on the constant being provided instead of supplying it. A libc with the header but not the constant now falls back to SIGSTKSZ, exactly as before this series, rather than compiling against a value this header invented. Verified after the change that the feature still enables on both: glibc alt_stack_size() = 14528 (SIGSTKSZ 14528, AT_MINSIGSTKSZ 3632) musl alt_stack_size() = 8192 (SIGSTKSZ 8192, AT_MINSIGSTKSZ 3632)
|
Thanks for the review and for approving the CI run. You are right that it was a magic number, and checking where it came from turned up a better answer than documenting it: the constant does not need to be here at all. It is kernel ABI —
So the fallback was dead code on every platform this path actually runs on, and on any other libc it would have been this header inventing a value it has no business asserting. Latest commit drops it and gates the feature on the constant being provided instead: # if defined(__linux__) && defined(__has_include)
# if __has_include(<sys/auxv.h>)
# include <sys/auxv.h>
# ifdef AT_MINSIGSTKSZ
# define BOOST_TEST_HAS_SYS_AUXV
# endif
# endif
# endifA libc with the header but not the constant now falls back to Verified the feature still enables on both after the change: Both pick One caveat I flagged in the PR description and will repeat here, since it bears on how much weight to put on a green board: I could not run the |
Two suppressed findings from the second Copilot round, both correct. The initialiser installed unconditionally. A sanitizer runtime or a crash handler may have installed an alternate stack before this runs, and overwriting it would break whatever relies on it. That is the same defect this series fixes in Boost upstream (boostorg/test#496), where the signal_handler destructor disables a stack it never installed -- so committing it here while fixing it there would be poor form. Query first. Where an adequate stack is already present, leave it and record that we deferred. The one case worth overriding is an existing stack smaller than the kernel's minimum, since that is the failure this file exists to prevent and deferring to it would mean deferring to a setting that cannot work. Verified both branches rather than the common one only. With nothing pre-installed: query returns SS_DISABLE, we install 22720. With a probe forced ahead of us via init_priority installing 256 KiB: we query, see it, and install nothing -- our install count is zero. Second finding: report.requested and report.kernel_minimum were populated only after resize() and sigaltstack(). If resize() threw, the failure said "rejected our alternate stack of 0 bytes" -- describing the uninitialised struct rather than what was attempted. They are now recorded as soon as the size is computed, before anything that can throw. That message was in the output of the forced-throw check used to verify the exception guard in the previous commit, and I read past the zero.
Two suppressed findings from the second Copilot round, both correct. The initialiser installed unconditionally. A sanitizer runtime or a crash handler may have installed an alternate stack before this runs, and overwriting it would break whatever relies on it. That is the same defect this series fixes in Boost upstream (boostorg/test#496), where the signal_handler destructor disables a stack it never installed -- so committing it here while fixing it there would be poor form. Query first. Where an adequate stack is already present, leave it and record that we deferred. The one case worth overriding is an existing stack smaller than the kernel's minimum, since that is the failure this file exists to prevent and deferring to it would mean deferring to a setting that cannot work. Verified both branches rather than the common one only. With nothing pre-installed: query returns SS_DISABLE, we install 22720. With a probe forced ahead of us via init_priority installing 256 KiB: we query, see it, and install nothing -- our install count is zero. Second finding: report.requested and report.kernel_minimum were populated only after resize() and sigaltstack(). If resize() threw, the failure said "rejected our alternate stack of 0 bytes" -- describing the uninitialised struct rather than what was attempted. They are now recorded as soon as the size is computed, before anything that can throw. That message was in the output of the forced-throw check used to verify the exception guard in the previous commit, and I read past the zero.
Patch set for #495, as requested. Four commits, each independently reviewable — take or drop them
separately.
BOOST_TEST_SYS_ASSERTp_use_alt_stack1 —
BOOST_STRINGIZE( exp )→BOOST_STRINGIZE( cond )The macro parameter is
cond; it stringized the literal tokenexp, so all six call sites inexecution_monitor.ippproduced the same text regardless of which syscall failed. That is why thereport in #495 says
exp: Out of memoryand names nothing.2 — documented default
The header documents
p_use_alt_stackas defaulting tofalse; the constructor setstrue.3 — sizing (the fix for #495)
BOOST_TEST_ALT_STACK_SIZEwasSIGSTKSZ. glibc 2.34+ made that dynamic, so it tracks the runningkernel; musl still defines it as a constant, 8192. The kernel's real minimum comes from the CPU's
XSAVE area and is published as auxv
AT_MINSIGSTKSZ— and that is whatsigaltstack(2)validatesagainst. Measured, it tracks XSAVE plus ~940 bytes of signal frame:
AT_MINSIGSTKSZ~11 KB is reached once AMX tile state (8192 B) sits on AVX-512's ~2.7 KB, i.e. Sapphire Rapids and
later. Under musl on such a host the minimum exceeds 8192, the install fails with
ENOMEM, and thebinary aborts before running anything. Because it depends on which CPU a CI job lands on, it
presents as an intermittent failure that becomes more common as fleets refresh onto newer
silicon.
Guarded on
__has_include(<sys/auxv.h>), so no new hard dependency.No effect where
SIGSTKSZis already dynamic. Verified on glibc: the size chosen is identicalbefore and after (14528 here), because
SIGSTKSZalready exceeds the kernel minimum.4 — teardown
The install is guarded twice — only when
alt_stackis non-null, and only when no alternate stackis already active. The teardown is not guarded at all: it runs whenever
BOOST_TEST_USE_ALT_STACKis compiled in and unconditionally disables whatever is installed, including a stack the calling
program installed itself.
Demonstrated with a program that installs a 64 KiB stack before
main()and inspects it from atest case:
This is also why the natural workaround for #495 only half works: pre-installing an adequately
sized stack does make
framework::init()skip its install, but the matching destructor thendisables it, so every later test case gets Boost's own
SIGSTKSZ-sized one.Where Boost did install, the saved value is a disabled stack in every case reachable today, so
behaviour is unchanged for programs that install nothing themselves.
Not included
--use_alt_stack=nostill does not reachframework::init()'s monitor — the parameter is appliedin
unit_test_monitor_t::execute_and_translate, which runs after the command line is parsed. Withcommit 3 that no longer causes a failure, so I have left it alone: whether the switch should cover
initialisation, or the documentation should say it does not, seemed a design call for you rather
than something to decide in a bug fix.
Verification, and its limits
execution_monitor-tscompiles clean against the patched headers (3/3).getauxvalandAT_MINSIGSTKSZconfirmed present under musl in analpine:edgecontainer —musl defines
AT_MINSIGSTKSZitself, so the fallback#defineis only for libcs that do not.I could not run the full b2 self-test suite — no
b2on this machine, and I did not want toclaim a pass I had not observed. The changes are confined to the POSIX alternate-stack path in
execution_monitor.ippplus two comment/token fixes, but a CI run is the real check.I could not reproduce the original abort directly. It needs musl and a CPU whose
AT_MINSIGSTKSZexceeds 8192; the machines available here report 1776 and 3632. The failing valueof 11952 comes from a CI runner. Commit 3's effect on such a host is arithmetic — it selects 11952
where the current code selects 8192 — rather than something I have executed.