Skip to content

fix(hal): let the build system, not the preprocessor, pick the HAL backend - #102

Closed
Kartikey1306 wants to merge 7 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/hal-backend-selection
Closed

Kartikey1306 wants to merge 7 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/hal-backend-selection

Conversation

@Kartikey1306

@Kartikey1306 Kartikey1306 commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Which backend source CMake compiles, and which registrar eos_hal_init() calls, are the same decision made in two places with two different conditions.

# CMakeLists.txt
if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
    target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
else()
    target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)
/* hal/src/hal_common.c */
#ifdef __linux__
    eos_hal_linux_register();
#else
    eos_hal_rtos_register();

They agree on Linux and on a cross build, and disagree on every other host. A macOS host build compiles hal_linux.c and then references a symbol from the file it did not compile:

Undefined symbols for architecture arm64:
  "_eos_hal_rtos_register", referenced from: _eos_hal_init in libeos_hal.a

The change

CMake sets EOS_HAL_BACKEND_HOST or EOS_HAL_BACKEND_RTOS right next to the target_sources() call that makes the choice, and hal_common.c keys off that. __linux__ stays as a fallback for anyone compiling these files without CMake.

Two things had to move with it:

  • hal.h declared the registrars under the same #ifdef, so hal_common.c could not even name the one CMake had compiled. Both are declared unconditionally now — exactly one is still defined per build, and an unreferenced declaration costs nothing. The header's own comment already argued for this: "an undeclared registrar is one nobody can find."
  • hal_linux.c wraps its whole body in #ifdef __linux__, so on macOS it compiled to an empty translation unit and the build had no backend at all. It is the host backend rather than the Linux one — the only platform calls in it are clock_gettime() and nanosleep(), both POSIX. hal_rtos.c takes the mirror condition.

Linux is unchanged

EOS_HAL_BACKEND_HOST and __linux__ are both defined there, so the same registrar is selected as before. Cross builds get EOS_HAL_BACKEND_RTOS and are likewise unchanged. master CI is green on Linux and stays that way — this is not a fix for CI, it is a fix for an inconsistency CI cannot see.

Scope — stated plainly

This does not make macOS a supported host on its own.

before after
eos_hal link fails links
ctest on macOS 0/28 19/28

The remaining 9 failures are services/compat/src/os_adapter_posix.c calling pthread_mutex_timedlock() and sem_timedwait(), neither of which macOS provides. That is separate work in a different subsystem and I have not attempted it here — flagging it rather than half-doing it.

…ckend

Which backend source CMake compiles and which registrar eos_hal_init() calls
are the same decision made in two places, with two different conditions.

CMakeLists.txt:

    if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
        target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
    else()
        target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)

hal_common.c:

    #ifdef __linux__
        eos_hal_linux_register();
    #else
        eos_hal_rtos_register();

They agree on Linux and on a cross build, and disagree on every other host. A
macOS host build compiles hal_linux.c, then references a symbol from the file
it did not compile:

    Undefined symbols for architecture arm64:
      "_eos_hal_rtos_register", referenced from: _eos_hal_init

CMake now sets EOS_HAL_BACKEND_HOST or EOS_HAL_BACKEND_RTOS next to the
target_sources() call that makes the choice, and hal_common.c keys off that.
__linux__ stays as a fallback for anyone compiling these files without CMake.

Two things had to move with it:

- hal.h declared the registrars under the same #ifdef, so hal_common.c could
  not even name the one CMake had compiled. Both are declared unconditionally
  now; exactly one is still defined per build, and an unreferenced declaration
  costs nothing. The header's own comment already argued for this -- "an
  undeclared registrar is one nobody can find".
- hal_linux.c wraps its whole body in #ifdef __linux__, so on macOS it compiled
  to an empty translation unit and the build had no backend at all. It is the
  host backend rather than the Linux one: the only platform calls in it are
  clock_gettime() and nanosleep(), both POSIX. Guarded on the CMake define,
  with __linux__ as the fallback. hal_rtos.c takes the mirror condition.

Linux behaviour is unchanged: EOS_HAL_BACKEND_HOST and __linux__ are both
defined there, and the same registrar is selected as before. Cross builds get
EOS_HAL_BACKEND_RTOS and are likewise unchanged. eos master CI is green on
Linux and stays that way.

Verified on macOS: the eos_hal link error is gone and ctest goes from 0/28 to
19/28.

This does NOT make macOS a supported host on its own. The remaining 9 failures
are services/compat/src/os_adapter_posix.c calling pthread_mutex_timedlock()
and sem_timedwait(), neither of which macOS provides. That is a separate piece
of work in a different subsystem and I have not attempted it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kartikey1306 and others added 6 commits August 31, 2026 22:49
With -DEBLDR_BOARD=qemu_arm64 actually taking effect (it was -DBOARD= before
and silently ignored), eBoot's configure now runs the board path and refuses:

    EBLDR_VERIFY_STAGE1 is ON but board qemu_arm64 has no stage-1 linker
    script, so there is no stage-1 image to hash. Configure with
    -DEBLDR_VERIFY_STAGE1=OFF to build stage-0 without the integrity check.

That refusal is correct -- the board ships only board_qemu_arm64.c/.h, no
linker scripts, so there is no stage-1 image for stage-0 to verify -- and the
message names the flag. Passed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The QEMU step ran for ~20ms, left an empty serial log, and reported nothing --
because its stderr went to /dev/null and its exit code into `|| true`. The one
message that says why the guest did not boot was the message being thrown away.

Keep the run non-fatal only for exit 124 (timeout(1) killing a still-running
guest, which is how a bare-metal image "finishes"); any other nonzero exit now
fails the step loudly with QEMU's own stderr printed. Also list the artifact
files before booting, so a missing or truncated eos_qemu.bin is visible.

The TC validation gate below is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With stderr finally visible, the reason the guest never booted is one line:

    qemu-system-aarch64: failed to find romfile "efi-virtio.rom"

The workflow installs qemu-system-arm with --no-install-recommends, which
skips ipxe-qemu -- the package that ships efi-virtio.rom. The virt machine
creates a default virtio-net device, that device wants its boot ROM, and QEMU
exits during machine init. This has been the failure since the job first ran;
it was just invisible behind 2>/dev/null.

The simulation uses no networking, so -nic none removes the device rather
than installing a ROM for a NIC nothing talks to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Build & Test (Linux x86_64) job hangs in "Run C unit tests" and holds the
workflow open until GitHub's 6-hour limit. Which test hangs is currently
unknowable from the outside: ctest runs with no per-test timeout, so the job
never reaches the summary that would name it.

--timeout 120 turns the invisible hang into a named failure. 120s is generous
-- the slowest test in this suite takes 1.4s.

Second, related: the concurrency group I added cancels in-progress runs on
every push. Right for PR branches; wrong for master, where each merge
cancelled the previous merge's run -- which is why this job has not been
allowed to COMPLETE on master since 08-30 and the hang went unnoticed.
cancel-in-progress is now conditional on the ref not being master.

Verified locally (macOS): ctest 25/28 pass with the timeout flag; the 3
remaining are Not Run because test_os_adapter/test_os_services/test_ota do not
build here (the known pthread_mutex_timedlock/sem_timedwait gap). Those three
are Linux-only -- exactly the ones this timeout will name if one of them is
the hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test named by the new ctest timeout is test_net, and the hang is not a
networking bug. CI builds Release; Release defines NDEBUG; NDEBUG deletes
assert() together with the expression inside it. Half of test_net's checks
wrap the call under test:

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

With those gone, no client ever connects, the echo thread blocks in accept()
forever, and pthread_join() hangs the suite -- for exactly as long as nothing
bounds the test, which is what the previous commit fixed.

Reproduced locally before fixing: a Release build of the unmodified test hangs
on macOS too (this was never Linux-specific; my earlier local runs simply were
not Release). Replaced every assert() with a CHECK() macro that always
evaluates and exits nonzero on failure.

After: Release test_net passes 19/19 in under a second; full local Release
ctest 27/28 (the 1 is test_os_adapter, Not Run on macOS as known).

Same latent class elsewhere: other C tests also use assert() and silently
check nothing in Release builds -- they pass today because their expressions
have no needed side effects that block, but they are weaker than they look.
Worth a sweep in its own PR rather than burying it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ARM Cortex-M4 job caught a regression in my backend-selection change:

    hal/src/hal_linux.c:30:10: fatal error: sys/ioctl.h: No such file or directory

That cross build also compiles hal_linux.c -- EOS_PLATFORM defaults to
"linux", so CMake's host branch is taken even for arm-none-eabi -- and it only
ever built because `#ifdef __linux__` reduced the file to an empty translation
unit there. Widening the guard to EOS_HAL_BACKEND_HOST un-emptied it on a
toolchain with no POSIX headers.

The guard now asks the right question -- "is this a hosted POSIX build?" --
once, in hal.h:

    #if defined(__linux__) || \
        (defined(EOS_HAL_BACKEND_HOST) && (defined(__unix__) || defined(__APPLE__)))
    #define EOS_HAL_HOSTED 1

and hal_linux.c, hal_rtos.c and hal_common.c all key off EOS_HAL_HOSTED
instead of each doing their own arithmetic. arm-none-eabi defines none of
those macros, so the empty-TU behavior the ARM job depends on is restored;
aarch64-linux-gnu defines __linux__, so the QEMU simulation lane is unchanged;
macOS defines __APPLE__, so the original fix stands.

Verified locally by preprocessing hal_linux.c both ways (clang -U__APPLE__
-U__unix__ -U__linux__ simulating bare metal: body absent; hosted: present)
and a full Release build + ctest: 26/28, the 2 remaining being the documented
macOS Not-Runs (pthread_mutex_timedlock/sem_timedwait).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srpatcha pushed a commit that referenced this pull request Sep 1, 2026
… it (#110)

* fix(hal): let the build system, not the preprocessor, pick the HAL backend

Which backend source CMake compiles and which registrar eos_hal_init() calls
are the same decision made in two places, with two different conditions.

CMakeLists.txt:

    if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
        target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
    else()
        target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)

hal_common.c:

    #ifdef __linux__
        eos_hal_linux_register();
    #else
        eos_hal_rtos_register();

They agree on Linux and on a cross build, and disagree on every other host. A
macOS host build compiles hal_linux.c, then references a symbol from the file
it did not compile:

    Undefined symbols for architecture arm64:
      "_eos_hal_rtos_register", referenced from: _eos_hal_init

CMake now sets EOS_HAL_BACKEND_HOST or EOS_HAL_BACKEND_RTOS next to the
target_sources() call that makes the choice, and hal_common.c keys off that.
__linux__ stays as a fallback for anyone compiling these files without CMake.

Two things had to move with it:

- hal.h declared the registrars under the same #ifdef, so hal_common.c could
  not even name the one CMake had compiled. Both are declared unconditionally
  now; exactly one is still defined per build, and an unreferenced declaration
  costs nothing. The header's own comment already argued for this -- "an
  undeclared registrar is one nobody can find".
- hal_linux.c wraps its whole body in #ifdef __linux__, so on macOS it compiled
  to an empty translation unit and the build had no backend at all. It is the
  host backend rather than the Linux one: the only platform calls in it are
  clock_gettime() and nanosleep(), both POSIX. Guarded on the CMake define,
  with __linux__ as the fallback. hal_rtos.c takes the mirror condition.

Linux behaviour is unchanged: EOS_HAL_BACKEND_HOST and __linux__ are both
defined there, and the same registrar is selected as before. Cross builds get
EOS_HAL_BACKEND_RTOS and are likewise unchanged. eos master CI is green on
Linux and stays that way.

Verified on macOS: the eos_hal link error is gone and ctest goes from 0/28 to
19/28.

This does NOT make macOS a supported host on its own. The remaining 9 failures
are services/compat/src/os_adapter_posix.c calling pthread_mutex_timedlock()
and sem_timedwait(), neither of which macOS provides. That is a separate piece
of work in a different subsystem and I have not attempted it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: build eBoot for qemu_arm64 with EBLDR_VERIFY_STAGE1 off

With -DEBLDR_BOARD=qemu_arm64 actually taking effect (it was -DBOARD= before
and silently ignored), eBoot's configure now runs the board path and refuses:

    EBLDR_VERIFY_STAGE1 is ON but board qemu_arm64 has no stage-1 linker
    script, so there is no stage-1 image to hash. Configure with
    -DEBLDR_VERIFY_STAGE1=OFF to build stage-0 without the integrity check.

That refusal is correct -- the board ships only board_qemu_arm64.c/.h, no
linker scripts, so there is no stage-1 image for stage-0 to verify -- and the
message names the flag. Passed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: surface QEMU's error instead of discarding it

The QEMU step ran for ~20ms, left an empty serial log, and reported nothing --
because its stderr went to /dev/null and its exit code into `|| true`. The one
message that says why the guest did not boot was the message being thrown away.

Keep the run non-fatal only for exit 124 (timeout(1) killing a still-running
guest, which is how a bare-metal image "finishes"); any other nonzero exit now
fails the step loudly with QEMU's own stderr printed. Also list the artifact
files before booting, so a missing or truncated eos_qemu.bin is visible.

The TC validation gate below is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: give the virt machine no NIC instead of a NIC with no ROM

With stderr finally visible, the reason the guest never booted is one line:

    qemu-system-aarch64: failed to find romfile "efi-virtio.rom"

The workflow installs qemu-system-arm with --no-install-recommends, which
skips ipxe-qemu -- the package that ships efi-virtio.rom. The virt machine
creates a default virtio-net device, that device wants its boot ROM, and QEMU
exits during machine init. This has been the failure since the job first ran;
it was just invisible behind 2>/dev/null.

The simulation uses no networking, so -nic none removes the device rather
than installing a ROM for a NIC nothing talks to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: bound each C test, and stop cancelling master's runs

The Build & Test (Linux x86_64) job hangs in "Run C unit tests" and holds the
workflow open until GitHub's 6-hour limit. Which test hangs is currently
unknowable from the outside: ctest runs with no per-test timeout, so the job
never reaches the summary that would name it.

--timeout 120 turns the invisible hang into a named failure. 120s is generous
-- the slowest test in this suite takes 1.4s.

Second, related: the concurrency group I added cancels in-progress runs on
every push. Right for PR branches; wrong for master, where each merge
cancelled the previous merge's run -- which is why this job has not been
allowed to COMPLETE on master since 08-30 and the hang went unnoticed.
cancel-in-progress is now conditional on the ref not being master.

Verified locally (macOS): ctest 25/28 pass with the timeout flag; the 3
remaining are Not Run because test_os_adapter/test_os_services/test_ota do not
build here (the known pthread_mutex_timedlock/sem_timedwait gap). Those three
are Linux-only -- exactly the ones this timeout will name if one of them is
the hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): test_net must not hide its calls inside assert()

The test named by the new ctest timeout is test_net, and the hang is not a
networking bug. CI builds Release; Release defines NDEBUG; NDEBUG deletes
assert() together with the expression inside it. Half of test_net's checks
wrap the call under test:

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

With those gone, no client ever connects, the echo thread blocks in accept()
forever, and pthread_join() hangs the suite -- for exactly as long as nothing
bounds the test, which is what the previous commit fixed.

Reproduced locally before fixing: a Release build of the unmodified test hangs
on macOS too (this was never Linux-specific; my earlier local runs simply were
not Release). Replaced every assert() with a CHECK() macro that always
evaluates and exits nonzero on failure.

After: Release test_net passes 19/19 in under a second; full local Release
ctest 27/28 (the 1 is test_os_adapter, Not Run on macOS as known).

Same latent class elsewhere: other C tests also use assert() and silently
check nothing in Release builds -- they pass today because their expressions
have no needed side effects that block, but they are weaker than they look.
Worth a sweep in its own PR rather than burying it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(hal): keep hal_linux.c an empty TU on bare metal

The ARM Cortex-M4 job caught a regression in my backend-selection change:

    hal/src/hal_linux.c:30:10: fatal error: sys/ioctl.h: No such file or directory

That cross build also compiles hal_linux.c -- EOS_PLATFORM defaults to
"linux", so CMake's host branch is taken even for arm-none-eabi -- and it only
ever built because `#ifdef __linux__` reduced the file to an empty translation
unit there. Widening the guard to EOS_HAL_BACKEND_HOST un-emptied it on a
toolchain with no POSIX headers.

The guard now asks the right question -- "is this a hosted POSIX build?" --
once, in hal.h:

    #if defined(__linux__) || \
        (defined(EOS_HAL_BACKEND_HOST) && (defined(__unix__) || defined(__APPLE__)))
    #define EOS_HAL_HOSTED 1

and hal_linux.c, hal_rtos.c and hal_common.c all key off EOS_HAL_HOSTED
instead of each doing their own arithmetic. arm-none-eabi defines none of
those macros, so the empty-TU behavior the ARM job depends on is restored;
aarch64-linux-gnu defines __linux__, so the QEMU simulation lane is unchanged;
macOS defines __APPLE__, so the original fix stands.

Verified locally by preprocessing hal_linux.c both ways (clang -U__APPLE__
-U__unix__ -U__linux__ simulating bare metal: body absent; hosted: present)
and a full Release build + ctest: 26/28, the 2 remaining being the documented
macOS Not-Runs (pthread_mutex_timedlock/sem_timedwait).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(fuzz): point the OTA harness at a function that exists, and build it

tests/fuzz/fuzz_ota_header.c declared and called

    extern int eos_ota_parse_header(const void *data, size_t len);

There is no such function anywhere in the tree. The harness was also commented
out of tests/fuzz/CMakeLists.txt, so it was dead twice over -- and could not
have compiled if anyone had enabled it. Same shape as the fuzz_devicetree
harness fixed in #50, which declared an eos_dtb_parse() that likewise did not
exist.

Rewritten against the real API, and aimed at the part that actually takes
attacker-controlled bytes. eos_ota_source_t carries a declared size and an
expected digest that travel WITH the update, and eos_ota_write_chunk() streams
the payload, so the harness treats the first 36 bytes of fuzz input as that
control block and the rest as payload -- fed in fuzz-chosen slice widths so
chunk boundaries vary rather than always landing on the same offsets.

It finishes with verify/apply/rollback so the digest comparison and the
authenticity gate are exercised, with an authenticator that refuses
everything: apply() must never install a fuzz-chosen image, and wiring one in
means the run cannot silently stop at the gate added in #75.

Not run here: libFuzzer itself. AppleClang ships no libclang_rt.fuzzer_osx.a,
so `-fsanitize=fuzzer` does not link on this machine. What I did instead was
build LLVMFuzzerTestOneInput against a small driver under
-fsanitize=address,undefined -fno-sanitize-recover=all and feed it random
inputs:

    5 seeds x 40,000 inputs = 200,000 -- no sanitizer reports

That proves the harness body runs and the ingest path survives arbitrary
input; it is not coverage-guided, and CI with a real libFuzzer toolchain is
where that happens.

CMake now reports:
    -- Fuzz targets: fuzz_sha256, fuzz_aes, fuzz_devicetree, fuzz_ota_header

