Skip to content

Size the alternate signal stack from the kernel minimum, and stop discarding the caller's (#495) - #496

Merged
mborland merged 5 commits into
boostorg:developfrom
jamescowens:fix/alt-stack-sizing
Aug 26, 2026
Merged

Size the alternate signal stack from the kernel minimum, and stop discarding the caller's (#495)#496
mborland merged 5 commits into
boostorg:developfrom
jamescowens:fix/alt-stack-sizing

Conversation

@jamescowens

Copy link
Copy Markdown
Contributor

Patch set for #495, as requested. Four commits, each independently reviewable — take or drop them
separately.

commit risk
1 Report the failing condition in BOOST_TEST_SYS_ASSERT none — one token
2 Correct the documented default for p_use_alt_stack none — comment
3 Size the alternate signal stack from the kernel's own minimum the substantive fix
4 Restore the caller's alternate stack instead of disabling it behavioural, see below

1 — BOOST_STRINGIZE( exp )BOOST_STRINGIZE( cond )

The macro parameter is cond; it stringized the literal token exp, so all six call sites in
execution_monitor.ipp produced the same text regardless of which syscall failed. That is why the
report in #495 says exp: Out of memory and names nothing.

2 — documented default

The header documents p_use_alt_stack as defaulting to false; the constructor sets true.

3 — sizing (the fix for #495)

BOOST_TEST_ALT_STACK_SIZE was SIGSTKSZ. glibc 2.34+ made that dynamic, so it tracks the running
kernel; 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 what sigaltstack(2) validates
against. Measured, it tracks XSAVE plus ~940 bytes of signal frame:

CPU XSAVE AT_MINSIGSTKSZ
Xeon E5-2687W v2 (AVX) 832 1776
Core i9-13900K (AVX2) 2696 3632
AMX-capable host ≈11016 11952

~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 the
binary 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 SIGSTKSZ is already dynamic. Verified on glibc: the size chosen is identical
before and after (14528 here), because SIGSTKSZ already exceeds the kernel minimum.

4 — teardown

The install is guarded twice — only when alt_stack is non-null, and only when no alternate stack
is already 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, including a stack the calling
program installed itself.

Demonstrated with a program that installs a 64 KiB stack before main() and inspects it from a
test case:

before:  ss_size 14528   (Boost's own — the caller's was destroyed)
after:   ss_size 65536   (the caller's, intact)

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 then
disables 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=no still does not reach framework::init()'s monitor — the parameter is applied
in unit_test_monitor_t::execute_and_translate, which runs after the command line is parsed. With
commit 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-ts compiles clean against the patched headers (3/3).
  • Header-only probes for both the sizing and the teardown behaviour, results above.
  • getauxval and AT_MINSIGSTKSZ confirmed present under musl in an alpine:edge container —
    musl defines AT_MINSIGSTKSZ itself, so the fallback #define is only for libcs that do not.

I could not run the full b2 self-test suite — no b2 on this machine, and I did not want to
claim a pass I had not observed. The changes are confined to the POSIX alternate-stack path in
execution_monitor.ipp plus 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_MINSIGSTKSZ exceeds 8192; the machines available here report 1776 and 3632. The failing value
of 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.

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 mborland left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the whole this looks ok to me. I have approved the CI run.

Comment thread include/boost/test/impl/execution_monitor.ipp Outdated
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)
@jamescowens

Copy link
Copy Markdown
Contributor Author

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 — linux/auxvec.h has #define AT_MINSIGSTKSZ 51 /* minimal stack size for signal delivery */, itself wrapped in #ifndef. But both glibc and musl already expose it through <sys/auxv.h>, which this code includes anyway:

libc <sys/auxv.h> alone linux/auxvec.h present?
glibc 2.42 AT_MINSIGSTKSZ = 51 yes
musl (alpine:edge) AT_MINSIGSTKSZ = 51 no — Alpine does not ship it with musl-dev, and does not need to

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
#  endif

A libc with the header but not the constant now falls back to SIGSTKSZ, exactly as before this series — the failure mode is "no improvement", not "wrong value".

Verified the feature still enables on both after the change:

glibc   BOOST_TEST_HAS_SYS_AUXV ENABLED   alt_stack_size() = 14528  (SIGSTKSZ 14528, AT_MINSIGSTKSZ 3632)
musl    BOOST_TEST_HAS_SYS_AUXV ENABLED   alt_stack_size() =  8192  (SIGSTKSZ  8192, AT_MINSIGSTKSZ 3632)

Both pick max(SIGSTKSZ, AT_MINSIGSTKSZ). Neither changes on this hardware, since its 3632 is below both — the divergence only appears on a host where the kernel minimum exceeds a hardcoded SIGSTKSZ, which is the 11952-vs-8192 case in #495.

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 b2 self-test suite locally, and I could not reproduce the original abort — it needs musl and a CPU whose AT_MINSIGSTKSZ exceeds 8192, while the machines available to me report 1776 and 3632. Commit 3's effect on an AMX host is arithmetic rather than something I have executed.

@mborland
mborland merged commit 0040e36 into boostorg:develop Aug 26, 2026
103 checks passed
jamescowens added a commit to jamescowens/Gridcoin-Research that referenced this pull request Aug 27, 2026
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.
jamescowens added a commit to jamescowens/Gridcoin-Research that referenced this pull request Aug 27, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants