From ae12834d131910fc4a8a48299512c011d7dfe6d4 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Sun, 2 Aug 2026 15:22:59 -0300 Subject: [PATCH 1/2] ci: compile for FreeBSD before merge, not after (#402); drop the macro probe The MNT_NODEV break reached main because nothing in the tree compiled the FreeBSD branch of a platform conditional before merge. make ci compiles only the branch matching the host it runs on, the CI matrix is Linux, macOS and Windows, and the single FreeBSD compile that does exist lives in the release workflow: it runs after merge, it was continue-on-error, and publish deliberately excluded it from its gate. So a FreeBSD compile failure produced a green release that shipped without the FreeBSD asset and said nothing. Every pull request now cross-compiles the toolchain for FreeBSD (FREEBSD=1, zig cc against the pinned FreeBSD base sysroot, the same path the release leg uses) and builds and runs the C unit suite inside a native FreeBSD VM. Both are required. The release leg is required too, so a broken FreeBSD build fails the release rather than quietly dropping a platform. Mount options are now assembled from a table of the flags the platform actually defines, replacing the format string that substituted an empty string for a missing flag. A flag the OS does not have is absent from the table, so FreeBSD reports no nodev state rather than reporting it as off. Removes ci-optional-macros, added one release ago to approximate a missing MNT_NODEV by preprocessing the source and recompiling it. Compiling for the real platform supersedes it, and it was the weaker instrument twice over: it depended on a hand-maintained list of file/macro/anchor triples that every future guard had to be added to by hand, and both its awk line-insertion and its hardcoded -std= diverged from the real build before it ever caught anything. make ci is back to 9 steps. Also finishes #1368, which landed without either: fs_is_socket and os_user_id_raw were the only functions in their modules defined with no declaration in the header, and docs/stdlib-reference.md still described the stat kind encoding as 1 through 4 with no mention of fs.fs_is_socket or os.user_id. Verified: 229/229 C unit tests; aether_fs.c compiles -Werror clean both with MNT_NODEV present and with it forced absent, which is the shape that broke; test_std_fs_mounts.ae and test_fs_stat_kind_socket_fifo.ae pass. Closes #402 Closes #1368 --- .github/workflows/ci.yml | 125 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 24 ++++--- CHANGELOG.md | 44 ++++++++++++ CONTRIBUTING.md | 2 +- Makefile | 65 +++--------------- README.md | 2 +- docs/stdlib-reference.md | 9 ++- std/fs/aether_fs.c | 44 ++++++++---- std/fs/aether_fs.h | 7 ++ std/os/aether_os.h | 6 ++ 10 files changed, 243 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a173585..ed7bed88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,131 @@ jobs: timeout-minutes: 20 run: make test-macos-leaks + # ============================================================ + # FreeBSD cross-compile gate (#402) + # + # Why this exists: the FreeBSD spelling of every `#ifdef` in the + # tree used to be compiled in exactly one place, the release + # workflow's build-freebsd leg, which runs only AFTER merge and was + # best-effort. A FreeBSD-only break therefore passed PR CI, passed + # the release, and surfaced only when somebody built on FreeBSD. + # That is how MNT_NODEV (removed in FreeBSD 10, still present on + # macOS and OpenBSD) reached main. + # + # `make ci` cannot catch this on any single host: it compiles only + # the branch of each platform conditional that matches the runner. + # Compiling FOR FreeBSD is the only thing that reads the FreeBSD + # branches, so it has to happen before merge, and it has to be + # required. No continue-on-error here on purpose. + # ============================================================ + ci-freebsd-cross: + name: FreeBSD / cross-compile (x86_64) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Install toolchain + run: sudo apt-get update && sudo apt-get install -y gcc make bc curl xz-utils + + # The pinned zig and the FreeBSD base sysroot (headers + libc) live in the + # sibling aether-crossbuild repo, same as the release leg uses. zig cc does + # not bundle a FreeBSD libc, so the base is what makes FreeBSD headers + # visible to the compile. + - name: Checkout aether-crossbuild + uses: actions/checkout@v4 + with: + repository: aether-lang-dev/aether-crossbuild + path: crossbuild + fetch-depth: 1 + + # Cache the downloaded tarballs, not the unpacked trees: get-zig.sh and + # fetch-freebsd-base.sh both skip the download when the tarball is already + # present and then re-extract, and fetch-freebsd-base.sh deletes and + # rebuilds its sysroot dir every run. Keyed on deps.lock, which pins both + # URLs and their checksums, so a hit is always the pinned bytes. + - name: Restore cross-toolchain download cache + id: cross_cache + uses: actions/cache/restore@v4 + with: + path: | + crossbuild/toolchain + crossbuild/work/downloads + key: freebsd-cross-${{ hashFiles('crossbuild/deps.lock') }} + + - name: Provision zig + FreeBSD base sysroot + id: prov + run: | + cd crossbuild + ./scripts/get-zig.sh + ./scripts/fetch-freebsd-base.sh x86_64 15 + ZIG="$(find "$PWD/toolchain" -maxdepth 2 -name zig -type f | head -1)" + SR="$PWD/bases/x86_64-freebsd15" + test -x "$ZIG" || { echo "zig not provisioned"; exit 1; } + test -f "$SR/lib/libc.so.7" || { echo "FreeBSD base sysroot missing libc.so.7"; exit 1; } + echo "zig=$ZIG" >> "$GITHUB_OUTPUT" + echo "sysroot=$SR" >> "$GITHUB_OUTPUT" + + - name: Save cross-toolchain download cache (merge to main, on miss only) + if: >- + github.event_name == 'push' && github.ref == 'refs/heads/main' + && steps.cross_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + crossbuild/toolchain + crossbuild/work/downloads + key: freebsd-cross-${{ hashFiles('crossbuild/deps.lock') }} + + - name: Cross-build for FreeBSD (FREEBSD=1) + run: | + make compiler ae stdlib \ + FREEBSD=1 \ + ZIG="${{ steps.prov.outputs.zig }}" \ + AETHER_SYSROOT="${{ steps.prov.outputs.sysroot }}" + # A cross-build that silently produced Linux binaries would pass the + # compile and prove nothing, so confirm the target really is FreeBSD. + file build/aetherc build/ae + file build/ae | grep -q 'FreeBSD' || { echo "ae is not a FreeBSD binary"; exit 1; } + + # ============================================================ + # FreeBSD native build + test (#402) + # + # The cross gate above proves the FreeBSD branches COMPILE. It + # cannot run anything: a FreeBSD ELF needs a FreeBSD kernel. This + # job boots a real FreeBSD VM and runs the C unit suite, so the + # runtime, scheduler (kqueue poller) and stdlib are exercised on + # the platform rather than merely type-checked for it. + # + # Scope is `make test` (the 229 C unit tests), not the full + # `make ci`: the .ae suites and examples shell out through the + # freshly built toolchain and would put a multi-hour build inside + # an emulated VM. The C suite is where the platform-specific + # runtime and stdlib code actually lives. + # ============================================================ + ci-freebsd-native: + name: FreeBSD / native build + unit tests + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + + - name: Build and test inside a FreeBSD VM + uses: vmactions/freebsd-vm@v1 + with: + release: '14.2' + usesh: true + # FreeBSD base ships clang and BSD make; this tree needs GNU make. + # bc and pkgconf are what the Makefile probes with. + prepare: | + pkg install -y gmake bc pkgconf + run: | + set -e + gmake compiler ae stdlib + gmake test + # ============================================================ # contrib/host bridge check — syntax check of every host bridge # in stub mode (always-on), plus end-to-end build+link+run of diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10f3fe08..550d00c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -442,17 +442,18 @@ jobs: # cc (Makefile FREEBSD=1 mode), against a FreeBSD base sysroot # # provisioned from the aether-crossbuild repo. # # # - # continue-on-error: this leg is BEST-EFFORT — a provisioning hiccup # - # (mirror down, upstream base moved) must never block the release of # - # the four first-class platforms. The artifact is also UNTESTED (no # - # FreeBSD to run `make test` on); it is validated only as a correct # - # FreeBSD ELF. If the leg fails or is skipped, publish simply omits # - # the FreeBSD asset. # + # This leg is REQUIRED, not best-effort. It used to be # + # continue-on-error with publish deliberately excluding it from the # + # gate, so a FreeBSD compile failure produced a green release that # + # silently shipped without the FreeBSD asset. That is precisely how # + # the MNT_NODEV break stayed invisible after merge (#402). A broken # + # FreeBSD build is now a broken release. PR CI cross-compiles for # + # FreeBSD on every pull request, so a code break is caught before it # + # can ever reach this point. # # ================================================================== # build-freebsd: name: Build -- freebsd-x86_64 (cross) needs: tag - continue-on-error: true if: | always() && ( (needs.tag.result == 'success' && needs.tag.outputs.tag != '') || @@ -548,13 +549,14 @@ jobs: # ================================================================== # publish: name: Publish release - # build-freebsd is in `needs` only so publish waits for its artifact to be - # available — it is NOT in the `if` gate, so a failed/skipped FreeBSD leg - # (continue-on-error) never blocks the release; publish just omits the asset. + # build-freebsd is in the `if` gate, not just in `needs`: a release that + # quietly drops a platform because its build broke is worse than no + # release, and dropping it silently is what hid the last FreeBSD break. needs: [tag, build, build-freebsd] runs-on: ubuntu-latest if: | - always() && needs.build.result == 'success' && ( + always() && needs.build.result == 'success' + && needs.build-freebsd.result == 'success' && ( (needs.tag.result == 'success' && needs.tag.outputs.tag != '') || startsWith(github.ref, 'refs/tags/') ) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c067de..0925f6d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `main`, the release pipeline automatically replaces `[current]` with the next version number before tagging the release. +## [current] + +### Added + +- **FreeBSD is a real CI target** (#402). Every pull request now cross-compiles + the toolchain for FreeBSD (`FREEBSD=1`, zig cc against a pinned FreeBSD base + sysroot) and, separately, builds and runs the C unit suite inside a native + FreeBSD VM. Both jobs are required. Until now the only FreeBSD compile in the + tree lived in the release workflow, which runs after merge, so nothing read + the FreeBSD branch of any `#ifdef` before code landed. `make ci` cannot cover + this on its own: it compiles only the branch of each platform conditional + that matches the host it runs on. + +### Changed + +- **A failed FreeBSD build now fails the release** instead of silently shipping + without the FreeBSD asset. The leg was `continue-on-error` and deliberately + left out of the publish gate, so a broken FreeBSD build produced a green + release with a platform quietly missing. That is what kept the `MNT_NODEV` + break invisible after it merged. +- **Mount options are built from a table of the flags the platform actually + defines**, rather than a fixed format string with an empty-string substitute + for whatever is missing. A flag absent from the OS is absent from the table, + so FreeBSD (which removed `MNT_NODEV` in 10, where nodev became a no-op) + reports no nodev state instead of reporting it as off. + +### Removed + +- **The optional-macro portability probe** (`make ci-optional-macros`), added + one release ago to simulate a missing `MNT_NODEV` by preprocessing the source + and recompiling it. Compiling for FreeBSD in CI supersedes it: the probe + approximated one platform through a hand-maintained list of file/macro/anchor + triples that every future guard had to be added to by hand, and its awk + line-insertion and its hardcoded `-std=` both diverged from the real build + before it caught anything. `make ci` is back to 9 steps. + +### Fixed + +- `fs_is_socket` and `os_user_id_raw` (#1368) were defined without declarations + in `std/fs/aether_fs.h` and `std/os/aether_os.h`, the only functions in + either module missing a prototype. Documented the new stat kinds, + `fs.fs_is_socket` and `os.user_id()` in `docs/stdlib-reference.md`, where the + kind encoding still described only kinds 1 through 4. + ## [0.473.0] ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4884655..705014bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -244,7 +244,7 @@ Keep checked-in Aether source canonically formatted; CI enforces this Run the full CI suite locally, this is the same suite that GitHub Actions runs: ```bash -make ci # Full 10-step suite with -Werror (compiler, tests, examples, smoke tests) +make ci # Full 9-step suite with -Werror (compiler, tests, examples, smoke tests) HARDEN=1 make ci # Hardened-build sweep (-fstack-protector-all + _FORTIFY_SOURCE=2); # required for any PR that touches C in compiler/, runtime/, or std/. # Catches unchecked memcpy / printf-format-injection bugs early. diff --git a/Makefile b/Makefile index 45eaaf65..3639afc0 100644 --- a/Makefile +++ b/Makefile @@ -2118,41 +2118,38 @@ ci: clean @echo " Parallel: $(NPROC) jobs (build) / $(NPROC) (.ae tests) / $${SH_NPROC:-1} (shell tests)" @echo "===================================" @echo "" - @echo "[0/10] Restoring miniaudio object cache (if valid)..." + @echo "[0/9] Restoring miniaudio object cache (if valid)..." @$(MAKE) audio-cache-restore @echo "" - @echo "[1/10] Building compiler (-Werror)..." + @echo "[1/9] Building compiler (-Werror)..." @$(MAKE) -j$(NPROC) compiler EXTRA_CFLAGS=-Werror @$(MAKE) audio-cache-save @echo "" - @echo "[2/10] Building ae CLI..." + @echo "[2/9] Building ae CLI..." @$(MAKE) -j$(NPROC) ae @echo "" - @echo "[3/10] Building stdlib..." + @echo "[3/9] Building stdlib..." @$(MAKE) -j$(NPROC) stdlib @echo "" - @echo "[4/10] Running C unit tests..." + @echo "[4/9] Running C unit tests..." @$(MAKE) -j$(NPROC) test @echo "" - @echo "[5/10] Running .ae integration tests..." + @echo "[5/9] Running .ae integration tests..." @$(MAKE) test-ae @echo "" - @echo "[6/10] Building examples..." + @echo "[6/9] Building examples..." @$(MAKE) examples @echo "" - @echo "[7/10] Install smoke test..." + @echo "[7/9] Install smoke test..." @$(MAKE) test-install @echo "" - @echo "[8/10] ae test smoke check..." + @echo "[8/9] ae test smoke check..." @AETHER_HOME="" ./build/ae test examples/basics/hello.ae 2>&1 | tail -1 @echo " [PASS] ae test runs correctly" @echo "" - @echo "[9/10] Release archive smoke test..." + @echo "[9/9] Release archive smoke test..." @$(MAKE) test-release-archive @echo "" - @echo "[10/10] Optional-macro portability probe..." - @$(MAKE) ci-optional-macros - @echo "" @echo "===================================" @echo " CI PASSED — all checks green" @echo "===================================" @@ -2261,7 +2258,7 @@ asan-check: clean fi @echo "✓ ASan clean — no memory errors detected" -.PHONY: ci-optional-macros all compiler lsp apkg ae profiler docgen docs-server docs docs-serve test test-build test-valgrind test-asan test-macos-leaks test-memory test-manual-runtime test-cross test-install test-release-archive benchmark benchmark-ui examples run compile repl clean help self-test install stats stdlib stdlib-asan stdlib-memory stdlib-dbg ci ci-windows docker-ci docker-ci-windows docker-build-ci valgrind-check asan-check ci-coop ci-wasm ci-embedded ci-portability docker-ci-wasm docker-ci-embedded contrib-host-check contrib install-contrib stdlib-cov ci-coverage ci-coverage-clean ci-coverage-html +.PHONY: all compiler lsp apkg ae profiler docgen docs-server docs docs-serve test test-build test-valgrind test-asan test-macos-leaks test-memory test-manual-runtime test-cross test-install test-release-archive benchmark benchmark-ui examples run compile repl clean help self-test install stats stdlib stdlib-asan stdlib-memory stdlib-dbg ci ci-windows docker-ci docker-ci-windows docker-build-ci valgrind-check asan-check ci-coop ci-wasm ci-embedded ci-portability docker-ci-wasm docker-ci-embedded contrib-host-check contrib install-contrib stdlib-cov ci-coverage ci-coverage-clean ci-coverage-html # Cross-language benchmark UI (alias for benchmark) benchmark-ui: benchmark @@ -2561,46 +2558,6 @@ docker-ci-embedded: docker run --rm -v $(PWD):/aether -w /aether aether-embedded make ci-embedded # Run ALL portability checks (native coop + Docker WASM + Docker embedded) -# Optional-macro portability probe. Some platform macros this tree keys on -# exist on one BSD and not another: FreeBSD 10 removed MNT_NODEV while macOS -# and OpenBSD still define it, and that difference broke the FreeBSD build -# after passing macOS CI, because the fallback path had never been compiled -# anywhere. This target compiles the affected sources with each such macro -# forced absent, so both sides of every #ifdef are built on every run. -# -# The undef goes before the LAST match of the anchor, not the first: these -# files carry a no-op stub of the same function for platforms without the -# feature, and that stub sits ABOVE the system include that defines the -# macro, so undefining there would be silently reversed by the include. -# -# Add a line here whenever you guard a new optional platform macro. -ci-optional-macros: - @echo "=== Optional-macro portability probe ===" - @mkdir -p build/portability - @fail=0; \ - for probe in "std/fs/aether_fs.c:MNT_NODEV:fs_try_mounts(void) {"; do \ - src=$${probe%%:*}; rest=$${probe#*:}; \ - macro=$${rest%%:*}; anchor=$${rest#*:}; \ - out="build/portability/$$(basename $$src)"; \ - awk -v a="$$anchor" -v m="$$macro" \ - 'NR==FNR { if (index($$0, a)) last=FNR; next } \ - FNR==last { print "#undef " m } { print }' \ - "$$src" "$$src" > "$$out"; \ - printf ' %-28s without %s ... ' "$$(basename $$src)" "$$macro"; \ - if $(CC) -std=gnu11 -Werror -fsyntax-only \ - -I"$$(dirname $$src)" $(CFLAGS) "$$out" \ - 2>build/portability/err.log; then \ - echo "OK"; \ - else \ - echo "FAILED"; sed 's/^/ /' build/portability/err.log; fail=1; \ - fi; \ - done; \ - if [ $$fail -ne 0 ]; then \ - echo " A guarded platform macro's fallback path does not compile."; \ - exit 1; \ - fi - @echo " All optional-macro fallback paths compile." - ci-portability: ci-coop docker-ci-wasm docker-ci-embedded @echo "" @echo "===================================" diff --git a/README.md b/README.md index ff6f52c3..74597c32 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ Same file is config, validation, conditional logic, and the entry point. No seco ### Running Tests ```bash -# Full CI suite (10 steps, -Werror), runs on your current platform +# Full CI suite (9 steps, -Werror), runs on your current platform make ci # Unit tests only (runtime C test suite) diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index fcad94d0..c72d4989 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -803,7 +803,7 @@ main() { - `dir.list(path)` → `(ptr, string)` - List contents (caller must `dir.list_free`) - `dir.list_count(list)` → `int` - Number of entries - `dir.list_get(list, index)` → `string` - Entry name at `index` -- `dir.list_kind(list, index)` → `int` - Entry's file kind from readdir's `d_type`, avoiding a `stat(2)` per entry: 1 = file, 2 = directory, 3 = symlink (target not followed), 4 = other; 0 = unknown (the filesystem didn't report a type, stat that entry to resolve it). Same encoding as `file_stat`'s kind. +- `dir.list_kind(list, index)` → `int` - Entry's file kind from readdir's `d_type`, avoiding a `stat(2)` per entry: 1 = file, 2 = directory, 3 = symlink (target not followed), 4 = other, 5 = socket, 6 = FIFO, 7 = device; 0 = unknown (the filesystem didn't report a type, stat that entry to resolve it). Same encoding as `file_stat`'s kind. Kinds 5-7 are POSIX-only: Windows reports those nodes as 4. The named constants `fs.STAT_KIND_FILE`, `STAT_KIND_DIR`, `STAT_KIND_SYMLINK`, `STAT_KIND_OTHER`, `STAT_KIND_SOCKET`, `STAT_KIND_FIFO` and `STAT_KIND_DEVICE` are exported from `std.fs`, prefer them over the bare numbers. - `dir.exists(path)` - 1 if a **directory** is at `path`, 0 otherwise. Returns 0 for regular files, even if they exist, see `fs.exists` for the path-agnostic check. - `dir.list_free(list)` - Free directory listing @@ -818,7 +818,8 @@ import std.fs // Walk: the callback sees every entry with its kind and depth. n, err = fs.walk(root, |path: string, kind: int, depth: int| { - // kind: 1 file / 2 dir / 3 symlink / 4 other (same as file_stat) + // kind: 1 file / 2 dir / 3 symlink / 4 other / 5 socket / 6 fifo / 7 device + // (same encoding as file_stat; see fs.STAT_KIND_*) if kind == 2 && string.ends_with(path, "/node_modules") == 1 { return 1 // skip this subtree } @@ -899,7 +900,8 @@ main() { - `fs.rename(from, to)` → `string` - POSIX `rename(2)` wrapper. Atomic when source and target are on the same filesystem. - `fs.create_dir_with_mode(path, mode)` → `string` - Like `fs.create_dir` but takes an explicit POSIX mode (0777-masked). Use this for private dirs (e.g. `0o700` for keys), sets the bits at creation time, closing the `mkdir` → `chmod` race window. Windows ignores the mode at the directory layer; the parameter is accepted for portability. - `fs.mtime(path)` → `(int, string)` - File's mtime as Unix epoch seconds, in the standard `(value, err)` shape. Distinguishes "stat failed" from "file's mtime is 0 (1970 epoch)", the older `file_mtime` extern collapsed both into a single 0 sentinel and is kept only for back-compat. -- `fs.file_stat(path)` → `(kind, size, mtime, err)` - One `lstat(2)`; symlinks report kind 3, target is not followed. +- `fs.file_stat(path)` → `(kind, size, mtime, err)` - One `lstat(2)`; symlinks report kind 3, target is not followed. Kind is 1 = file, 2 = directory, 3 = symlink, 4 = other, 5 = socket, 6 = FIFO, 7 = device (char and block devices share 7). Sockets, FIFOs and devices are distinguished on POSIX only; Windows reports them as 4. Use the `fs.STAT_KIND_*` constants rather than the literals. +- `fs.fs_is_socket(path)` → `int` - 1 if `path` is a UNIX-domain socket, 0 otherwise (including when nothing is there). Unlike `fs.fs_is_symlink` this **follows** symlinks, since a caller asking "is this a socket" wants the target. POSIX only; always 0 on Windows. Pair it with `os.user_id()` to locate a per-user runtime socket such as `/run/user/${os.user_id()}/podman/podman.sock`. - `fs.read_binary(path)` → `(content, length, err)` - Length-aware read preserving embedded NULs. ### Structured-error pilot @@ -1934,6 +1936,7 @@ main() { - `os.setenv(name, value)` → `string` - Set environment variable, returns "" on success or an error string. Same C-side function as `io.setenv` use `os.setenv` when you've already imported `std.os` for `os.getenv`. - `os.unsetenv(name)` → `string` - Unset environment variable, returns "" on success or an error string. Same C-side function as `io.unsetenv`. - `os.getpid()` → `int` - Process identifier of the current process. POSIX `getpid(2)`; Windows `_getpid()`. Useful for tmpfile names (`/tmp/myprog.${os.getpid()}.tmp`), per-process locks, log prefixes, and stable tagging across forked children. Returns 0 on platforms compiled without filesystem support. +- `os.user_id()` → `int` - Effective user id of the calling process (POSIX `geteuid(2)`). Windows has no numeric uid model and returns -1, so treat any negative result as "unavailable" rather than as a uid. Mainly for building per-user runtime paths like `/run/user/${os.user_id()}/`. - `os.now_utc_iso8601()` → `string` - Current UTC time as ISO-8601 (`YYYY-MM-DDThh:mm:ssZ`). Returns `""` (never null) on clock/format failure. Thread-safe. - `os.wall_seconds()` → `long` - Whole seconds since the Unix epoch (POSIX `gettimeofday`; Windows `GetSystemTimeAsFileTime`). NTP-jumpable, pair with `wall_micros` for sub-second precision, or use the monotonic accessors below for elapsed-time measurements. - `os.wall_micros()` → `int` - Sub-second microsecond fraction (0..999999) from the same `struct timeval` as `wall_seconds`. diff --git a/std/fs/aether_fs.c b/std/fs/aether_fs.c index 8fc21a9d..40c50854 100644 --- a/std/fs/aether_fs.c +++ b/std/fs/aether_fs.c @@ -1171,26 +1171,40 @@ int fs_try_mounts(void) { * this body would not compile. It falls through to the unsupported * branch and reports the error rather than shipping a shape nobody * has built, which is how the MNT_NODEV break below reached CI. */ + /* The optional mount flags this platform actually has. A flag is listed + * only where the OS defines it, so the table IS the platform's flag set + * rather than a full list with substitutes for the missing entries. + * MNT_NODEV is the reason this is a table: nodev became a no-op on + * FreeBSD and the macro was removed in FreeBSD 10, so a FreeBSD mount + * has no nodev state and must report none, while macOS and OpenBSD + * still carry it. MNT_RDONLY is not here because it is not optional: + * every entry reports ro or rw. */ + static const struct { unsigned long long mask; const char* name; } + k_optional_mount_flags[] = { + { MNT_NOSUID, ",nosuid" }, +#ifdef MNT_NODEV + { MNT_NODEV, ",nodev" }, +#endif + { MNT_NOEXEC, ",noexec" }, + }; struct statfs* mntbuf = NULL; int n = getmntinfo(&mntbuf, MNT_NOWAIT); if (n <= 0) return -1; for (int i = 0; i < n; i++) { - /* MNT_NODEV is not universal: FreeBSD deprecated it (it became a - * no-op) and removed the macro in FreeBSD 10, while macOS and - * OpenBSD still define it. Probe the macro, not the platform, so - * a future removal elsewhere degrades to omitting the flag - * instead of failing the build. */ -#ifdef MNT_NODEV - const char* nodev_opt = (mntbuf[i].f_flags & MNT_NODEV) ? ",nodev" : ""; -#else - const char* nodev_opt = ""; -#endif char opts[128]; - snprintf(opts, sizeof(opts), "%s%s%s%s", - (mntbuf[i].f_flags & MNT_RDONLY) ? "ro" : "rw", - (mntbuf[i].f_flags & MNT_NOSUID) ? ",nosuid" : "", - nodev_opt, - (mntbuf[i].f_flags & MNT_NOEXEC) ? ",noexec" : ""); + int used = snprintf(opts, sizeof(opts), "%s", + (mntbuf[i].f_flags & MNT_RDONLY) ? "ro" : "rw"); + if (used < 0) return -1; + for (size_t k = 0; + k < sizeof(k_optional_mount_flags) / sizeof(k_optional_mount_flags[0]); + k++) { + if (!((unsigned long long)mntbuf[i].f_flags & k_optional_mount_flags[k].mask)) + continue; + int w = snprintf(opts + used, sizeof(opts) - (size_t)used, "%s", + k_optional_mount_flags[k].name); + if (w < 0 || (size_t)w >= sizeof(opts) - (size_t)used) break; + used += w; + } if (!fs_mounts_append(mntbuf[i].f_mntfromname, mntbuf[i].f_mntonname, mntbuf[i].f_fstypename, opts)) { fs_release_mounts(); diff --git a/std/fs/aether_fs.h b/std/fs/aether_fs.h index d173a299..66c7dbe5 100644 --- a/std/fs/aether_fs.h +++ b/std/fs/aether_fs.h @@ -81,12 +81,19 @@ int fs_mkdir_p_raw(const char* path); // follow), 0 otherwise. Pure boolean query — no wrapper // needed, matches file_exists / dir_exists shape. // +// fs_is_socket: returns 1 if `path` is a UNIX-domain socket, 0 +// otherwise (including when the path does not exist). +// DOES follow symlinks, unlike fs_is_symlink: a caller +// asking "is this a socket" wants the target. POSIX +// only; returns 0 on Windows. +// // fs_unlink_raw: remove a file or symlink. Will NOT remove a directory // — use dir_delete_raw for that. Returns 1 on success, // 0 on failure. int fs_symlink_raw(const char* target, const char* link_path); char* fs_readlink_raw(const char* path); int fs_is_symlink(const char* path); +int fs_is_socket(const char* path); int fs_unlink_raw(const char* path); // Non-atomic binary write to `path` — opens "wb", writes exactly diff --git a/std/os/aether_os.h b/std/os/aether_os.h index 51b35b3c..10bbee8a 100644 --- a/std/os/aether_os.h +++ b/std/os/aether_os.h @@ -149,6 +149,12 @@ char* os_platform_raw(void); // _getpid(). Returns 0 on platforms without filesystem (no-op stub). int os_getpid_raw(void); +// Effective user id of the calling process. POSIX geteuid(2). Windows +// has no numeric uid model, so it returns -1 there and on +// no-filesystem builds; callers treat a negative result as +// "unavailable" rather than as a real uid. +int os_user_id_raw(void); + // Wall-clock time as the two fields of POSIX struct timeval: whole // seconds since the Unix epoch, and the sub-second microsecond // fraction (0..999999). POSIX gettimeofday(2); Windows From cc9cabf188e37d0d30a42ba08d3a0dbbbe8f479e Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Sun, 2 Aug 2026 15:27:49 -0300 Subject: [PATCH 2/2] ci: run the FreeBSD VM suite post-merge, keep the cross gate on every PR A compile break can only be caught by compiling, so the pre-merge check has to be the real cross-compile; there is no cheaper smoke test for it, and it runs in a few minutes. The native VM job is the expensive one, and what it adds over the cross gate is runtime divergence rather than compile coverage, so it moves to push-to-main. Merges here are frequent enough that this is continuous coverage, not a nightly. This does not reintroduce what hid the MNT_NODEV break. That hid because its check was continue-on-error and excluded from the release gate, so failing looked like passing. Both FreeBSD jobs and the release leg hard-fail. Also fixes the VM prepare step: bc ships in FreeBSD base and is not a package, so pkg install bc failed the run outright. --- .github/workflows/ci.yml | 25 +++++++++++++++++++++---- CHANGELOG.md | 19 +++++++++++-------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed7bed88..e4c51fa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,7 +205,7 @@ jobs: file build/ae | grep -q 'FreeBSD' || { echo "ae is not a FreeBSD binary"; exit 1; } # ============================================================ - # FreeBSD native build + test (#402) + # FreeBSD native build + test (#402) — POST-MERGE # # The cross gate above proves the FreeBSD branches COMPILE. It # cannot run anything: a FreeBSD ELF needs a FreeBSD kernel. This @@ -213,6 +213,21 @@ jobs: # runtime, scheduler (kqueue poller) and stdlib are exercised on # the platform rather than merely type-checked for it. # + # Why this one runs on push to main and not on pull requests: + # booting a VM, installing packages and building the tree from + # scratch costs tens of minutes, and what it adds over the cross + # gate is RUNTIME divergence, which is far rarer than a compile + # break and rarely PR-specific. The compile-break class, which is + # what actually broke FreeBSD, is fully covered before merge by + # the cross gate. Every merge to main runs this, so coverage is + # continuous rather than scheduled. + # + # What makes this safe as a post-merge check is that it HARD + # FAILS. The MNT_NODEV break did not hide because its check ran + # after merge; it hid because that check was continue-on-error + # and excluded from the release gate, so failing looked exactly + # like passing. Nothing here is allowed to fail quietly. + # # Scope is `make test` (the 229 C unit tests), not the full # `make ci`: the .ae suites and examples shell out through the # freshly built toolchain and would put a multi-hour build inside @@ -221,6 +236,7 @@ jobs: # ============================================================ ci-freebsd-native: name: FreeBSD / native build + unit tests + if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 60 @@ -232,10 +248,11 @@ jobs: with: release: '14.2' usesh: true - # FreeBSD base ships clang and BSD make; this tree needs GNU make. - # bc and pkgconf are what the Makefile probes with. + # FreeBSD base ships clang, bc and BSD make; this tree needs GNU make, + # and pkgconf for the Makefile's optional-library probes. bc is NOT a + # package here (it lives in base), so asking pkg for it fails the run. prepare: | - pkg install -y gmake bc pkgconf + pkg install -y gmake pkgconf run: | set -e gmake compiler ae stdlib diff --git a/CHANGELOG.md b/CHANGELOG.md index 0925f6d2..5e49bdcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,14 +13,17 @@ next version number before tagging the release. ### Added -- **FreeBSD is a real CI target** (#402). Every pull request now cross-compiles - the toolchain for FreeBSD (`FREEBSD=1`, zig cc against a pinned FreeBSD base - sysroot) and, separately, builds and runs the C unit suite inside a native - FreeBSD VM. Both jobs are required. Until now the only FreeBSD compile in the - tree lived in the release workflow, which runs after merge, so nothing read - the FreeBSD branch of any `#ifdef` before code landed. `make ci` cannot cover - this on its own: it compiles only the branch of each platform conditional - that matches the host it runs on. +- **FreeBSD is a real CI target** (#402), split by what each check can catch. + Every pull request cross-compiles the toolchain for FreeBSD (`FREEBSD=1`, + zig cc against a pinned FreeBSD base sysroot): a compile break can only be + caught by compiling, and this is fast and deterministic. Every merge to main + additionally builds the tree and runs the C unit suite inside a native + FreeBSD VM, which is what covers runtime divergence (the kqueue poller) and + costs too much to put in front of every pull request. Both hard-fail. Until + now the only FreeBSD compile in the tree lived in the release workflow, so + nothing read the FreeBSD branch of any `#ifdef` before code landed. `make ci` + cannot cover this on its own: it compiles only the branch of each platform + conditional that matches the host it runs on. ### Changed