Verified: ctest 26/28, the two Not-Run being targets that need services/compat,
which does not build on macOS (#96's subject).

Stacked on #102, which repairs the macOS HAL link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
srpatcha pushed a commit that referenced this pull request Sep 1, 2026
… zero (#109)

* fix(hal): let the build system, not the preprocessor, pick the HAL backend

Which backend source CMake compiles and which registrar eos_hal_init() calls
are the same decision made in two places, with two different conditions.

CMakeLists.txt:

    if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
        target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
    else()
        target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)

hal_common.c:

    #ifdef __linux__
        eos_hal_linux_register();
    #else
        eos_hal_rtos_register();

They agree on Linux and on a cross build, and disagree on every other host. A
macOS host build compiles hal_linux.c, then references a symbol from the file
it did not compile:

    Undefined symbols for architecture arm64:
      "_eos_hal_rtos_register", referenced from: _eos_hal_init

CMake now sets EOS_HAL_BACKEND_HOST or EOS_HAL_BACKEND_RTOS next to the
target_sources() call that makes the choice, and hal_common.c keys off that.
__linux__ stays as a fallback for anyone compiling these files without CMake.

Two things had to move with it:

- hal.h declared the registrars under the same #ifdef, so hal_common.c could
  not even name the one CMake had compiled. Both are declared unconditionally
  now; exactly one is still defined per build, and an unreferenced declaration
  costs nothing. The header's own comment already argued for this -- "an
  undeclared registrar is one nobody can find".
- hal_linux.c wraps its whole body in #ifdef __linux__, so on macOS it compiled
  to an empty translation unit and the build had no backend at all. It is the
  host backend rather than the Linux one: the only platform calls in it are
  clock_gettime() and nanosleep(), both POSIX. Guarded on the CMake define,
  with __linux__ as the fallback. hal_rtos.c takes the mirror condition.

Linux behaviour is unchanged: EOS_HAL_BACKEND_HOST and __linux__ are both
defined there, and the same registrar is selected as before. Cross builds get
EOS_HAL_BACKEND_RTOS and are likewise unchanged. eos master CI is green on
Linux and stays that way.

Verified on macOS: the eos_hal link error is gone and ctest goes from 0/28 to
19/28.

This does NOT make macOS a supported host on its own. The remaining 9 failures
are services/compat/src/os_adapter_posix.c calling pthread_mutex_timedlock()
and sem_timedwait(), neither of which macOS provides. That is a separate piece
of work in a different subsystem and I have not attempted it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: build eBoot for qemu_arm64 with EBLDR_VERIFY_STAGE1 off

With -DEBLDR_BOARD=qemu_arm64 actually taking effect (it was -DBOARD= before
and silently ignored), eBoot's configure now runs the board path and refuses:

    EBLDR_VERIFY_STAGE1 is ON but board qemu_arm64 has no stage-1 linker
    script, so there is no stage-1 image to hash. Configure with
    -DEBLDR_VERIFY_STAGE1=OFF to build stage-0 without the integrity check.

That refusal is correct -- the board ships only board_qemu_arm64.c/.h, no
linker scripts, so there is no stage-1 image for stage-0 to verify -- and the
message names the flag. Passed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: surface QEMU's error instead of discarding it

The QEMU step ran for ~20ms, left an empty serial log, and reported nothing --
because its stderr went to /dev/null and its exit code into `|| true`. The one
message that says why the guest did not boot was the message being thrown away.

Keep the run non-fatal only for exit 124 (timeout(1) killing a still-running
guest, which is how a bare-metal image "finishes"); any other nonzero exit now
fails the step loudly with QEMU's own stderr printed. Also list the artifact
files before booting, so a missing or truncated eos_qemu.bin is visible.

The TC validation gate below is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: give the virt machine no NIC instead of a NIC with no ROM

With stderr finally visible, the reason the guest never booted is one line:

    qemu-system-aarch64: failed to find romfile "efi-virtio.rom"

The workflow installs qemu-system-arm with --no-install-recommends, which
skips ipxe-qemu -- the package that ships efi-virtio.rom. The virt machine
creates a default virtio-net device, that device wants its boot ROM, and QEMU
exits during machine init. This has been the failure since the job first ran;
it was just invisible behind 2>/dev/null.

The simulation uses no networking, so -nic none removes the device rather
than installing a ROM for a NIC nothing talks to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: bound each C test, and stop cancelling master's runs

The Build & Test (Linux x86_64) job hangs in "Run C unit tests" and holds the
workflow open until GitHub's 6-hour limit. Which test hangs is currently
unknowable from the outside: ctest runs with no per-test timeout, so the job
never reaches the summary that would name it.

--timeout 120 turns the invisible hang into a named failure. 120s is generous
-- the slowest test in this suite takes 1.4s.

Second, related: the concurrency group I added cancels in-progress runs on
every push. Right for PR branches; wrong for master, where each merge
cancelled the previous merge's run -- which is why this job has not been
allowed to COMPLETE on master since 08-30 and the hang went unnoticed.
cancel-in-progress is now conditional on the ref not being master.

Verified locally (macOS): ctest 25/28 pass with the timeout flag; the 3
remaining are Not Run because test_os_adapter/test_os_services/test_ota do not
build here (the known pthread_mutex_timedlock/sem_timedwait gap). Those three
are Linux-only -- exactly the ones this timeout will name if one of them is
the hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): test_net must not hide its calls inside assert()

The test named by the new ctest timeout is test_net, and the hang is not a
networking bug. CI builds Release; Release defines NDEBUG; NDEBUG deletes
assert() together with the expression inside it. Half of test_net's checks
wrap the call under test:

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

With those gone, no client ever connects, the echo thread blocks in accept()
forever, and pthread_join() hangs the suite -- for exactly as long as nothing
bounds the test, which is what the previous commit fixed.

Reproduced locally before fixing: a Release build of the unmodified test hangs
on macOS too (this was never Linux-specific; my earlier local runs simply were
not Release). Replaced every assert() with a CHECK() macro that always
evaluates and exits nonzero on failure.

After: Release test_net passes 19/19 in under a second; full local Release
ctest 27/28 (the 1 is test_os_adapter, Not Run on macOS as known).

Same latent class elsewhere: other C tests also use assert() and silently
check nothing in Release builds -- they pass today because their expressions
have no needed side effects that block, but they are weaker than they look.
Worth a sweep in its own PR rather than burying it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(hal): keep hal_linux.c an empty TU on bare metal

The ARM Cortex-M4 job caught a regression in my backend-selection change:

    hal/src/hal_linux.c:30:10: fatal error: sys/ioctl.h: No such file or directory

That cross build also compiles hal_linux.c -- EOS_PLATFORM defaults to
"linux", so CMake's host branch is taken even for arm-none-eabi -- and it only
ever built because `#ifdef __linux__` reduced the file to an empty translation
unit there. Widening the guard to EOS_HAL_BACKEND_HOST un-emptied it on a
toolchain with no POSIX headers.

The guard now asks the right question -- "is this a hosted POSIX build?" --
once, in hal.h:

    #if defined(__linux__) || \
        (defined(EOS_HAL_BACKEND_HOST) && (defined(__unix__) || defined(__APPLE__)))
    #define EOS_HAL_HOSTED 1

and hal_linux.c, hal_rtos.c and hal_common.c all key off EOS_HAL_HOSTED
instead of each doing their own arithmetic. arm-none-eabi defines none of
those macros, so the empty-TU behavior the ARM job depends on is restored;
aarch64-linux-gnu defines __linux__, so the QEMU simulation lane is unchanged;
macOS defines __APPLE__, so the original fix stands.

Verified locally by preprocessing hal_linux.c both ways (clang -U__APPLE__
-U__unix__ -U__linux__ simulating bare metal: body absent; hosted: present)
and a full Release build + ctest: 26/28, the 2 remaining being the documented
macOS Not-Runs (pthread_mutex_timedlock/sem_timedwait).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(mpu): reject regions the MPU cannot program, and stop dividing by zero

eos_mpu_validate() returns an error count, and its two hardware-constraint
checks did not contribute to it:

    if (reg->size > 0 && (reg->size & (reg->size - 1)) != 0)
        printf("  WARN: ... size 0x%x not power-of-2\n", ...);
    if (reg->base_addr % reg->size != 0 && reg->size > 0)
        printf("  WARN: ... base 0x%x not aligned to size 0x%x\n", ...);

Neither touches `errors`, so a configuration the hardware cannot program is
reported clean and eos_mpu_generate_config() writes it out.

These are not warnings. ARMv7-M encodes a region's extent as a power-of-two
exponent in RASR.SIZE and requires RBAR's base to be aligned to that extent. A
region violating either cannot be expressed, so what actually gets programmed
covers different memory than was asked for -- and for a task-isolation
service, "isolated, but not over the range you specified" is precisely the
failure this validation exists to catch. Both now count, along with the
32-byte floor RASR.SIZE implies, which nothing checked at all.

Second, in the same expression: `reg->base_addr % reg->size != 0 && reg->size > 0`
divides before it checks. && only guards what is to its right, so a zero-sized
region reaches the modulo. eos_mpu_add_region() never validates size, so this
is reachable from any caller:

    services/rtos/src/rtos_security.c:80:32: runtime error: division by zero
    SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior

Worth knowing why it went unnoticed: on x86 a zero divisor raises SIGFPE and
the process dies, while AArch64's udiv quietly yields 0. The same input
crashes on the CI host and returns silently on an Apple-silicon dev machine.
Size 0 is now rejected first, and the check `continue`s past everything that
divides by or masks with size.

tests/test_mpu_validate.c is new -- this service had no tests. It covers a
programmable region validating clean, then each rejection: non-power-of-two,
misaligned base, zero size, below the 32-byte minimum, and an unprogrammable
region inside a satisfied region budget. Against the unpatched validate() it
fails on the non-power-of-two case, which prints WARN and returns 0.

Verified: ctest 27/29. The two Not-Run are test_os_adapter and
test_os_services, which need services/compat and do not build on macOS
(pthread_mutex_timedlock/sem_timedwait -- #96's subject).

Stacked on #102, which repairs the macOS HAL link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
srpatcha pushed a commit that referenced this pull request Sep 1, 2026
* fix(hal): let the build system, not the preprocessor, pick the HAL backend

Which backend source CMake compiles and which registrar eos_hal_init() calls
are the same decision made in two places, with two different conditions.

CMakeLists.txt:

    if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
        target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
    else()
        target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)

hal_common.c:

    #ifdef __linux__
        eos_hal_linux_register();
    #else
        eos_hal_rtos_register();

They agree on Linux and on a cross build, and disagree on every other host. A
macOS host build compiles hal_linux.c, then references a symbol from the file
it did not compile:

    Undefined symbols for architecture arm64:
      "_eos_hal_rtos_register", referenced from: _eos_hal_init

CMake now sets EOS_HAL_BACKEND_HOST or EOS_HAL_BACKEND_RTOS next to the
target_sources() call that makes the choice, and hal_common.c keys off that.
__linux__ stays as a fallback for anyone compiling these files without CMake.

Two things had to move with it:

- hal.h declared the registrars under the same #ifdef, so hal_common.c could
  not even name the one CMake had compiled. Both are declared unconditionally
  now; exactly one is still defined per build, and an unreferenced declaration
  costs nothing. The header's own comment already argued for this -- "an
  undeclared registrar is one nobody can find".
- hal_linux.c wraps its whole body in #ifdef __linux__, so on macOS it compiled
  to an empty translation unit and the build had no backend at all. It is the
  host backend rather than the Linux one: the only platform calls in it are
  clock_gettime() and nanosleep(), both POSIX. Guarded on the CMake define,
  with __linux__ as the fallback. hal_rtos.c takes the mirror condition.

Linux behaviour is unchanged: EOS_HAL_BACKEND_HOST and __linux__ are both
defined there, and the same registrar is selected as before. Cross builds get
EOS_HAL_BACKEND_RTOS and are likewise unchanged. eos master CI is green on
Linux and stays that way.

Verified on macOS: the eos_hal link error is gone and ctest goes from 0/28 to
19/28.

This does NOT make macOS a supported host on its own. The remaining 9 failures
are services/compat/src/os_adapter_posix.c calling pthread_mutex_timedlock()
and sem_timedwait(), neither of which macOS provides. That is a separate piece
of work in a different subsystem and I have not attempted it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: build eBoot for qemu_arm64 with EBLDR_VERIFY_STAGE1 off

With -DEBLDR_BOARD=qemu_arm64 actually taking effect (it was -DBOARD= before
and silently ignored), eBoot's configure now runs the board path and refuses:

    EBLDR_VERIFY_STAGE1 is ON but board qemu_arm64 has no stage-1 linker
    script, so there is no stage-1 image to hash. Configure with
    -DEBLDR_VERIFY_STAGE1=OFF to build stage-0 without the integrity check.

That refusal is correct -- the board ships only board_qemu_arm64.c/.h, no
linker scripts, so there is no stage-1 image for stage-0 to verify -- and the
message names the flag. Passed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: surface QEMU's error instead of discarding it

The QEMU step ran for ~20ms, left an empty serial log, and reported nothing --
because its stderr went to /dev/null and its exit code into `|| true`. The one
message that says why the guest did not boot was the message being thrown away.

Keep the run non-fatal only for exit 124 (timeout(1) killing a still-running
guest, which is how a bare-metal image "finishes"); any other nonzero exit now
fails the step loudly with QEMU's own stderr printed. Also list the artifact
files before booting, so a missing or truncated eos_qemu.bin is visible.

The TC validation gate below is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: give the virt machine no NIC instead of a NIC with no ROM

With stderr finally visible, the reason the guest never booted is one line:

    qemu-system-aarch64: failed to find romfile "efi-virtio.rom"

The workflow installs qemu-system-arm with --no-install-recommends, which
skips ipxe-qemu -- the package that ships efi-virtio.rom. The virt machine
creates a default virtio-net device, that device wants its boot ROM, and QEMU
exits during machine init. This has been the failure since the job first ran;
it was just invisible behind 2>/dev/null.

The simulation uses no networking, so -nic none removes the device rather
than installing a ROM for a NIC nothing talks to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: bound each C test, and stop cancelling master's runs

The Build & Test (Linux x86_64) job hangs in "Run C unit tests" and holds the
workflow open until GitHub's 6-hour limit. Which test hangs is currently
unknowable from the outside: ctest runs with no per-test timeout, so the job
never reaches the summary that would name it.

--timeout 120 turns the invisible hang into a named failure. 120s is generous
-- the slowest test in this suite takes 1.4s.

Second, related: the concurrency group I added cancels in-progress runs on
every push. Right for PR branches; wrong for master, where each merge
cancelled the previous merge's run -- which is why this job has not been
allowed to COMPLETE on master since 08-30 and the hang went unnoticed.
cancel-in-progress is now conditional on the ref not being master.

Verified locally (macOS): ctest 25/28 pass with the timeout flag; the 3
remaining are Not Run because test_os_adapter/test_os_services/test_ota do not
build here (the known pthread_mutex_timedlock/sem_timedwait gap). Those three
are Linux-only -- exactly the ones this timeout will name if one of them is
the hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): test_net must not hide its calls inside assert()

The test named by the new ctest timeout is test_net, and the hang is not a
networking bug. CI builds Release; Release defines NDEBUG; NDEBUG deletes
assert() together with the expression inside it. Half of test_net's checks
wrap the call under test:

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

With those gone, no client ever connects, the echo thread blocks in accept()
forever, and pthread_join() hangs the suite -- for exactly as long as nothing
bounds the test, which is what the previous commit fixed.

Reproduced locally before fixing: a Release build of the unmodified test hangs
on macOS too (this was never Linux-specific; my earlier local runs simply were
not Release). Replaced every assert() with a CHECK() macro that always
evaluates and exits nonzero on failure.

After: Release test_net passes 19/19 in under a second; full local Release
ctest 27/28 (the 1 is test_os_adapter, Not Run on macOS as known).

Same latent class elsewhere: other C tests also use assert() and silently
check nothing in Release builds -- they pass today because their expressions
have no needed side effects that block, but they are weaker than they look.
Worth a sweep in its own PR rather than burying it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(hal): keep hal_linux.c an empty TU on bare metal

The ARM Cortex-M4 job caught a regression in my backend-selection change:

    hal/src/hal_linux.c:30:10: fatal error: sys/ioctl.h: No such file or directory

That cross build also compiles hal_linux.c -- EOS_PLATFORM defaults to
"linux", so CMake's host branch is taken even for arm-none-eabi -- and it only
ever built because `#ifdef __linux__` reduced the file to an empty translation
unit there. Widening the guard to EOS_HAL_BACKEND_HOST un-emptied it on a
toolchain with no POSIX headers.

The guard now asks the right question -- "is this a hosted POSIX build?" --
once, in hal.h:

    #if defined(__linux__) || \
        (defined(EOS_HAL_BACKEND_HOST) && (defined(__unix__) || defined(__APPLE__)))
    #define EOS_HAL_HOSTED 1

and hal_linux.c, hal_rtos.c and hal_common.c all key off EOS_HAL_HOSTED
instead of each doing their own arithmetic. arm-none-eabi defines none of
those macros, so the empty-TU behavior the ARM job depends on is restored;
aarch64-linux-gnu defines __linux__, so the QEMU simulation lane is unchanged;
macOS defines __APPLE__, so the original fix stands.

Verified locally by preprocessing hal_linux.c both ways (clang -U__APPLE__
-U__unix__ -U__linux__ simulating bare metal: body absent; hosted: present)
and a full Release build + ctest: 26/28, the 2 remaining being the documented
macOS Not-Runs (pthread_mutex_timedlock/sem_timedwait).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ota): log the refusal through EOS_ERROR, not fprintf

Follow-up to the review on #75:

    "the refusal uses fprintf(stderr, ...) while the tree has EOS_ERROR in
     core/include/eos/log.h, which is what core/src/config.c uses. On a target
     with no hosted stdio this is the one line in the change that will not
     build."

Correct, and it is live on master. eos_ota_apply()'s refusal path was the only
reason services/ota/src/ota.c included <stdio.h> at all, so the include goes
with it -- an OTA service that cannot compile without a hosted stdio is not
much use to the targets this project exists for.

EOS_ERROR expands to eos_log(), which lives in eos_core, so eos_ota now links
it. That is a new edge in the dependency graph and worth being explicit about:
eos_core is core/src/{log,config,graph,cache,lockfile,layer,scheduler}.c and
does not depend on eos_ota, so it introduces no cycle.

Message text is unchanged apart from dropping the trailing newline, which
eos_log adds, and the "eos-ota:" prefix, which the log layer supplies.

Verified: eos_ota builds clean; ctest 26/28, the two Not-Run being
test_os_adapter and test_os_services, which need services/compat and do not
build on macOS (pthread_mutex_timedlock/sem_timedwait -- #96's subject, not
this). test_ota itself is 9/9 including the four authenticator cases that
exercise this exact path.

Stacked on #102, which repairs the macOS HAL link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Kartikey1306

Copy link
Copy Markdown
Contributor Author

Closing — this landed. Merging current master into the branch leaves an empty diff: EOS_HAL_HOSTED is on master, comment and all, carried in when #108/#109/#110 merged from on top of this.

Thank you for taking the stack. Everything that was blocked behind it is now unblocked, and #104/#105/#106 are rebased onto master directly rather than onto this.

srpatcha pushed a commit that referenced this pull request Sep 1, 2026
* fix(hal): let the build system, not the preprocessor, pick the HAL backend

Which backend source CMake compiles and which registrar eos_hal_init() calls
are the same decision made in two places, with two different conditions.

CMakeLists.txt:

    if(EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING)
        target_sources(eos_hal PRIVATE hal/src/hal_linux.c)
    else()
        target_sources(eos_hal PRIVATE hal/src/hal_rtos.c)

hal_common.c:

    #ifdef __linux__
        eos_hal_linux_register();
    #else
        eos_hal_rtos_register();

They agree on Linux and on a cross build, and disagree on every other host. A
macOS host build compiles hal_linux.c, then references a symbol from the file
it did not compile:

    Undefined symbols for architecture arm64:
      "_eos_hal_rtos_register", referenced from: _eos_hal_init

CMake now sets EOS_HAL_BACKEND_HOST or EOS_HAL_BACKEND_RTOS next to the
target_sources() call that makes the choice, and hal_common.c keys off that.
__linux__ stays as a fallback for anyone compiling these files without CMake.

Two things had to move with it:

- hal.h declared the registrars under the same #ifdef, so hal_common.c could
  not even name the one CMake had compiled. Both are declared unconditionally
  now; exactly one is still defined per build, and an unreferenced declaration
  costs nothing. The header's own comment already argued for this -- "an
  undeclared registrar is one nobody can find".
- hal_linux.c wraps its whole body in #ifdef __linux__, so on macOS it compiled
  to an empty translation unit and the build had no backend at all. It is the
  host backend rather than the Linux one: the only platform calls in it are
  clock_gettime() and nanosleep(), both POSIX. Guarded on the CMake define,
  with __linux__ as the fallback. hal_rtos.c takes the mirror condition.

Linux behaviour is unchanged: EOS_HAL_BACKEND_HOST and __linux__ are both
defined there, and the same registrar is selected as before. Cross builds get
EOS_HAL_BACKEND_RTOS and are likewise unchanged. eos master CI is green on
Linux and stays that way.

Verified on macOS: the eos_hal link error is gone and ctest goes from 0/28 to
19/28.

This does NOT make macOS a supported host on its own. The remaining 9 failures
are services/compat/src/os_adapter_posix.c calling pthread_mutex_timedlock()
and sem_timedwait(), neither of which macOS provides. That is a separate piece
of work in a different subsystem and I have not attempted it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: build eBoot for qemu_arm64 with EBLDR_VERIFY_STAGE1 off

With -DEBLDR_BOARD=qemu_arm64 actually taking effect (it was -DBOARD= before
and silently ignored), eBoot's configure now runs the board path and refuses:

    EBLDR_VERIFY_STAGE1 is ON but board qemu_arm64 has no stage-1 linker
    script, so there is no stage-1 image to hash. Configure with
    -DEBLDR_VERIFY_STAGE1=OFF to build stage-0 without the integrity check.

That refusal is correct -- the board ships only board_qemu_arm64.c/.h, no
linker scripts, so there is no stage-1 image for stage-0 to verify -- and the
message names the flag. Passed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: surface QEMU's error instead of discarding it

The QEMU step ran for ~20ms, left an empty serial log, and reported nothing --
because its stderr went to /dev/null and its exit code into `|| true`. The one
message that says why the guest did not boot was the message being thrown away.

Keep the run non-fatal only for exit 124 (timeout(1) killing a still-running
guest, which is how a bare-metal image "finishes"); any other nonzero exit now
fails the step loudly with QEMU's own stderr printed. Also list the artifact
files before booting, so a missing or truncated eos_qemu.bin is visible.

The TC validation gate below is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: give the virt machine no NIC instead of a NIC with no ROM

With stderr finally visible, the reason the guest never booted is one line:

    qemu-system-aarch64: failed to find romfile "efi-virtio.rom"

The workflow installs qemu-system-arm with --no-install-recommends, which
skips ipxe-qemu -- the package that ships efi-virtio.rom. The virt machine
creates a default virtio-net device, that device wants its boot ROM, and QEMU
exits during machine init. This has been the failure since the job first ran;
it was just invisible behind 2>/dev/null.

The simulation uses no networking, so -nic none removes the device rather
than installing a ROM for a NIC nothing talks to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: bound each C test, and stop cancelling master's runs

The Build & Test (Linux x86_64) job hangs in "Run C unit tests" and holds the
workflow open until GitHub's 6-hour limit. Which test hangs is currently
unknowable from the outside: ctest runs with no per-test timeout, so the job
never reaches the summary that would name it.

--timeout 120 turns the invisible hang into a named failure. 120s is generous
-- the slowest test in this suite takes 1.4s.

Second, related: the concurrency group I added cancels in-progress runs on
every push. Right for PR branches; wrong for master, where each merge
cancelled the previous merge's run -- which is why this job has not been
allowed to COMPLETE on master since 08-30 and the hang went unnoticed.
cancel-in-progress is now conditional on the ref not being master.

Verified locally (macOS): ctest 25/28 pass with the timeout flag; the 3
remaining are Not Run because test_os_adapter/test_os_services/test_ota do not
build here (the known pthread_mutex_timedlock/sem_timedwait gap). Those three
are Linux-only -- exactly the ones this timeout will name if one of them is
the hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): test_net must not hide its calls inside assert()

The test named by the new ctest timeout is test_net, and the hang is not a
networking bug. CI builds Release; Release defines NDEBUG; NDEBUG deletes
assert() together with the expression inside it. Half of test_net's checks
wrap the call under test:

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

With those gone, no client ever connects, the echo thread blocks in accept()
forever, and pthread_join() hangs the suite -- for exactly as long as nothing
bounds the test, which is what the previous commit fixed.

Reproduced locally before fixing: a Release build of the unmodified test hangs
on macOS too (this was never Linux-specific; my earlier local runs simply were
not Release). Replaced every assert() with a CHECK() macro that always
evaluates and exits nonzero on failure.

After: Release test_net passes 19/19 in under a second; full local Release
ctest 27/28 (the 1 is test_os_adapter, Not Run on macOS as known).

Same latent class elsewhere: other C tests also use assert() and silently
check nothing in Release builds -- they pass today because their expressions
have no needed side effects that block, but they are weaker than they look.
Worth a sweep in its own PR rather than burying it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(hal): keep hal_linux.c an empty TU on bare metal

The ARM Cortex-M4 job caught a regression in my backend-selection change:

    hal/src/hal_linux.c:30:10: fatal error: sys/ioctl.h: No such file or directory

That cross build also compiles hal_linux.c -- EOS_PLATFORM defaults to
"linux", so CMake's host branch is taken even for arm-none-eabi -- and it only
ever built because `#ifdef __linux__` reduced the file to an empty translation
unit there. Widening the guard to EOS_HAL_BACKEND_HOST un-emptied it on a
toolchain with no POSIX headers.

The guard now asks the right question -- "is this a hosted POSIX build?" --
once, in hal.h:

    #if defined(__linux__) || \
        (defined(EOS_HAL_BACKEND_HOST) && (defined(__unix__) || defined(__APPLE__)))
    #define EOS_HAL_HOSTED 1

and hal_linux.c, hal_rtos.c and hal_common.c all key off EOS_HAL_HOSTED
instead of each doing their own arithmetic. arm-none-eabi defines none of
those macros, so the empty-TU behavior the ARM job depends on is restored;
aarch64-linux-gnu defines __linux__, so the QEMU simulation lane is unchanged;
macOS defines __APPLE__, so the original fix stands.

Verified locally by preprocessing hal_linux.c both ways (clang -U__APPLE__
-U__unix__ -U__linux__ simulating bare metal: body absent; hosted: present)
and a full Release build + ctest: 26/28, the 2 remaining being the documented
macOS Not-Runs (pthread_mutex_timedlock/sem_timedwait).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: keep assertions live in test binaries

CI configures -DCMAKE_BUILD_TYPE=Release. Release defines NDEBUG. NDEBUG
deletes assert() *together with the expression inside it*. These suites express
nearly every check as assert():

    684 assert() calls across 16 test files

All of them compile to nothing in CI, and the tests they belong to pass by
reaching the end of main(). Demonstrated with the exact flags CI uses:

    $ clang -DNDEBUG -o t t.c   # int main(void){ assert(1 == 2); ... }
    REACHED END: the assert did nothing
    exit=0

This is not theoretical. test_net wrapped the calls under test in assert():

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

Under Release no client ever connected, the echo thread blocked in accept()
forever, and the whole suite hung until ctest killed it -- the hang I fixed in
the previous commit. Every other assert() in the tree is the same construction;
that one was simply the only one whose deleted expression also blocked.

Stripping NDEBUG from test targets is a one-line change that makes all 684
meaningful without rewriting a single call site. It applies to the test
binaries only -- the libraries under test are still compiled exactly as CI
builds them for release, so this does not turn the suite into a debug-build
test of a debug-build library.

Verified on macOS (Release): every test that builds passes with assertions
live -- test_kernel (89 asserts), test_priority_inherit (49), test_power (26),
test_net (32), test_motor_ctrl (15) among them, roughly 211 assertions now
actually executing. The remaining targets do not link here for an unrelated
reason: services/compat/src/os_adapter_posix.c uses pthread_mutex_timedlock()
and sem_timedwait(), which macOS does not provide (that is #96's subject, not
this one). Linux CI builds all 28 and is the real check.

Stacked on #102, which repairs the macOS HAL link so this could be evaluated
locally at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 14, 2026
… zero

eos_mpu_validate() returns an error count, and its two hardware-constraint
checks did not contribute to it:

    if (reg->size > 0 && (reg->size & (reg->size - 1)) != 0)
        printf("  WARN: ... size 0x%x not power-of-2\n", ...);
    if (reg->base_addr % reg->size != 0 && reg->size > 0)
        printf("  WARN: ... base 0x%x not aligned to size 0x%x\n", ...);

Neither touches `errors`, so a configuration the hardware cannot program is
reported clean and eos_mpu_generate_config() writes it out.

These are not warnings. ARMv7-M encodes a region's extent as a power-of-two
exponent in RASR.SIZE and requires RBAR's base to be aligned to that extent. A
region violating either cannot be expressed, so what actually gets programmed
covers different memory than was asked for -- and for a task-isolation
service, "isolated, but not over the range you specified" is precisely the
failure this validation exists to catch. Both now count, along with the
32-byte floor RASR.SIZE implies, which nothing checked at all.

Second, in the same expression: `reg->base_addr % reg->size != 0 && reg->size > 0`
divides before it checks. && only guards what is to its right, so a zero-sized
region reaches the modulo. eos_mpu_add_region() never validates size, so this
is reachable from any caller:

    services/rtos/src/rtos_security.c:80:32: runtime error: division by zero
    SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior

Worth knowing why it went unnoticed: on x86 a zero divisor raises SIGFPE and
the process dies, while AArch64's udiv quietly yields 0. The same input
crashes on the CI host and returns silently on an Apple-silicon dev machine.
Size 0 is now rejected first, and the check `continue`s past everything that
divides by or masks with size.

tests/test_mpu_validate.c is new -- this service had no tests. It covers a
programmable region validating clean, then each rejection: non-power-of-two,
misaligned base, zero size, below the 32-byte minimum, and an unprogrammable
region inside a satisfied region budget. Against the unpatched validate() it
fails on the non-power-of-two case, which prints WARN and returns 0.

Verified: ctest 27/29. The two Not-Run are test_os_adapter and
test_os_services, which need services/compat and do not build on macOS
(pthread_mutex_timedlock/sem_timedwait -- embeddedos-org#96's subject).

Stacked on embeddedos-org#102, which repairs the macOS HAL link.
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 14, 2026
tests/fuzz/fuzz_ota_header.c declared and called

    extern int eos_ota_parse_header(const void *data, size_t len);

There is no such function anywhere in the tree. The harness was also commented
out of tests/fuzz/CMakeLists.txt, so it was dead twice over -- and could not
have compiled if anyone had enabled it. Same shape as the fuzz_devicetree
harness fixed in embeddedos-org#50, which declared an eos_dtb_parse() that likewise did not
exist.

Rewritten against the real API, and aimed at the part that actually takes
attacker-controlled bytes. eos_ota_source_t carries a declared size and an
expected digest that travel WITH the update, and eos_ota_write_chunk() streams
the payload, so the harness treats the first 36 bytes of fuzz input as that
control block and the rest as payload -- fed in fuzz-chosen slice widths so
chunk boundaries vary rather than always landing on the same offsets.

It finishes with verify/apply/rollback so the digest comparison and the
authenticity gate are exercised, with an authenticator that refuses
everything: apply() must never install a fuzz-chosen image, and wiring one in
means the run cannot silently stop at the gate added in embeddedos-org#75.

Not run here: libFuzzer itself. AppleClang ships no libclang_rt.fuzzer_osx.a,
so `-fsanitize=fuzzer` does not link on this machine. What I did instead was
build LLVMFuzzerTestOneInput against a small driver under
-fsanitize=address,undefined -fno-sanitize-recover=all and feed it random
inputs:

    5 seeds x 40,000 inputs = 200,000 -- no sanitizer reports

That proves the harness body runs and the ingest path survives arbitrary
input; it is not coverage-guided, and CI with a real libFuzzer toolchain is
where that happens.

CMake now reports:
    -- Fuzz targets: fuzz_sha256, fuzz_aes, fuzz_devicetree, fuzz_ota_header

Verified: ctest 26/28, the two Not-Run being targets that need services/compat,
which does not build on macOS (embeddedos-org#96's subject).

Stacked on embeddedos-org#102, which repairs the macOS HAL link.
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 14, 2026
Follow-up to the review on embeddedos-org#75:

    "the refusal uses fprintf(stderr, ...) while the tree has EOS_ERROR in
     core/include/eos/log.h, which is what core/src/config.c uses. On a target
     with no hosted stdio this is the one line in the change that will not
     build."

Correct, and it is live on master. eos_ota_apply()'s refusal path was the only
reason services/ota/src/ota.c included <stdio.h> at all, so the include goes
with it -- an OTA service that cannot compile without a hosted stdio is not
much use to the targets this project exists for.

EOS_ERROR expands to eos_log(), which lives in eos_core, so eos_ota now links
it. That is a new edge in the dependency graph and worth being explicit about:
eos_core is core/src/{log,config,graph,cache,lockfile,layer,scheduler}.c and
does not depend on eos_ota, so it introduces no cycle.

Message text is unchanged apart from dropping the trailing newline, which
eos_log adds, and the "eos-ota:" prefix, which the log layer supplies.

Verified: eos_ota builds clean; ctest 26/28, the two Not-Run being
test_os_adapter and test_os_services, which need services/compat and do not
build on macOS (pthread_mutex_timedlock/sem_timedwait -- embeddedos-org#96's subject, not
this). test_ota itself is 9/9 including the four authenticator cases that
exercise this exact path.

Stacked on embeddedos-org#102, which repairs the macOS HAL link.
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 14, 2026
CI configures -DCMAKE_BUILD_TYPE=Release. Release defines NDEBUG. NDEBUG
deletes assert() *together with the expression inside it*. These suites express
nearly every check as assert():

    684 assert() calls across 16 test files

All of them compile to nothing in CI, and the tests they belong to pass by
reaching the end of main(). Demonstrated with the exact flags CI uses:

    $ clang -DNDEBUG -o t t.c   # int main(void){ assert(1 == 2); ... }
    REACHED END: the assert did nothing
    exit=0

This is not theoretical. test_net wrapped the calls under test in assert():

    assert(eos_net_connect(c, &addr) == 0);
    assert(eos_net_send(c, "ping", 4) == 4);

Under Release no client ever connected, the echo thread blocked in accept()
forever, and the whole suite hung until ctest killed it -- the hang I fixed in
the previous commit. Every other assert() in the tree is the same construction;
that one was simply the only one whose deleted expression also blocked.

Stripping NDEBUG from test targets is a one-line change that makes all 684
meaningful without rewriting a single call site. It applies to the test
binaries only -- the libraries under test are still compiled exactly as CI
builds them for release, so this does not turn the suite into a debug-build
test of a debug-build library.

Verified on macOS (Release): every test that builds passes with assertions
live -- test_kernel (89 asserts), test_priority_inherit (49), test_power (26),
test_net (32), test_motor_ctrl (15) among them, roughly 211 assertions now
actually executing. The remaining targets do not link here for an unrelated
reason: services/compat/src/os_adapter_posix.c uses pthread_mutex_timedlock()
and sem_timedwait(), which macOS does not provide (that is embeddedos-org#96's subject, not
this one). Linux CI builds all 28 and is the real check.

Stacked on embeddedos-org#102, which repairs the macOS HAL link so this could be evaluated
locally at all.
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.

1 participant