diff --git a/apps/starry/nix/README.md b/apps/starry/nix/README.md new file mode 100644 index 0000000000..25998550dd --- /dev/null +++ b/apps/starry/nix/README.md @@ -0,0 +1,112 @@ +# Starry Nix App + +This case runs a minimal Nix smoke test inside StarryOS through the app runner. +A prebuilt Nix is injected into the Alpine rootfs; the guest evaluates a Nix +expression and verifies a store-path write, covering the +install→startup→evaluate→artifact chain. + +```bash +cargo xtask starry app qemu -t nix --arch x86_64 +cargo xtask starry app qemu -t nix --arch aarch64 +``` + +## No-Sandbox Only + +Sandboxed `nix-build` is not yet enabled. Nix requires mount namespace +isolation to activate the build sandbox, and StarryOS mount namespace support is +still incomplete. Without working `unshare(CLONE_NEWNS)`, Nix prints: + +> auto-disabling sandboxing because the prerequisite namespaces are not +> available + +and silently falls back to unsandboxed mode. Running the sandbox variant as-is +would produce a **false PASS** — the build succeeds, but the claimed +sandboxed-build behaviour was never exercised. + +Per the [discussion](https://github.com/rcore-os/tgoskits/pull/1125#issuecomment-4639168301) +on PR #1125: the teacher advised small-step iteration and submitting +no-sandbox first. The current version uses `builtins.toFile` for store-path +creation, which verifies Nix evaluation and store writes without depending on +the builder communication protocol (socketpair). Full `builtins.derivation` +builder workflow is deferred until the builder protocol (Nix socketpair hook) +is working on StarryOS — currently blocked by a poll notification gap in the +IRQ-safe deferred notification layer. + +For now `test_nix.sh` intentionally skips the sandbox test and only runs +`nix-nosandbox`, which passes `--option sandbox false` explicitly so the result +honestly reflects the exercised code path. The sandbox test (`nix.sh`) will be +connected once mount namespace isolation is available in StarryOS. + +## Test Content + +| Script | Mode | Runs? | +|--------|------|-------| +| `nix-nosandbox` | `builtins.toFile` store-path write (no builder) | ✅ CI | +| `nix` | `nix-build --option sandbox true` (full derivation builder) | ❌ blocked (mount ns + socketpair) | + +`test_nix.sh` runs only the `nix-nosandbox` phase. The sandbox test (`nix.sh`) +is blocked until mount namespace isolation is ready. + +nixpkgs / `stdenv.mkDerivation` testing is tracked on a separate branch and is +intentionally not part of this smoke test; it requires mount namespace +isolation and a working `builtins.fetchTarball` download path that StarryOS +does not yet provide. + +## Kernel Regression Tests + +Kernel-level semantics (pipe poll, pidfd, rsext4 open-unlink, mount namespace +isolation, etc.) are covered separately by the **qemu-smp1/system** grouped suite: + +```bash +cargo xtask starry test qemu --arch x86_64 -c qemu-smp1/system +cargo xtask starry test qemu --arch aarch64 -c qemu-smp1/system +``` + +These C-language regression tests were migrated from the former +`test-nix-prereqs` case and now live alongside other kernel regression tests +under the unified system grouped suite. +See `test-suit/starryos/qemu-smp1/system/`. + +## File Structure + +``` +apps/starry/nix/ +├── prebuild.sh # apk add nix into staging rootfs +├── nix.sh # sandbox-enabled nix-build (blocked, not CI) +├── nix-nosandbox.sh # builtins.toFile store-path write (CI gate) +├── test_nix.sh # nosandbox only +├── build-x86_64-unknown-none.toml +├── build-aarch64-unknown-none-softfloat.toml +├── qemu-x86_64.toml # 1200s timeout, shell_init_cmd=test_nix.sh +├── qemu-aarch64.toml +└── README.md # this file +``` + +## Dependencies + +- Nix 2.31.5 (prebuilt via `apk add nix` in `prebuild.sh`) +- Alpine musl shared libraries (libc, libcrypto, libcurl, libgit2, libseccomp, + libsodium, libsqlite3, libssh2, etc.) +- Guest network access during prebuild only (Nix is injected into rootfs; no + network required at QEMU runtime) + +## aarch64 Verification + +`prebuild.sh` selects the correct `qemu-*-static` binary by `STARRY_ARCH`. +Both architectures have been verified end-to-end in the CI container: + +```bash +# x86_64 +podman run --rm \ + -v "$(pwd)":/workspace -w /workspace \ + ghcr.io/rcore-os/tgoskits-container:latest \ + cargo xtask starry app qemu -t nix --arch x86_64 +# → NIX_NOSANDBOX_COMPLETE + +# aarch64 +podman run --rm \ + -v "$(pwd)":/workspace -w /workspace \ + ghcr.io/rcore-os/tgoskits-container:latest \ + cargo xtask starry app qemu -t nix --arch aarch64 +# → NIX_NOSANDBOX_COMPLETE +``` diff --git a/apps/starry/nix/build-aarch64-unknown-none-softfloat.toml b/apps/starry/nix/build-aarch64-unknown-none-softfloat.toml new file mode 100644 index 0000000000..f535bc06b6 --- /dev/null +++ b/apps/starry/nix/build-aarch64-unknown-none-softfloat.toml @@ -0,0 +1,8 @@ +target = "aarch64-unknown-none-softfloat" +env = { AX_IP = "10.0.2.15", AX_GW = "10.0.2.2" } +log = "Warn" +features = [ + "ax-driver/virtio-blk", + "ax-driver/virtio-net", +] +plat_dyn = true diff --git a/apps/starry/nix/build-x86_64-unknown-none.toml b/apps/starry/nix/build-x86_64-unknown-none.toml new file mode 100644 index 0000000000..3173417cb3 --- /dev/null +++ b/apps/starry/nix/build-x86_64-unknown-none.toml @@ -0,0 +1,8 @@ +target = "x86_64-unknown-none" +env = { AX_IP = "10.0.2.15", AX_GW = "10.0.2.2" } +log = "Warn" +features = [ + "ax-driver/virtio-blk", + "ax-driver/virtio-net", +] +plat_dyn = true diff --git a/apps/starry/nix/nix-nosandbox.sh b/apps/starry/nix/nix-nosandbox.sh new file mode 100755 index 0000000000..9169f9a2f3 --- /dev/null +++ b/apps/starry/nix/nix-nosandbox.sh @@ -0,0 +1,64 @@ +#!/bin/sh +set -eu + +# Create Nix subcommand symlinks (overlay doesn't support symlinks). +for cmd in build channel collect-garbage copy-closure env hash \ + instantiate prefetch-url shell store; do + ln -sf nix /usr/bin/nix-$cmd 2>/dev/null || true +done + +fail() { + echo "NIX_NOSANDBOX_ERROR: $1" + echo 'NIX_NOSANDBOX_TEST_FAILED' + exit 1 +} + +echo 'NIX_NOSANDBOX_PHASE_ROOTFS_BEGIN' +mkdir -p /nix /etc/nix /tmp/nix-nosandbox || fail 'failed to create Nix smoke directories' + +echo 'NIX_NOSANDBOX_PHASE_PREBUILT_NIX_BEGIN' +command -v nix >/dev/null 2>&1 || fail 'prebuilt Nix is missing from case rootfs' +echo 'NIX_NOSANDBOX_PHASE_PREBUILT_NIX_DONE' + +echo 'NIX_NOSANDBOX_PHASE_NIX_BEGIN' +nix --version || fail 'nix --version failed' +echo 'NIX_NOSANDBOX_PHASE_NIX_DONE' + +echo 'NIX_NOSANDBOX_PHASE_BUILD_BEGIN' + +# Use builtins.toFile for store path creation (no builder subprocess). +# This verifies Nix expression evaluation + store write without depending on +# the builder communication protocol (socketpair), which requires poll +# notification semantics not yet complete on StarryOS. +# See nix.sh (sandbox variant) for the full derivation workflow once +# mount namespace isolation and builder protocol support are available. +eval_output="/tmp/nix-nosandbox/eval_output" +rm -f "$eval_output" +set +e +nix --extra-experimental-features nix-command eval --raw --expr \ + 'builtins.toFile "nix-nosandbox" "NIX_LOCAL_BUILD_NOSANDBOX_OK"' \ + >"$eval_output" 2>/tmp/nix-nosandbox/eval.log +eval_rc=$? +set -e +if [ "$eval_rc" -ne 0 ]; then + echo 'NIX_NOSANDBOX_DIAG_EVAL_LOG_BEGIN' + cat /tmp/nix-nosandbox/eval.log + echo 'NIX_NOSANDBOX_DIAG_EVAL_LOG_END' + echo "NIX_NOSANDBOX_EVAL_EXIT=$eval_rc" + fail 'tiny local nix eval failed' +fi +store_path=$(cat "$eval_output") +store_path=$(echo "$store_path" | tr -d '\n\r') # strip trailing newlines + +echo 'NIX_NOSANDBOX_DIAG_STORE_PATH_BEGIN' +echo "$store_path" +echo 'NIX_NOSANDBOX_DIAG_STORE_PATH_END' + +if [ ! -f "$store_path" ]; then + fail "store path does not exist: $store_path" +fi + +cat "$store_path" || fail 'nix store output could not be read' +grep -q 'NIX_LOCAL_BUILD_NOSANDBOX_OK' "$store_path" || fail 'nix store output marker missing' +echo 'NIX_NOSANDBOX_PHASE_BUILD_DONE' +echo 'NIX_NOSANDBOX_TEST_PASSED' diff --git a/apps/starry/nix/nix.sh b/apps/starry/nix/nix.sh new file mode 100755 index 0000000000..6d227405ea --- /dev/null +++ b/apps/starry/nix/nix.sh @@ -0,0 +1,224 @@ +#!/bin/sh +set -eu + +# Create Nix subcommand symlinks (overlay doesn't support symlinks). +for cmd in build channel collect-garbage copy-closure env hash \ + instantiate prefetch-url shell store; do + ln -sf nix /usr/bin/nix-$cmd 2>/dev/null || true +done + +DIAG_PID=$$ +echo "NIX_DIAG_SCRIPT_PID=$DIAG_PID" + +fail() { + echo "NIX_ERROR: $1" + echo 'NIX_TEST_FAILED' + exit 1 +} + +# Dump process state for a given PID. +dump_proc_state() { + pid="$1" + label="$2" + echo "NIX_DIAG_PROCSTAT ${label}_PID=${pid}" + echo "NIX_DIAG_PROCSTAT ${label}_STATUS_BEGIN" + cat "/proc/$pid/status" 2>/dev/null || echo "NIX_DIAG_NO_PROC_STATUS" + echo "NIX_DIAG_PROCSTAT ${label}_STATUS_END" + echo "NIX_DIAG_PROCSTAT ${label}_STAT_BEGIN" + cat "/proc/$pid/stat" 2>/dev/null || echo "NIX_DIAG_NO_PROC_STAT" + echo "NIX_DIAG_PROCSTAT ${label}_STAT_END" +} + +# Dump per-thread state. Nix keeps a small worker thread pool, so the stuck +# waiter can be a different tid. +dump_thread_states() { + pid="$1" + label="$2" + echo "NIX_DIAG_THREADS ${label}_BEGIN" + for tdir in "/proc/$pid/task"/*/; do + tid=$(basename "$tdir") + case "$tid" in *[!0-9]*) continue ;; esac + comm=$(cat "$tdir/comm" 2>/dev/null || echo '?') + stat=$(cat "$tdir/stat" 2>/dev/null || echo '?') + echo "TID=$tid COMM=$comm STAT=$stat" + echo "NIX_DIAG_THREAD_FD ${label}_${tid}_BEGIN" + ls -la "$tdir/fd" 2>/dev/null || echo 'NIX_DIAG_NO_THREAD_FD' + echo "NIX_DIAG_THREAD_FD ${label}_${tid}_END" + done + echo "NIX_DIAG_THREADS ${label}_END" +} + +# Lightweight poll: just the stat line fields that show CPU progress +poll_proc_progress() { + pid="$1" + label="$2" + stat_line=$(cat "/proc/$pid/stat" 2>/dev/null || echo '?') + # Fields: pid comm state ppid ... utime(14) stime(15) cutime(16) cstime(17) ... + echo "NIX_DIAG_PROGRESS ${label} ${stat_line}" +} + +# Dump all processes state overview +dump_all_procs() { + echo 'NIX_DIAG_ALLPROC_BEGIN' + for pdir in /proc/*/; do + pid=$(basename "$pdir") + case "$pid" in *[!0-9]*) continue ;; esac + name=$(cat "$pdir/comm" 2>/dev/null || echo '?') + state=$(awk '{print $3}' "$pdir/stat" 2>/dev/null || echo '?') + echo "PID=$pid NAME=$name STATE=$state" + done + echo 'NIX_DIAG_ALLPROC_END' +} + +# Check for zombie processes +dump_zombies() { + echo 'NIX_DIAG_ZOMBIE_BEGIN' + ps -eo pid,ppid,state,comm 2>/dev/null | grep -E 'Z|zombie|defunct' || echo 'NIX_DIAG_NO_ZOMBIES' + echo 'NIX_DIAG_ZOMBIE_END' +} + +# Dump Nix store database status +dump_nix_db() { + echo 'NIX_DIAG_NIX_DB_BEGIN' + echo "NIX_DB_SIZE=$(wc -c < /nix/var/nix/db/db.sqlite 2>/dev/null || echo '?')" + echo "NIX_DB_WAL_SIZE=$(wc -c < /nix/var/nix/db/db.sqlite-wal 2>/dev/null || echo '?')" + ls -la /nix/var/nix/db/ 2>/dev/null || echo 'NIX_DIAG_NO_DB_DIR' + echo 'NIX_DIAG_NIX_DB_END' +} + +echo 'NIX_PHASE_ROOTFS_BEGIN' +mkdir -p /nix /etc/nix /tmp/nix || fail 'failed to create Nix smoke directories' + +echo 'NIX_PHASE_PREBUILT_NIX_BEGIN' +command -v nix >/dev/null 2>&1 || fail 'prebuilt Nix is missing from case rootfs' +echo 'NIX_PHASE_PREBUILT_NIX_DONE' + +echo 'NIX_PHASE_NIX_BEGIN' +nix --version || fail 'nix --version failed' +echo 'NIX_PHASE_NIX_DONE' + +echo 'NIX_PHASE_BUILD_BEGIN' +rm -f ./result +cat > /tmp/nix/default.nix <<'EOF' +derivation { + name = "nix"; + system = builtins.currentSystem; + builder = "/bin/sh"; + args = [ "-c" "mkdir -p /tmp/nix; echo BUILDER_STARTED > /tmp/nix/builder.log; echo OUT=$out >> /tmp/nix/builder.log; echo NIX_LOCAL_BUILD_OK > $out" ]; +} +EOF +echo 'NIX_INFO: tiny local sandboxed nix-build timeout is 120s' +# Run with -vvvvv for verbose Nix internal logging +nix-build -vvvvv --no-substitute --option build-users-group '' --option sandbox true /tmp/nix/default.nix >/tmp/nix/build.log 2>&1 & +build_pid=$! +build_rc=0 +build_diag_done=0 +prev_utime=0 +prev_stime=0 +for i in $(seq 1 120); do + if ! kill -0 "$build_pid" 2>/dev/null; then + set +e + wait "$build_pid" + build_rc=$? + set -e + break + fi + # Lightweight CPU progress poll every 10s starting at t=5 + if [ "$((i % 10))" -eq 5 ] 2>/dev/null; then + poll_proc_progress "$build_pid" "T${i}" + fi + if [ "$i" -eq 30 ] && [ "$build_diag_done" -eq 0 ]; then + echo 'NIX_DIAG_RUNNING_BEGIN' + ps + echo 'NIX_DIAG_RUNNING_FIND_BEGIN' + find /nix/store -maxdepth 1 -name '*-nix' -exec ls -li {} \; + find /nix/store -maxdepth 1 -name '*-nix' -exec stat {} \; 2>/dev/null || true + echo 'NIX_DIAG_RUNNING_FIND_END' + echo 'NIX_DIAG_RUNNING_FD_BEGIN' + ls -la "/proc/$build_pid/fd" 2>/dev/null || echo 'NIX_DIAG_NO_PROC_FD' + echo 'NIX_DIAG_RUNNING_FD_END' + echo 'NIX_DIAG_RUNNING_BUILD_LOG_TAIL_BEGIN' + tail -40 /tmp/nix/build.log + echo 'NIX_DIAG_RUNNING_BUILD_LOG_TAIL_END' + echo 'NIX_DIAG_PROCESS_BEGIN' + dump_proc_state "$build_pid" 'NIX_BUILD_T30' + dump_thread_states "$build_pid" 'NIX_BUILD_T30' + dump_all_procs + dump_zombies + dump_nix_db + echo 'NIX_DIAG_PROCESS_END' + echo 'NIX_DIAG_RESULT_SYMLINK_BEGIN' + ls -la ./result 2>/dev/null || echo 'NIX_DIAG_NO_RESULT_SYMLINK' + echo 'NIX_DIAG_RESULT_SYMLINK_END' + echo 'NIX_DIAG_RUNNING_END' + build_diag_done=1 + fi + # Second diag at t=80: full process state + verbose build log tail + if [ "$i" -eq 80 ] && [ "$build_diag_done" -eq 1 ]; then + if kill -0 "$build_pid" 2>/dev/null; then + echo 'NIX_DIAG_T80_BEGIN' + dump_proc_state "$build_pid" 'NIX_BUILD_T80' + dump_thread_states "$build_pid" 'NIX_BUILD_T80' + dump_all_procs + dump_zombies + dump_nix_db + echo 'NIX_DIAG_T80_BUILD_LOG_TAIL_BEGIN' + tail -60 /tmp/nix/build.log + echo 'NIX_DIAG_T80_BUILD_LOG_TAIL_END' + echo 'NIX_DIAG_T80_RESULT_SYMLINK_BEGIN' + ls -la ./result 2>/dev/null || echo 'NIX_DIAG_NO_RESULT_SYMLINK' + echo 'NIX_DIAG_T80_RESULT_SYMLINK_END' + echo 'NIX_DIAG_T80_END' + fi + fi + sleep 1 +done +if kill -0 "$build_pid" 2>/dev/null; then + kill "$build_pid" 2>/dev/null || true + wait "$build_pid" 2>/dev/null || true + build_rc=124 +fi +if [ "$build_rc" -ne 0 ]; then + echo 'NIX_DIAG_BUILDER_LOG_BEGIN' + cat /tmp/nix/builder.log 2>/dev/null || echo 'NIX_DIAG_NO_BUILDER_LOG' + echo 'NIX_DIAG_BUILDER_LOG_END' + echo 'NIX_DIAG_BUILD_LOG_FINAL_BEGIN' + cat /tmp/nix/build.log + echo 'NIX_DIAG_BUILD_LOG_FINAL_END' + echo 'NIX_DIAG_PS_BEGIN' + ps + echo 'NIX_DIAG_PS_END' + echo 'NIX_DIAG_PROCESS_FINAL_BEGIN' + dump_all_procs + dump_zombies + dump_nix_db + echo 'NIX_DIAG_PROCESS_FINAL_END' + echo 'NIX_DIAG_FIND_BEGIN' + find /nix/store -maxdepth 1 -name '*-nix' -exec ls -la {} \; + echo 'NIX_DIAG_FIND_END' + echo 'NIX_DIAG_INODE_BEGIN' + find /nix/store -maxdepth 1 -name '*-nix' -exec ls -li {} \; + find /nix/store -maxdepth 1 -name '*-nix' -exec stat {} \; 2>/dev/null || true + echo 'NIX_DIAG_INODE_END' + echo "NIX_BUILD_EXIT=$build_rc" + if [ "$build_rc" -eq 124 ] || [ "$build_rc" -eq 143 ]; then + fail 'tiny local sandboxed nix-build timed out after 120s' + fi + if grep -q 'interrupted by the user' /tmp/nix/build.log; then + fail 'tiny local sandboxed nix-build was interrupted after waiting for store lock' + fi + fail 'tiny local sandboxed nix-build failed' +fi +cat /tmp/nix/build.log +# Detect sandbox auto-disabled by Nix (missing mount namespace isolation). +# When sandbox is silently disabled, the build may still succeed, but the +# claimed sandboxed-build behavior was never exercised. This test MUST FAIL +# in that case so reviewers can see whether namespace support is ready. +if grep -qi 'disabling sandbox\|sandbox.*disabled\|sandbox.*not supported' /tmp/nix/build.log; then + echo 'NIX_INFO: sandbox was disabled by Nix (namespace isolation missing)' + fail 'nix-build sandbox was disabled unexpectedly — mount namespace isolation missing' +fi +cat ./result || fail 'nix-build result symlink could not be read' +grep -q 'NIX_LOCAL_BUILD_OK' ./result || fail 'tiny local nix-build output marker missing' +echo 'NIX_PHASE_BUILD_DONE' +echo 'NIX_TEST_PASSED' diff --git a/apps/starry/nix/prebuild.sh b/apps/starry/nix/prebuild.sh new file mode 100644 index 0000000000..f960709a8d --- /dev/null +++ b/apps/starry/nix/prebuild.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -euo pipefail + +app_dir="${STARRY_APP_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +base_rootfs="${STARRY_ROOTFS:-${STARRY_BASE_ROOTFS:-}}" +staging_root="${STARRY_STAGING_ROOT:-}" +overlay_dir="${STARRY_OVERLAY_DIR:-}" +apk_cache="${STARRY_WORKSPACE:-$(cd "$app_dir/../../.." && pwd)}/target/nix-apk-cache" + +require_env() { + local name="$1" + local value="$2" + if [[ -z "$value" ]]; then + echo "error: $name is required" >&2 + exit 1 + fi +} + +# Map STARRY_ARCH to the correct qemu-user-static binary. +# Defaults to qemu-x86_64-static when STARRY_ARCH is unset or empty +# (the prebuild runs on the host, and x86_64 is the most common host). +qemu_user_static_binary() { + case "${STARRY_ARCH:-x86_64}" in + x86_64) echo "qemu-x86_64-static" ;; + aarch64) echo "qemu-aarch64-static" ;; + riscv64) echo "qemu-riscv64-static" ;; + loongarch64) echo "qemu-loongarch64-static" ;; + *) + echo "error: unsupported STARRY_ARCH '${STARRY_ARCH}' for nix prebuild" >&2 + exit 1 + ;; + esac +} + +ensure_host_packages() { + local missing=() + + command -v debugfs >/dev/null 2>&1 || missing+=(e2fsprogs) + command -v install >/dev/null 2>&1 || missing+=(coreutils) + command -v readelf >/dev/null 2>&1 || missing+=(binutils) + command -v "$(qemu_user_static_binary)" >/dev/null 2>&1 || missing+=(qemu-user-static) + + if [[ ${#missing[@]} -eq 0 ]]; then + return + fi + + if ! command -v apt-get >/dev/null 2>&1; then + echo "error: missing required host packages and no supported package manager is available: ${missing[*]}" >&2 + exit 1 + fi + + echo "installing missing host packages: ${missing[*]}" + apt-get update + apt-get install -y --no-install-recommends "${missing[@]}" +} + +extract_base_rootfs() { + debugfs -R "rdump / $staging_root" "$base_rootfs" +} + +install_nix_package() { + if [[ -f /etc/resolv.conf ]]; then + cp /etc/resolv.conf "$staging_root/etc/resolv.conf" + fi + + mkdir -p "$apk_cache" + QEMU_LD_PREFIX="$staging_root" \ + LD_LIBRARY_PATH="$staging_root/lib:$staging_root/usr/lib" \ + $(qemu_user_static_binary) -L "$staging_root" \ + "$staging_root/sbin/apk" \ + --root "$staging_root" \ + --repositories-file "$staging_root/etc/apk/repositories" \ + --keys-dir "$staging_root/etc/apk/keys" \ + --cache-dir "$apk_cache" \ + --update-cache \ + --no-progress \ + --no-scripts \ + add nix + + mkdir -p "$staging_root/etc/nix" + cat > "$staging_root/etc/nix/nix.conf" <<'NIXCONF' +sandbox = false +build-users-group = +# sandbox = false is required because StarryOS does not yet support +# unshare(CLONE_NEWNS). Without it, builtins.fetchTarball fails in its +# download thread with "unsharing filesystem state: Invalid argument". +# Flip to sandbox = true once mount namespace isolation is available. +NIXCONF + + echo "Nix installed from Alpine apk" +} + +# The official Nix tarball path is intentionally left disabled. +# +# It installs Nix as a /nix/store closure with many symlinks. The current +# debugfs overlay injector accepts only regular files and directories, so +# preserving symlinks fails injection. Dereferencing the full closure is not +# reliable either because the official closure currently contains at least one +# broken symlink (for example libgcc_s.so in the GCC lib output), causing a +# plain `cp -aL` or tar dereference copy to fail or skip files unpredictably. +# Use Alpine's `apk add nix` path above until the overlay injector supports +# symlinks or the tarball closure is copied through a Nix-aware path. +# +# install_nix_from_official_tarball() { +# local nix_ver="2.31.5" +# local nix_url="https://releases.nixos.org/nix/nix-${nix_ver}/nix-${nix_ver}-x86_64-linux.tar.xz" +# ... +# } + +copy_file_to_overlay() { + local guest_path="$1" + local mode="$2" + local source="$staging_root${guest_path}" + local target="$overlay_dir${guest_path}" + + if [[ ! -e "$source" ]]; then + echo "error: missing guest file after Nix package install: $guest_path" >&2 + exit 1 + fi + + if [[ -L "$source" ]]; then + source="$(readlink -f "$source")" + fi + + install -Dm"$mode" "$source" "$target" +} + +find_library_path() { + local library="$1" + local dir + + for dir in lib usr/lib usr/local/lib; do + if [[ -e "$staging_root/$dir/$library" ]]; then + printf '/%s/%s\n' "$dir" "$library" + return 0 + fi + done + + return 1 +} + +copy_runtime_dependencies() { + local pending=("$@") + local seen=" " + local guest_path library + + while [[ ${#pending[@]} -gt 0 ]]; do + guest_path="${pending[0]}" + pending=("${pending[@]:1}") + + if [[ "$seen" == *" $guest_path "* ]]; then + continue + fi + seen+="$guest_path " + + if [[ -x "$staging_root$guest_path" ]]; then + copy_file_to_overlay "$guest_path" 0755 + else + copy_file_to_overlay "$guest_path" 0644 + fi + + while IFS= read -r library; do + local library_path + if ! library_path="$(find_library_path "$library")"; then + continue + fi + pending+=("$library_path") + done < <( + readelf -d "$staging_root$guest_path" 2>/dev/null | + sed -n 's/.*Shared library: \[\(.*\)\].*/\1/p' + ) + done +} + +populate_overlay() { + copy_runtime_dependencies /usr/bin/nix + + install -Dm0644 "$staging_root/etc/nix/nix.conf" "$overlay_dir/etc/nix/nix.conf" + + mkdir -p "$overlay_dir/nix/store" + + # Install test scripts. + # NOTE: nix.sh (sandbox test) is intentionally NOT injected — sandbox + # requires mount namespace isolation not yet available in StarryOS. + # The nix binary must be kept as /usr/bin/nix (already copied above). + install -Dm0755 "$app_dir/nix-nosandbox.sh" "$overlay_dir/usr/bin/nix-nosandbox" + install -Dm0755 "$app_dir/test_nix.sh" "$overlay_dir/usr/bin/test_nix.sh" + + echo "overlay populated" +} + +require_env STARRY_ROOTFS "$base_rootfs" +require_env STARRY_STAGING_ROOT "$staging_root" +require_env STARRY_OVERLAY_DIR "$overlay_dir" + +ensure_host_packages +extract_base_rootfs +install_nix_package +populate_overlay + +echo "nix prebuild complete" diff --git a/apps/starry/nix/qemu-aarch64.toml b/apps/starry/nix/qemu-aarch64.toml new file mode 100644 index 0000000000..2958ea957f --- /dev/null +++ b/apps/starry/nix/qemu-aarch64.toml @@ -0,0 +1,28 @@ +args = [ + "-nographic", + "-m", + "512M", + "-cpu", + "cortex-a53", + "-device", + "virtio-blk-pci,drive=disk0", + "-drive", + "id=disk0,if=none,format=raw,file=${workspace}/tmp/axbuild/rootfs/rootfs-aarch64-alpine.img", + "-device", + "virtio-net-pci,netdev=net0", + "-netdev", + "user,id=net0", +] +uefi = false +to_bin = true +shell_prefix = "root@starry:" +shell_init_cmd = "/usr/bin/test_nix.sh" +success_regex = [ + "(?m)^NIX_NOSANDBOX_COMPLETE\\s*$", +] +fail_regex = [ + '(?i)\\bpanic(?:ked)?\\b', + '(?i)\\bfatal\\b', + "(?m)^NIX_NOSANDBOX_TEST_FAILED\\s*$", +] +timeout = 1200 diff --git a/apps/starry/nix/qemu-x86_64.toml b/apps/starry/nix/qemu-x86_64.toml new file mode 100644 index 0000000000..621d82e252 --- /dev/null +++ b/apps/starry/nix/qemu-x86_64.toml @@ -0,0 +1,28 @@ +args = [ + "-machine", + "q35", + "-nographic", + "-m", + "512M", + "-device", + "virtio-blk-pci,drive=disk0", + "-drive", + "id=disk0,if=none,format=raw,file=${workspace}/tmp/axbuild/rootfs/rootfs-x86_64-alpine.img", + "-device", + "virtio-net-pci,netdev=net0", + "-netdev", + "user,id=net0", +] +uefi = false +to_bin = false +shell_prefix = "root@starry:" +shell_init_cmd = "/usr/bin/test_nix.sh" +success_regex = [ + "(?m)^NIX_NOSANDBOX_COMPLETE\\s*$", +] +fail_regex = [ + '(?i)\bpanic(?:ked)?\b', + '(?i)\bfatal\b', + "(?m)^NIX_NOSANDBOX_TEST_FAILED\\s*$", +] +timeout = 1200 diff --git a/apps/starry/nix/test_nix.sh b/apps/starry/nix/test_nix.sh new file mode 100644 index 0000000000..7c2060a46e --- /dev/null +++ b/apps/starry/nix/test_nix.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +# Create symlinks that can't be stored in the overlay. +for cmd in build channel collect-garbage copy-closure env hash \ + instantiate prefetch-url shell store; do + ln -sf nix /usr/bin/nix-$cmd 2>/dev/null || true +done + +echo "=== nix-nosandbox ===" +if /usr/bin/nix-nosandbox; then + echo "NIX_NOSANDBOX_TEST_PASSED" +else + echo "NIX_NOSANDBOX_TEST_FAILED" + exit 1 +fi + +echo "NIX_NOSANDBOX_COMPLETE" diff --git a/components/axfs-ng-vfs/src/mount.rs b/components/axfs-ng-vfs/src/mount.rs index f3984af15f..6935b93657 100644 --- a/components/axfs-ng-vfs/src/mount.rs +++ b/components/axfs-ng-vfs/src/mount.rs @@ -153,8 +153,8 @@ pub struct Mountpoint { root: DirEntry, /// Location in the parent mountpoint. `None` for the global root mount. location: Mutex>, - /// Children of the mountpoint. - children: Mutex>>, + /// Children of the mountpoint in this namespace-local mount tree. + children: Mutex>>, /// Device ID device: u64, /// Read-only flag for this mountpoint. @@ -215,19 +215,53 @@ impl Mountpoint { .readonly .store(source.mountpoint.is_readonly(), Ordering::Release); if recursive { - let mut children_to_bind: Vec<_> = source - .mountpoint - .children + Self::clone_children_from(&source.mountpoint, &result, true); + } + result + } + + fn clone_shallow(source: &Arc, location_in_parent: Option) -> Arc { + let result = Self::new_with_root(source.root.clone(), location_in_parent, source.device()); + result + .readonly + .store(source.is_readonly(), Ordering::Release); + *result.propagation.lock() = source.propagation(); + result + .expired + .store(source.expired.load(Ordering::Acquire), Ordering::Release); + result + } + + fn clone_children_from(source: &Arc, target: &Arc, skip_unbindable: bool) { + let children: Vec<_> = source + .children + .lock() + .iter() + .map(|(key, child)| (key.clone(), child.clone())) + .filter(|(_, child)| !(skip_unbindable && child.is_unbindable())) + .collect(); + + let mut target_children = target.children.lock(); + for (key, child) in children { + let location = child + .location .lock() - .iter() - .map(|(key, child)| (key.clone(), child.clone())) - .collect(); - children_to_bind - .retain(|(_, child)| child.upgrade().is_none_or(|child| !child.is_unbindable())); - let result_children: HashMap> = - children_to_bind.into_iter().collect(); - *result.children.lock() = result_children; + .as_ref() + .map(|loc| Location::new(target.clone(), loc.entry.clone())); + let cloned = Self::clone_shallow(&child, location); + Self::clone_children_from(&child, &cloned, skip_unbindable); + target_children.insert(key, cloned); } + } + + /// Clone this mount tree into an independent namespace-local topology. + /// + /// The returned tree shares underlying directory entries and filesystem + /// objects, but all `Mountpoint` nodes and parent/child links are private + /// to the clone. + pub fn clone_tree(self: &Arc) -> Arc { + let result = Self::clone_shallow(self, None); + Self::clone_children_from(self, &result, false); result } @@ -235,6 +269,11 @@ impl Mountpoint { Location::new(self.clone(), self.root.clone()) } + /// Returns live child mountpoints in this namespace-local mount tree. + pub fn children(&self) -> Vec> { + self.children.lock().values().cloned().collect() + } + /// Returns the location in the parent mountpoint. pub fn location(&self) -> Option { self.location.lock().clone() @@ -269,7 +308,6 @@ impl Mountpoint { let mut new_root_loc = new_root_mp.location.lock(); if let Some(ref old_loc) = *new_root_loc { self.children.lock().remove(&old_loc.entry.key()); - *old_loc.entry.as_dir()?.mountpoint.lock() = None; } // new_root becomes the global root. *new_root_loc = None; @@ -277,11 +315,10 @@ impl Mountpoint { // 2. Attach old root at put_old under new_root. { - *put_old.entry.as_dir()?.mountpoint.lock() = Some(self.clone()); new_root_mp .children .lock() - .insert(put_old.entry.key(), Arc::downgrade(self)); + .insert(put_old.entry.key(), self.clone()); *self.location.lock() = Some(put_old.clone()); } @@ -296,7 +333,13 @@ impl Mountpoint { /// return `mnt2` for `mnt1.effective_mountpoint()`. pub(crate) fn effective_mountpoint(self: &Arc) -> Arc { let mut mountpoint = self.clone(); - while let Some(mount) = mountpoint.root.as_dir().unwrap().mountpoint() { + while let Some(mount) = { + mountpoint + .children + .lock() + .get(&mountpoint.root.key()) + .cloned() + } { mountpoint = mount; } mountpoint @@ -414,11 +457,11 @@ impl Mountpoint { } fn attach_child(parent: &Arc, location: Location, child: &Arc) -> VfsResult<()> { - *location.entry.as_dir()?.mountpoint.lock() = Some(child.clone()); + location.check_is_dir()?; parent .children .lock() - .insert(location.entry.key(), Arc::downgrade(child)); + .insert(location.entry.key(), child.clone()); Ok(()) } @@ -495,19 +538,17 @@ impl Mountpoint { return Err(VfsError::InvalidInput); }; - *old_location.entry.as_dir()?.mountpoint.lock() = None; old_location .mountpoint .children .lock() .remove(&old_location.entry.key()); - *new_location.entry.as_dir()?.mountpoint.lock() = Some(self.clone()); new_location .mountpoint .children .lock() - .insert(new_location.entry.key(), Arc::downgrade(self)); + .insert(new_location.entry.key(), self.clone()); *self.location.lock() = Some(new_location.clone()); Ok(()) @@ -525,7 +566,6 @@ impl Mountpoint { .children .lock() .remove(&location.entry.key()); - *location.entry.as_dir()?.mountpoint.lock() = None; Ok(()) } } @@ -553,8 +593,6 @@ impl Location { pub fn node_type(&self) -> NodeType; - pub fn is_root_of_mount(&self) -> bool; - pub fn read_link(&self) -> VfsResult; pub fn ioctl(&self, cmd: u32, arg: usize) -> VfsResult; @@ -585,6 +623,10 @@ impl Location { &self.entry } + pub fn is_root_of_mount(&self) -> bool { + self.entry.ptr_eq(&self.mountpoint.root) + } + pub fn update_metadata(&self, update: MetadataUpdate) -> VfsResult<()> { if self.is_readonly() { return Err(VfsError::ReadOnlyFilesystem); @@ -618,7 +660,7 @@ impl Location { } pub fn is_root(&self) -> bool { - self.mountpoint.is_root() && self.entry.is_root_of_mount() + self.mountpoint.is_root() && self.is_root_of_mount() } pub fn check_is_dir(&self) -> VfsResult<()> { @@ -655,7 +697,12 @@ impl Location { } pub fn is_mountpoint(&self) -> bool { - self.entry.as_dir().is_ok_and(|it| it.is_mountpoint()) + self.entry.as_dir().is_ok() + && self + .mountpoint + .children + .lock() + .contains_key(&self.entry.key()) } /// See [`Mountpoint::effective_mountpoint`]. @@ -665,7 +712,7 @@ impl Location { .children .lock() .get(&self.entry.key()) - .and_then(Weak::upgrade) + .cloned() else { return self; }; @@ -810,17 +857,14 @@ impl Location { pub fn mount(&self, fs: &Filesystem) -> VfsResult> { let result = Mountpoint::new(fs, Some(self.clone())); let should_propagate = self.mountpoint.is_shared(); + self.check_is_dir()?; { - let mut mountpoint = self.entry.as_dir()?.mountpoint.lock(); - if mountpoint.is_some() { + let mut children = self.mountpoint.children.lock(); + if children.contains_key(&self.entry.key()) { return Err(VfsError::ResourceBusy); } - *mountpoint = Some(result.clone()); + children.insert(self.entry.key(), result.clone()); } - self.mountpoint - .children - .lock() - .insert(self.entry.key(), Arc::downgrade(&result)); if should_propagate { Mountpoint::propagate_new_child(self.mountpoint(), self, &result)?; } @@ -828,30 +872,22 @@ impl Location { } pub fn bind_mount(&self, source: &Self, recursive: bool) -> VfsResult> { - let source_mountpoint = source.mountpoint().clone(); - if source_mountpoint.is_unbindable() { + if source.mountpoint().is_unbindable() { return Err(VfsError::InvalidInput); } - let target_dir = self.entry.as_dir()?; + self.check_is_dir()?; + let mut children = self.mountpoint.children.lock(); + if children.contains_key(&self.entry.key()) { + return Err(VfsError::ResourceBusy); + } let result = Mountpoint::bind(source, self.clone(), recursive); - if source_mountpoint.is_shared() { - result.join_shared_group(&source_mountpoint); - } else if source_mountpoint.is_slave() { + if source.mountpoint().is_shared() { + result.join_shared_group(source.mountpoint()); + } else if source.mountpoint().is_slave() { result.set_slave(); } - { - let mut mountpoint = target_dir.mountpoint.lock(); - if mountpoint.is_some() { - result.set_private(); - return Err(VfsError::ResourceBusy); - } - *mountpoint = Some(result.clone()); - } - self.mountpoint - .children - .lock() - .insert(self.entry.key(), Arc::downgrade(&result)); + children.insert(self.entry.key(), result.clone()); Ok(result) } @@ -886,7 +922,6 @@ impl Location { .children .lock() .remove(&parent_loc.entry.key()); - *parent_loc.entry.as_dir()?.mountpoint.lock() = None; } Ok(()) } @@ -904,9 +939,7 @@ impl Location { } let children = mem::take(&mut *self.mountpoint.children.lock()); for (_, child) in children { - if let Some(child) = child.upgrade() { - child.root_location().unmount_all()?; - } + child.root_location().unmount_all()?; } self.unmount() } diff --git a/components/rsext4/src/blockdev/cached_device.rs b/components/rsext4/src/blockdev/cached_device.rs index 66ff4ff917..a3962f4a0d 100644 --- a/components/rsext4/src/blockdev/cached_device.rs +++ b/components/rsext4/src/blockdev/cached_device.rs @@ -207,15 +207,23 @@ impl BlockDev { self.entries[self.active].buffer.as_mut_slice() } - /// Invalidates the cache without flushing. - /// Used after journal commit to prevent stale cached data - /// from shadowing newly-committed blocks. - pub fn invalidate_cache(&mut self) { + /// Flushes dirty cached blocks, then invalidates all entries. + /// + /// Dirty entries are flushed first so metadata modifications made + /// via [`buffer_mut`] are never silently discarded. + pub fn invalidate_cache(&mut self) -> Ext4Result<()> { for entry in self.entries.iter_mut() { + if entry.dirty && !entry.is_empty() { + let bid = entry.block_id.unwrap(); + self.dev.write(entry.buffer.as_slice(), bid, 1)?; + entry.dirty = false; + } entry.block_id = None; - entry.dirty = false; entry.referenced = false; } + self.active = 0; + self.clock = 0; + Ok(()) } /// Replaces cached block contents without writing to the device. diff --git a/components/rsext4/src/blockdev/journal.rs b/components/rsext4/src/blockdev/journal.rs index 4a143d9746..6a7dd0c422 100644 --- a/components/rsext4/src/blockdev/journal.rs +++ b/components/rsext4/src/blockdev/journal.rs @@ -22,6 +22,84 @@ pub enum Jbd2RunState { Replay, } +#[cfg(test)] +mod tests { + use alloc::vec; + + use super::*; + + struct MemBlockDev { + data: Vec, + } + + impl MemBlockDev { + fn new(blocks: usize) -> Self { + Self { + data: vec![0; blocks * BLOCK_SIZE], + } + } + } + + impl BlockDevice for MemBlockDev { + fn read(&mut self, buffer: &mut [u8], block_id: AbsoluteBN, _count: u32) -> Ext4Result<()> { + let start = block_id.as_usize()? * BLOCK_SIZE; + let end = start + buffer.len(); + buffer.copy_from_slice(&self.data[start..end]); + Ok(()) + } + + fn write(&mut self, buffer: &[u8], block_id: AbsoluteBN, _count: u32) -> Ext4Result<()> { + let start = block_id.as_usize()? * BLOCK_SIZE; + let end = start + buffer.len(); + self.data[start..end].copy_from_slice(buffer); + Ok(()) + } + + fn open(&mut self) -> Ext4Result<()> { + Ok(()) + } + + fn close(&mut self) -> Ext4Result<()> { + Ok(()) + } + + fn total_blocks(&self) -> u64 { + (self.data.len() / BLOCK_SIZE) as u64 + } + + fn block_size(&self) -> u32 { + BLOCK_SIZE as u32 + } + + fn current_time(&self) -> Ext4Result { + Ok(Ext4Timestamp::new(0, 0)) + } + } + + #[test] + fn auto_commit_invalidates_stale_block_cache() { + let mut dev = Jbd2Dev::initial_jbd2dev(0, MemBlockDev::new(256), true); + dev.set_journal_superblock(JournalSuperBllockS::default(), AbsoluteBN::new(128)); + + let target = AbsoluteBN::new(10); + dev.read_block(target).expect("prime target cache"); + assert_eq!(dev.buffer()[0], 0); + + let count = (JBD2_BUFFER_MAX + 1) as u32; + let mut updates = vec![0u8; count as usize * BLOCK_SIZE]; + for idx in 0..count as usize { + updates[idx * BLOCK_SIZE] = (idx + 1) as u8; + } + + dev.write_blocks(&updates, target, count, true) + .expect("queue metadata updates"); + + dev.read_block(target) + .expect("read target after auto commit"); + assert_eq!(dev.buffer()[0], 1); + } +} + /// Block device proxy that optionally routes metadata writes through JBD2. pub struct Jbd2Dev { _mode: u8, @@ -37,22 +115,24 @@ impl Jbd2Dev { system: &mut JBD2DEVSYSTEM, raw_dev: &mut B, update: Jbd2Update, - ) -> Ext4Result<()> { + ) -> Ext4Result { if let Some(existing) = system .commit_queue .iter_mut() .find(|queued| queued.0 == update.0) { *existing = update; - return Ok(()); + return Ok(false); } + let mut committed = false; if system.commit_queue.len() >= JBD2_BUFFER_MAX { system.commit_transaction(raw_dev)?; + committed = true; } system.commit_queue.push(update); - Ok(()) + Ok(committed) } fn make_system( @@ -119,7 +199,9 @@ impl Jbd2Dev { }; let status = jbd_sys.replay_with_mapping(self.inner.device_mut(), &self.journal_blocks); - self.inner.invalidate_cache(); + if self.inner.invalidate_cache().is_err() { + return ReplayStatus::Incomplete; + } status } @@ -161,9 +243,14 @@ impl Jbd2Dev { } if let Some(system) = self.system.as_mut() { - system + let committed = system .commit_transaction_with_mapping(self.inner.device_mut(), &self.journal_blocks) .expect("journal transaction commit failed"); + if committed { + self.inner + .invalidate_cache() + .expect("invalidate_cache failed during umount commit"); + } } else { trace!("Journal enabled but system uninitialized, skip commit"); } @@ -189,7 +276,10 @@ impl Jbd2Dev { }; let raw_dev = self.inner.device_mut(); - Self::enqueue_journal_update(system, raw_dev, updates)?; + let committed = Self::enqueue_journal_update(system, raw_dev, updates)?; + if committed { + let _ = self.inner.invalidate_cache(); + } trace!("[JBD2 buffer] queued metadata block {block_id}"); Ok(()) } @@ -255,13 +345,17 @@ impl Jbd2Dev { return Err(Ext4Error::buffer_too_small(buf.len(), required)); } + let mut committed_any = false; for i in 0..count { let off = (i as usize) * BLOCK_SIZE; let mut boxbuf = Box::new([0; BLOCK_SIZE]); boxbuf[..].copy_from_slice(&buf[off..off + BLOCK_SIZE]); let updates = Jbd2Update(block_id.checked_add(i)?, boxbuf); - Self::enqueue_journal_update(system, raw_dev, updates)?; + committed_any |= Self::enqueue_journal_update(system, raw_dev, updates)?; + } + if committed_any { + let _ = self.inner.invalidate_cache(); } Ok(()) diff --git a/components/rsext4/src/file/delete.rs b/components/rsext4/src/file/delete.rs index 20cd8f8fcb..de273f0ca7 100644 --- a/components/rsext4/src/file/delete.rs +++ b/components/rsext4/src/file/delete.rs @@ -7,7 +7,7 @@ pub(crate) struct ParentDirEntry { pub file_type: u8, } -fn free_inode_with_dtime( +pub fn free_inode( fs: &mut Ext4FileSystem, block_dev: &mut Jbd2Dev, inode_num: InodeNumber, @@ -96,7 +96,7 @@ pub fn unlink( // When the final link disappears, free blocks and inode through the shared // deletion path. if new_links == 0 { - free_inode_with_dtime(fs, block_dev, entry.ino, &mut target_inode)?; + free_inode(fs, block_dev, entry.ino, &mut target_inode)?; } // Remove the directory entry at the block found above (no second scan). @@ -522,7 +522,7 @@ pub fn delete_dir( fs.set_inode_links_count(block_dev, pino, parent_new_links)?; } - free_inode_with_dtime(fs, block_dev, frame.ino_num, &mut cur_inode)?; + free_inode(fs, block_dev, frame.ino_num, &mut cur_inode)?; // Keep the group-descriptor directory count in sync with the removal. let (group_idx, _idx_in_group) = fs.inode_allocator.global_to_group(frame.ino_num)?; @@ -603,7 +603,7 @@ pub fn delete_file( fs.set_inode_links_count(block_dev, entry.ino, new_links)?; if new_links == 0 { debug!("Will free inode:{} path:{path}", entry.ino); - free_inode_with_dtime(fs, block_dev, entry.ino, &mut target_inode)?; + free_inode(fs, block_dev, entry.ino, &mut target_inode)?; } else { debug!( "inode {} still has {new_links} link(s); removing directory entry only", diff --git a/components/rsext4/src/file/io.rs b/components/rsext4/src/file/io.rs index 7876e68ad0..b8dd5a94ed 100644 --- a/components/rsext4/src/file/io.rs +++ b/components/rsext4/src/file/io.rs @@ -19,7 +19,7 @@ pub fn truncate( truncate_inode(device, fs, inode_num, truncate_size) } -fn truncate_inode( +pub fn truncate_inode( device: &mut Jbd2Dev, fs: &mut Ext4FileSystem, inode_num: InodeNumber, diff --git a/components/rsext4/src/file/mod.rs b/components/rsext4/src/file/mod.rs index 0920be2a8d..ddc80ecc02 100644 --- a/components/rsext4/src/file/mod.rs +++ b/components/rsext4/src/file/mod.rs @@ -32,7 +32,11 @@ mod rename; pub use blocks::build_file_block_mapping_with_inode_num; pub use create::{create_symbol_link, create_symbol_link_with_owner, mkfile, mkfile_with_owner}; -pub use delete::{delete_dir, delete_file, is_dir_empty, unlink}; -pub use io::{read_file, read_inode_data_into, truncate, write_file, write_inode_data}; +pub use delete::{ + delete_dir, delete_file, free_inode, is_dir_empty, remove_inodeentry_from_parentdir, unlink, +}; +pub use io::{ + read_file, read_inode_data_into, truncate, truncate_inode, write_file, write_inode_data, +}; pub use link::link; pub use rename::{mv, rename}; diff --git a/components/rsext4/src/lib.rs b/components/rsext4/src/lib.rs index 6f54c96d0c..f7de229da3 100644 --- a/components/rsext4/src/lib.rs +++ b/components/rsext4/src/lib.rs @@ -32,9 +32,10 @@ pub use disknode::{Ext4TimeSpec, Ext4Timestamp}; pub use error::{Errno, Ext4Error, Ext4Result}; pub use ext4::{Ext4FileSystem, MountOptions, find_file, mkfs, mount, mount_with_options, umount}; pub use file::{ - create_symbol_link, create_symbol_link_with_owner, delete_dir, delete_file, is_dir_empty, link, - mkfile, mkfile_with_owner, mv, read_file, read_inode_data_into, rename, truncate, unlink, - write_file, write_inode_data, + create_symbol_link, create_symbol_link_with_owner, delete_dir, delete_file, free_inode, + is_dir_empty, link, mkfile, mkfile_with_owner, mv, read_file, read_inode_data_into, + remove_inodeentry_from_parentdir, rename, truncate, truncate_inode, unlink, write_file, + write_inode_data, }; pub use metadata::{chmod, chown, set_flags, set_project, utimens}; diff --git a/components/rsext4/tests/crc_integrity.rs b/components/rsext4/tests/crc_integrity.rs index 7942792201..d4e1753a3f 100644 --- a/components/rsext4/tests/crc_integrity.rs +++ b/components/rsext4/tests/crc_integrity.rs @@ -8,6 +8,7 @@ use std::{ cell::{Cell, RefCell}, + collections::BTreeSet, rc::Rc, }; @@ -137,6 +138,22 @@ fn build_filesystem_with_written_file() -> (SharedCrcDevice, Vec) { (device, payload) } +fn sync_with_axfs_ng_order( + dev: &mut Jbd2Dev, + fs: &mut Ext4FileSystem, +) -> Ext4Result<()> { + fs.datablock_cache.flush_all(dev)?; + fs.bitmap_cache.flush_all(dev)?; + fs.inodetable_cache.flush_all(dev)?; + fs.superblock.s_state = Ext4Superblock::EXT4_VALID_FS; + fs.sync_superblock(dev)?; + fs.sync_group_descriptors(dev)?; + if dev.is_use_journal() { + dev.umount_commit(); + } + dev.cantflush() +} + fn read_superblock(device: &SharedCrcDevice) -> Ext4Superblock { let bytes = device.read_bytes(SUPERBLOCK_OFFSET as usize, Ext4Superblock::SUPERBLOCK_SIZE); Ext4Superblock::from_disk_bytes(&bytes) @@ -336,6 +353,55 @@ fn checksums_are_persisted_and_clean_remount_preserves_the_written_file() { umount(fs, &mut remount_dev).expect("umount failed"); } +#[test] +fn axfs_ng_sync_order_preserves_inode_bitmap_across_remount() { + // Test idea: mirror axfs-ng's sync_to_disk ordering, then remount and keep + // creating files. Inodes allocated before the sync must remain marked in + // the persisted inode bitmap and must not be reused after remount. + let device = SharedCrcDevice::new(100 * 1024 * 1024); + let mut first_dev = new_jbd2_dev(device.clone()); + mkfs(&mut first_dev).expect("mkfs failed"); + let mut fs = mount(&mut first_dev).expect("mount failed"); + + let mut seen = BTreeSet::new(); + for idx in 0..256 { + let path = format!("/before-{idx}"); + mkfile(&mut first_dev, &mut fs, &path, Some(b"x"), None).expect("mkfile before failed"); + let file = open(&mut first_dev, &mut fs, &path, false).expect("open before failed"); + assert!( + seen.insert(file.inode_num.raw()), + "duplicate inode before sync" + ); + } + + sync_with_axfs_ng_order(&mut first_dev, &mut fs).expect("axfs-ng order sync failed"); + drop(fs); + drop(first_dev); + + let sb = read_superblock(&device); + let desc = read_group_desc0(&device, &sb); + let inode_bitmap = device.read_block_bytes(desc.inode_bitmap()); + assert_eq!( + desc.inode_bitmap_csum(&sb), + ext4_inode_bitmap_csum32(&sb, &inode_bitmap) + ); + + let mut remount_dev = new_jbd2_dev(device.clone()); + let mut fs = mount(&mut remount_dev).expect("mount after axfs-ng order sync failed"); + + for idx in 0..256 { + let path = format!("/after-{idx}"); + mkfile(&mut remount_dev, &mut fs, &path, Some(b"y"), None).expect("mkfile after failed"); + let file = open(&mut remount_dev, &mut fs, &path, false).expect("open after failed"); + assert!( + seen.insert(file.inode_num.raw()), + "inode reused after axfs-ng order sync/remount" + ); + } + + umount(fs, &mut remount_dev).expect("umount failed"); +} + #[test] fn old_32_byte_descriptors_match_low_16_bits_of_bitmap_checksums() { let (device, _payload) = build_filesystem_with_written_file(); diff --git a/flake.lock b/flake.lock index 10f1c41b0b..fed8b03b67 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1778869304, - "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", + "lastModified": 1781577229, + "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d233902339c02a9c334e7e593de68855ad26c4cb", + "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", "type": "github" }, "original": { @@ -52,7 +52,28 @@ "root": { "inputs": { "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1782271078, + "narHash": "sha256-Ukpyd+syDTr+ky+nI+SbUnWySfiqNRzA3gzFZYWepeg=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "b7286019daa89e4e192c8913c3ec7002976034d0", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 6d29f1091b..f2d19c16ea 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,10 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = @@ -15,8 +19,15 @@ ]; perSystem = - { pkgs, ... }: + { system, ... }: let + pkgs = import inputs.nixpkgs { + inherit system; + overlays = [ + # (import inputs.rust-overlay) + ]; + }; + lib = pkgs.lib; optionalPackageByPath = @@ -27,6 +38,10 @@ lib.optional (package != null) package; llvmPackages = pkgs.llvmPackages; + rustBin = inputs.rust-overlay.lib.mkRustBin { + # distRoot = "https://mirrors.ustc.edu.cn/rust-static"; + } pkgs; + rustToolchain = rustBin.fromRustupToolchainFile ./rust-toolchain.toml; commonPackages = with pkgs; @@ -49,7 +64,8 @@ pkg-config python3 qemu - rustup + rustToolchain + # rustup wget xz zlib @@ -109,18 +125,19 @@ LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; shellHook = '' - project_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + export project_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" - export RUSTUP_HOME="$project_root/.rustup" + # export RUSTUP_HOME="$project_root/.rustup" export CARGO_HOME="$project_root/.cargo" export PATH="$CARGO_HOME/bin:$PATH" - mkdir -p "$RUSTUP_HOME" "$CARGO_HOME" "$CARGO_HOME/bin" + # mkdir -p "$RUSTUP_HOME" "$CARGO_HOME" "$CARGO_HOME/bin" + mkdir -p "$CARGO_HOME" "$CARGO_HOME/bin" echo "TGOSKits dev shell" - echo " RUSTUP_HOME=$RUSTUP_HOME" + # echo " RUSTUP_HOME=$RUSTUP_HOME" echo " CARGO_HOME=$CARGO_HOME" - echo " Rust toolchain: rust-toolchain.toml" + echo " Rust toolchain: rust-overlay from rust-toolchain.toml" exec fish ''; diff --git a/os/StarryOS/axnsproxy/src/mnt.rs b/os/StarryOS/axnsproxy/src/mnt.rs index a4204d6f7d..e500ea0f57 100644 --- a/os/StarryOS/axnsproxy/src/mnt.rs +++ b/os/StarryOS/axnsproxy/src/mnt.rs @@ -1,5 +1,5 @@ use alloc::sync::Arc; -use core::ffi::c_char; +use core::sync::atomic::{AtomicU64, Ordering}; use ax_kspin::SpinNoIrq; @@ -8,29 +8,31 @@ use ax_kspin::SpinNoIrq; pub static ROOT_MNT_NS: spin::LazyLock>> = spin::LazyLock::new(|| Arc::new(SpinNoIrq::new(MntNamespace::new_root()))); -const fn pad_mnt_root(root: &str) -> [c_char; 256] { - let mut data: [c_char; 256] = [0; 256]; - unsafe { - core::ptr::copy_nonoverlapping(root.as_ptr().cast(), data.as_mut_ptr(), root.len()); - } - data -} +static MNT_NS_ID: AtomicU64 = AtomicU64::new(1); /// Per-process mount namespace. -/// Isolates the set of filesystem mount points seen by a process. -/// In the root namespace `root` starts as `"/"`. +/// +/// This is the namespace identity visible through `NsProxy`. The live mount +/// topology is held by the task-local `ax_fs::FsContext`, and syscall paths +/// update both objects together when entering a new mount namespace. pub struct MntNamespace { - pub root: [c_char; 256], + id: u64, } impl MntNamespace { pub fn new_root() -> Self { Self { - root: pad_mnt_root("/"), + id: MNT_NS_ID.fetch_add(1, Ordering::Relaxed), } } pub fn clone_ns(&self) -> Self { - Self { root: self.root } + Self { + id: MNT_NS_ID.fetch_add(1, Ordering::Relaxed), + } + } + + pub fn id(&self) -> u64 { + self.id } } diff --git a/os/StarryOS/axnsproxy/src/pid.rs b/os/StarryOS/axnsproxy/src/pid.rs index 0e586056b8..45e6148ae7 100644 --- a/os/StarryOS/axnsproxy/src/pid.rs +++ b/os/StarryOS/axnsproxy/src/pid.rs @@ -1,4 +1,5 @@ use alloc::{collections::BTreeMap, sync::Arc}; +use core::sync::atomic::{AtomicU64, Ordering}; use ax_kspin::SpinNoIrq; @@ -7,6 +8,8 @@ use ax_kspin::SpinNoIrq; pub static ROOT_PID_NS: spin::LazyLock>> = spin::LazyLock::new(|| Arc::new(SpinNoIrq::new(PidNamespace::new_root()))); +static NEXT_PID_NS_ID: AtomicU64 = AtomicU64::new(1); + /// Per-process PID namespace. /// /// Each PID namespace has a nesting `level` (0 for the root namespace, @@ -14,20 +17,26 @@ pub static ROOT_PID_NS: spin::LazyLock>> = /// so that processes in different PID namespaces may have the same PID /// value as seen from within their respective namespace. pub struct PidNamespace { + /// Globally unique namespace identifier (exposed via /proc/PID/ns/pid). + pub id: u64, /// PID namespace nesting level. Root is 0, first child is 1, etc. pub level: u32, /// Next local PID to allocate in this namespace (starts at 1). next_pid: u32, /// Map from global TID to namespace-local PID. pid_map: BTreeMap, + /// Global TID of the PID namespace init process (PID 1 in this ns). + init_global_tid: Option, } impl PidNamespace { pub fn new_root() -> Self { Self { + id: NEXT_PID_NS_ID.fetch_add(1, Ordering::Relaxed), level: 0, next_pid: 1, pid_map: BTreeMap::new(), + init_global_tid: None, } } @@ -35,9 +44,11 @@ impl PidNamespace { /// next_pid starts at 1). pub fn clone_ns(&self) -> Self { Self { + id: NEXT_PID_NS_ID.fetch_add(1, Ordering::Relaxed), level: self.level + 1, next_pid: 1, pid_map: BTreeMap::new(), + init_global_tid: None, } } @@ -57,4 +68,14 @@ impl PidNamespace { } self.pid_map.get(&global_tid).copied() } + + /// Record the global TID of this namespace's init process (PID 1). + pub fn set_init_global_tid(&mut self, tid: u64) { + self.init_global_tid = Some(tid); + } + + /// Returns the global TID of this namespace's init process. + pub fn init_global_tid(&self) -> Option { + self.init_global_tid + } } diff --git a/os/StarryOS/axnsproxy/src/user.rs b/os/StarryOS/axnsproxy/src/user.rs index 1fc34012db..d5a1849d26 100644 --- a/os/StarryOS/axnsproxy/src/user.rs +++ b/os/StarryOS/axnsproxy/src/user.rs @@ -1,4 +1,5 @@ use alloc::sync::Arc; +use core::sync::atomic::{AtomicU64, Ordering}; use ax_kspin::SpinNoIrq; @@ -7,6 +8,8 @@ use ax_kspin::SpinNoIrq; pub static ROOT_USER_NS: spin::LazyLock>> = spin::LazyLock::new(|| Arc::new(SpinNoIrq::new(UserNamespace::new_root()))); +static NEXT_USER_NS_ID: AtomicU64 = AtomicU64::new(1); + /// Per-process user namespace. /// /// Isolates UID/GID mappings so that a process may have uid 0 inside @@ -21,6 +24,8 @@ pub static ROOT_USER_NS: spin::LazyLock>> = /// `gid_mapped`, so the two sides are independent — a half-configured /// namespace correctly returns 65534 for the unmapped side. pub struct UserNamespace { + /// Globally unique namespace identifier (exposed via /proc/PID/ns/user). + pub id: u64, /// Effective UID of the namespace creator (0 for root namespace). pub owner_uid: u32, /// Whether this is the initial root user namespace or a child @@ -37,6 +42,7 @@ pub struct UserNamespace { impl UserNamespace { pub fn new_root() -> Self { Self { + id: NEXT_USER_NS_ID.fetch_add(1, Ordering::Relaxed), owner_uid: 0, is_root: true, uid_mapped: true, @@ -46,6 +52,7 @@ impl UserNamespace { pub fn clone_ns(&self) -> Self { Self { + id: NEXT_USER_NS_ID.fetch_add(1, Ordering::Relaxed), owner_uid: self.owner_uid, is_root: false, uid_mapped: false, diff --git a/os/StarryOS/axnsproxy/src/uts.rs b/os/StarryOS/axnsproxy/src/uts.rs index 62e3527d0a..35e38b83fc 100644 --- a/os/StarryOS/axnsproxy/src/uts.rs +++ b/os/StarryOS/axnsproxy/src/uts.rs @@ -1,5 +1,8 @@ use alloc::sync::Arc; -use core::ffi::c_char; +use core::{ + ffi::c_char, + sync::atomic::{AtomicU64, Ordering}, +}; use ax_kspin::SpinNoIrq; @@ -20,12 +23,15 @@ const fn pad_str(info: &str) -> [c_char; 65] { data } +static NEXT_UTS_NS_ID: AtomicU64 = AtomicU64::new(1); + /// Per-process UTS namespace, containing the hostname and domain name /// visible to `uname(2)`. When a process calls `unshare(CLONE_NEWUTS)` or /// `clone(CLONE_NEWUTS)`, it receives a fresh copy of the parent namespace /// so that subsequent `sethostname(2)` / `setdomainname(2)` do not affect /// the original namespace. pub struct UtNamespace { + pub id: u64, pub nodename: [c_char; 65], pub domainname: [c_char; 65], } @@ -34,6 +40,7 @@ impl UtNamespace { /// Create the initial root UTS namespace with default values. pub fn new_root() -> Self { Self { + id: NEXT_UTS_NS_ID.fetch_add(1, Ordering::Relaxed), nodename: pad_str("starry"), domainname: pad_str("https://github.com/Starry-OS/StarryOS"), } @@ -42,6 +49,7 @@ impl UtNamespace { /// Clone the namespace (shallow copy of nodename/domainname). pub fn clone_ns(&self) -> Self { Self { + id: NEXT_UTS_NS_ID.fetch_add(1, Ordering::Relaxed), nodename: self.nodename, domainname: self.domainname, } diff --git a/os/StarryOS/kernel/src/file/epoll.rs b/os/StarryOS/kernel/src/file/epoll.rs index b6dfe86271..f243a369f0 100644 --- a/os/StarryOS/kernel/src/file/epoll.rs +++ b/os/StarryOS/kernel/src/file/epoll.rs @@ -104,7 +104,17 @@ enum ConsumeResult { } fn match_ready_events(current: IoEvents, interested: IoEvents) -> IoEvents { - (current & interested) | (current & IoEvents::ALWAYS_POLL) + let mut matched = (current & interested) | (current & IoEvents::ALWAYS_POLL); + // When the fd is hung up, also force IN so that epoll callers who only + // inspect EPOLLIN (a common pattern for pipes/sockets) can detect EOF. + // This is safe because a hung-up fd is always readable (read() returns 0 + // immediately). Linux epoll reports EPOLLHUP regardless of interest, but + // applications that mask on EPOLLIN alone still need to see the event. + // Calling `poll(2)` directly is unaffected by this epoll-only convention. + if matched.contains(IoEvents::HUP) { + matched |= IoEvents::IN; + } + matched } fn register_events(interested: IoEvents) -> IoEvents { diff --git a/os/StarryOS/kernel/src/file/mod.rs b/os/StarryOS/kernel/src/file/mod.rs index de04781961..4beee3f28f 100644 --- a/os/StarryOS/kernel/src/file/mod.rs +++ b/os/StarryOS/kernel/src/file/mod.rs @@ -27,7 +27,7 @@ use ax_errno::{AxError, AxResult}; use ax_fs_ng::vfs::{FS_CONTEXT, FileBackend, FileFlags, OpenOptions}; use ax_io::prelude::*; use ax_kspin::SpinRwLock as RwLock; -use ax_task::current; +use ax_task::{TaskState, current}; use axfs_ng_vfs::DeviceId; use axpoll::Pollable; use downcast_rs::{DowncastSync, impl_downcast}; @@ -36,6 +36,7 @@ use linux_raw_sys::general::{ O_ACCMODE, O_PATH, O_RDONLY, O_RDWR, O_WRONLY, RLIMIT_NOFILE, STATX_BASIC_STATS, stat, statx, statx_timestamp, }; +use starry_process::Pid; pub use self::{ fs::{Directory, File, ResolveAtResult, resolve_at, with_fs}, @@ -48,7 +49,7 @@ pub use self::{ }; use crate::{ pseudofs::DeviceMmap, - task::{AX_FILE_LIMIT, AsThread}, + task::{AX_FILE_LIMIT, AsThread, tasks}, }; #[derive(Debug, Clone, Copy)] @@ -314,6 +315,29 @@ pub fn close_file_like(fd: c_int) -> AxResult { Err(AxError::BadFileDescriptor) } +pub(crate) fn fd_tables_contain_file(file: &Arc) -> bool { + !fd_table_file_refs(file).is_empty() +} + +pub(crate) fn fd_table_file_refs(file: &Arc) -> alloc::vec::Vec<(Pid, usize)> { + let mut refs = alloc::vec::Vec::new(); + for task in tasks() { + if task.state() == TaskState::Exited { + continue; + } + let pid = task.as_thread().proc_data.proc.pid(); + let scope = task.as_thread().proc_data.scope.read(); + let scoped_fd_table = FD_TABLE.scope(&scope); + let table = scoped_fd_table.read(); + for id in table.ids() { + if table.get(id).is_some_and(|fd| Arc::ptr_eq(&fd.inner, file)) { + refs.push((pid, id)); + } + } + } + refs +} + fn notify_close_write(fd: &FileDescriptor) { let access = fd.inner.open_flags() & O_ACCMODE; if (access == O_WRONLY || access == O_RDWR) && fd.inner.is::() { @@ -343,6 +367,9 @@ pub fn release_locks_on_close(fd: FileDescriptor) { if let Some(k) = key { let pid = current().as_thread().proc_data.proc.pid(); crate::syscall::release_inode_posix_locks(pid, k); + if !fd_tables_contain_file(&fd.inner) { + crate::syscall::release_flock_lock(k, &fd.inner); + } } drop(fd); if let Some(k) = key { @@ -383,26 +410,8 @@ pub fn close_all_fds() { } drop(table); - // Snapshot inode keys before drop so we can wake F_SETLKW waiters - // afterwards: the Arc drops here may release OFD locks (their owner - // weak-refs go dead), and a parked waiter has no other way to learn - // about it. POSIX locks owned by this pid are released separately by - // `release_pid_locks`, which already wakes; the inode-key dedup - // means the per-inode wake is at most O(fds) and harmless when - // double-fired. - let lock_keys: alloc::vec::Vec<(u64, u64)> = removed - .iter() - .filter_map(|fd| fd.inner.inode_key()) - .collect(); - for fd in &removed { - notify_close_write(fd); - } - // Drop removed descriptors after releasing FD_TABLE lock to avoid - // lock re-entry or side effects from destructor paths. - drop(removed); - for key in lock_keys { - crate::syscall::wake_lock_waiters(key); - crate::syscall::wake_flock_waiters(key); + for fd in removed { + release_locks_on_close(fd); } } diff --git a/os/StarryOS/kernel/src/file/nsfd.rs b/os/StarryOS/kernel/src/file/nsfd.rs index 5751f5dc70..287d4d5b4f 100644 --- a/os/StarryOS/kernel/src/file/nsfd.rs +++ b/os/StarryOS/kernel/src/file/nsfd.rs @@ -2,9 +2,11 @@ use alloc::{borrow::Cow, sync::Arc}; use core::task::Context; use ax_errno::AxResult; +use ax_fs_ng::MountNamespace as FsMountNamespace; use ax_kspin::SpinNoIrq; use axnsproxy::{ - IpcNamespace, MntNamespace, NetNamespace, PidNamespace, UserNamespace, UtNamespace, + IpcNamespace, MntNamespace as ProxyMntNamespace, NetNamespace, PidNamespace, UserNamespace, + UtNamespace, }; use axpoll::{IoEvents, Pollable}; use linux_raw_sys::general::{ @@ -20,7 +22,10 @@ use super::FileLike; pub enum NsFd { Uts(Arc>), Ipc(Arc>), - Mnt(Arc>), + Mnt { + ns: Arc>, + fs_ns: Arc, + }, Pid(Arc>), Net(Arc>), User(Arc>), @@ -32,7 +37,7 @@ impl NsFd { match self { NsFd::Uts(_) => CLONE_NEWUTS, NsFd::Ipc(_) => CLONE_NEWIPC, - NsFd::Mnt(_) => CLONE_NEWNS, + NsFd::Mnt { .. } => CLONE_NEWNS, NsFd::Pid(_) => CLONE_NEWPID, NsFd::Net(_) => CLONE_NEWNET, NsFd::User(_) => CLONE_NEWUSER, @@ -45,7 +50,7 @@ impl FileLike for NsFd { match self { NsFd::Uts(_) => "anon_inode:[uts_ns]".into(), NsFd::Ipc(_) => "anon_inode:[ipc_ns]".into(), - NsFd::Mnt(_) => "anon_inode:[mnt_ns]".into(), + NsFd::Mnt { .. } => "anon_inode:[mnt_ns]".into(), NsFd::Pid(_) => "anon_inode:[pid_ns]".into(), NsFd::Net(_) => "anon_inode:[net_ns]".into(), NsFd::User(_) => "anon_inode:[user_ns]".into(), @@ -53,7 +58,21 @@ impl FileLike for NsFd { } fn stat(&self) -> AxResult { - Ok(super::Kstat::default()) + let ino = match self { + NsFd::Uts(ns) => ns.lock().id, + NsFd::Ipc(ns) => ns.lock().ns_id, + NsFd::Mnt { ns, .. } => ns.lock().id(), + NsFd::Pid(ns) => ns.lock().id, + NsFd::Net(ns) => ns.lock().ns_id, + NsFd::User(ns) => ns.lock().id, + }; + Ok(super::Kstat { + ino, + mode: 0o100_444, // S_IFREG | 0444 + nlink: 1, + blksize: 4096, + ..super::Kstat::default() + }) } } diff --git a/os/StarryOS/kernel/src/file/pidfd.rs b/os/StarryOS/kernel/src/file/pidfd.rs index cacdc7f4f5..e1a1b68a71 100644 --- a/os/StarryOS/kernel/src/file/pidfd.rs +++ b/os/StarryOS/kernel/src/file/pidfd.rs @@ -96,14 +96,17 @@ impl FileLike for PidFd { impl Pollable for PidFd { fn poll(&self) -> IoEvents { let mut events = IoEvents::empty(); - events.set( - IoEvents::IN, - self.proc_data.strong_count() > 0 - && self - .thread_exit - .as_ref() - .is_none_or(|it| !it.load(Ordering::Acquire)), - ); + // Linux pidfd becomes readable only after the referenced task exits. + // Reporting IN while it is still alive makes event loops spin or wait + // on the wrong readiness edge. + let exited = if let Some(thread_exit) = &self.thread_exit { + thread_exit.load(Ordering::Acquire) + } else { + self.proc_data + .upgrade() + .is_none_or(|proc_data| proc_data.proc.is_zombie()) + }; + events.set(IoEvents::IN, exited); events } diff --git a/os/StarryOS/kernel/src/pseudofs/dev/tty/mod.rs b/os/StarryOS/kernel/src/pseudofs/dev/tty/mod.rs index d2fab649d8..d15f9f5f09 100644 --- a/os/StarryOS/kernel/src/pseudofs/dev/tty/mod.rs +++ b/os/StarryOS/kernel/src/pseudofs/dev/tty/mod.rs @@ -11,7 +11,12 @@ use alloc::{ sync::{Arc, Weak}, vec::Vec, }; -use core::{any::Any, ops::Deref, sync::atomic::Ordering, task::Context}; +use core::{ + any::Any, + ops::Deref, + sync::atomic::{AtomicUsize, Ordering}, + task::Context, +}; use ax_errno::{AxError, AxResult}; use ax_sync::Mutex; @@ -62,6 +67,7 @@ pub struct Tty { ldisc: Mutex>, writer: W, is_ptm: bool, + open_count: AtomicUsize, } impl Tty { @@ -75,6 +81,7 @@ impl Tty { ldisc, writer, is_ptm, + open_count: AtomicUsize::new(0), }) } } @@ -103,9 +110,25 @@ impl Tty { impl DeviceOps for Tty { fn open(&self, _exclusive: bool) -> AxResult<()> { + self.open_count.fetch_add(1, Ordering::AcqRel); self.writer.open() } + fn close(&self, _exclusive: bool) { + // On the last fd close, notify the writer side so the peer reader can + // observe POLLHUP / EOF. Without this, a PTY master/slave close never + // wakes the peer and poll()/read() hang. + if self + .open_count + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| { + count.checked_sub(1) + }) + .is_ok_and(|old| old == 1) + { + self.writer.close(); + } + } + fn read_at(&self, buf: &mut [u8], _offset: u64) -> AxResult { if self.is_ptm || self.terminal.job_control.current_in_foreground() { self.ldisc.lock().read(buf) diff --git a/os/StarryOS/kernel/src/pseudofs/dev/tty/pty.rs b/os/StarryOS/kernel/src/pseudofs/dev/tty/pty.rs index a185d3c70e..d92ac0d1f8 100644 --- a/os/StarryOS/kernel/src/pseudofs/dev/tty/pty.rs +++ b/os/StarryOS/kernel/src/pseudofs/dev/tty/pty.rs @@ -1,4 +1,5 @@ use alloc::sync::Arc; +use core::sync::atomic::{AtomicBool, Ordering}; use ax_kspin::SpinNoIrq; use axpoll::{IoEvents, PollSet}; @@ -21,11 +22,11 @@ pub type PtyDriver = Tty; type Buffer = Arc>; -pub struct PtyReader(Cons); +pub struct PtyReader(Cons, Arc); impl PtyReader { - pub fn new(buffer: Buffer) -> Self { - Self(Cons::new(buffer)) + pub fn new(buffer: Buffer, writer_closed: Arc) -> Self { + Self(Cons::new(buffer), writer_closed) } } @@ -33,14 +34,22 @@ impl TtyRead for PtyReader { fn read(&mut self, buf: &mut [u8]) -> usize { self.0.pop_slice(buf) } + + fn closed(&self) -> bool { + self.1.load(Ordering::Acquire) + } } #[derive(Clone)] -pub struct PtyWriter(Arc>>, Arc); +pub struct PtyWriter(Arc>>, Arc, Arc); impl PtyWriter { - pub fn new(buffer: Buffer, poll_rx: Arc) -> Self { - Self(Arc::new(SpinNoIrq::new(Prod::new(buffer))), poll_rx) + pub fn new(buffer: Buffer, poll_rx: Arc, writer_closed: Arc) -> Self { + Self( + Arc::new(SpinNoIrq::new(Prod::new(buffer))), + poll_rx, + writer_closed, + ) } } @@ -58,6 +67,16 @@ impl TtyWrite for PtyWriter { unsafe { self.1.wake(IoEvents::IN) }; read } + + fn close(&self) { + // Mark this writer side as fully closed so the peer reader can report + // POLLHUP / read EOF, and wake the peer reader's poll set so its + // blocked poll()/read() observe the hangup. The peer drains any + // already-buffered bytes first, then sees hangup on the next poll/read + // once the buffer is empty. + self.2.store(true, Ordering::Release); + unsafe { self.1.wake(IoEvents::IN) }; + } } pub(crate) fn create_pty_pair() -> (Arc, Arc) { @@ -65,14 +84,22 @@ pub(crate) fn create_pty_pair() -> (Arc, Arc) { let slave_to_master = Arc::new(HeapRb::new(PTY_BUF_SIZE)); let poll_rx_slave = Arc::new(PollSet::new()); let poll_rx_master = Arc::new(PollSet::new()); + // Shared close-flags: each writer sets its own flag on last-fd close so the + // peer reader can observe hangup (POLLHUP / EOF). + let master_closed = Arc::new(AtomicBool::new(false)); + let slave_closed = Arc::new(AtomicBool::new(false)); let terminal = Arc::new(Terminal::default()); let master = Tty::new( terminal.clone(), TtyConfig { - reader: PtyReader::new(slave_to_master.clone()), - writer: PtyWriter::new(master_to_slave.clone(), poll_rx_slave.clone()), + reader: PtyReader::new(slave_to_master.clone(), slave_closed.clone()), + writer: PtyWriter::new( + master_to_slave.clone(), + poll_rx_slave.clone(), + master_closed.clone(), + ), process_mode: ProcessMode::Passive(poll_rx_master.clone()), }, ); @@ -80,8 +107,8 @@ pub(crate) fn create_pty_pair() -> (Arc, Arc) { let slave = Tty::new( terminal, TtyConfig { - reader: PtyReader::new(master_to_slave), - writer: PtyWriter::new(slave_to_master, poll_rx_master), + reader: PtyReader::new(master_to_slave, master_closed), + writer: PtyWriter::new(slave_to_master, poll_rx_master, slave_closed), process_mode: ProcessMode::InterruptDriven { input: poll_rx_slave, output: None, diff --git a/os/StarryOS/kernel/src/pseudofs/dev/tty/terminal/ldisc.rs b/os/StarryOS/kernel/src/pseudofs/dev/tty/terminal/ldisc.rs index 093082d2a6..557ed956e4 100644 --- a/os/StarryOS/kernel/src/pseudofs/dev/tty/terminal/ldisc.rs +++ b/os/StarryOS/kernel/src/pseudofs/dev/tty/terminal/ldisc.rs @@ -52,12 +52,23 @@ pub struct TtyConfig { pub trait TtyRead: Send + Sync + 'static { fn read(&mut self, buf: &mut [u8]) -> usize; + + /// Whether the writer peer has been fully closed (last fd dropped). + /// Default: never closed. Lets a Passive reader report hangup + /// (POLLHUP / read EOF) once the writer side is gone. + fn closed(&self) -> bool { + false + } } pub trait TtyWrite: Send + Sync + 'static { fn open(&self) -> AxResult<()> { Ok(()) } + /// Called when the last fd referencing this writer side is closed, so the + /// peer reader can be woken for POLLHUP/EOF. Default: no-op. + fn close(&self) {} + fn write(&self, buf: &[u8]); fn try_write(&self, buf: &[u8]) -> usize { @@ -365,6 +376,10 @@ struct SimpleReader { buf_tx: CachingProd, } impl SimpleReader { + pub fn closed(&self) -> bool { + self.reader.closed() + } + pub fn poll(&mut self) { let read = self.reader.read(&mut self.read_buf); let _ = self.buf_tx.push_slice(&self.read_buf[..read]); @@ -535,10 +550,17 @@ impl LineDiscipline { } pub fn poll_read(&mut self) -> bool { + // Peer writer fully closed (Passive mode) → report readable so poll() + // wakes and the caller's read() observes EOF / POLLHUP instead of + // blocking forever after the last fd to the writer side is dropped. + let writer_closed = match &self.processor { + Processor::Passive(reader, _) => reader.closed(), + _ => false, + }; if let Processor::Passive(reader, _) = &mut self.processor { reader.poll(); } - if !self.injected_input.is_empty() { + if writer_closed || !self.injected_input.is_empty() { return true; } let term = self.terminal.termios.lock().clone(); @@ -585,7 +607,13 @@ impl LineDiscipline { if matches!(self.processor, Processor::Passive(_, _)) { let read = self.buf_rx.pop_slice(buf); return if read == 0 { - Err(AxError::WouldBlock) + // Buffer drained: if the peer writer has closed, report EOF; + // otherwise the read would block. + if matches!(&self.processor, Processor::Passive(reader, _) if reader.closed()) { + Ok(0) + } else { + Err(AxError::WouldBlock) + } } else { Ok(read) }; diff --git a/os/StarryOS/kernel/src/pseudofs/proc.rs b/os/StarryOS/kernel/src/pseudofs/proc.rs index 1aa7e40251..e33d7b6add 100644 --- a/os/StarryOS/kernel/src/pseudofs/proc.rs +++ b/os/StarryOS/kernel/src/pseudofs/proc.rs @@ -732,25 +732,23 @@ impl SimpleDirOps for NsDir { let content: String = match name { "uts" => { let nsproxy = proc_data.nsproxy.lock(); - let nodename = &nsproxy.uts_ns.lock().nodename; - let nodename_str = core::ffi::CStr::from_bytes_until_nul(unsafe { - core::mem::transmute::<&[core::ffi::c_char; 65], &[u8; 65]>(nodename) - }) - .unwrap_or_default() - .to_str() - .unwrap_or_default(); - format!("uts:[{}]\n", nodename_str) + let ns_id = nsproxy.uts_ns.lock().id; + format!("uts:[{}]\n", ns_id) } "ipc" => { let nsproxy = proc_data.nsproxy.lock(); let ns_id = nsproxy.ipc_ns.lock().ns_id; format!("ipc:[{}]\n", ns_id) } - "mnt" => "mnt:[root]\n".to_string(), + "mnt" => { + let nsproxy = proc_data.nsproxy.lock(); + let ns_id = nsproxy.mnt_ns.lock().id(); + format!("mnt:[{}]\n", ns_id) + } "pid" => { let nsproxy = proc_data.nsproxy.lock(); - let level = nsproxy.pid_ns.lock().level; - format!("pid:[{}]\n", level) + let ns_id = nsproxy.pid_ns.lock().id; + format!("pid:[{}]\n", ns_id) } "net" => { let nsproxy = proc_data.nsproxy.lock(); @@ -759,12 +757,8 @@ impl SimpleDirOps for NsDir { } "user" => { let nsproxy = proc_data.nsproxy.lock(); - let inner = nsproxy.user_ns.lock(); - if inner.is_root { - "user:[root]\n".to_string() - } else { - format!("user:[{}]\n", inner.owner_uid) - } + let ns_id = nsproxy.user_ns.lock().id; + format!("user:[{}]\n", ns_id) } _ => return Err(VfsError::NotFound), }; diff --git a/os/StarryOS/kernel/src/syscall/fs/fd_ops.rs b/os/StarryOS/kernel/src/syscall/fs/fd_ops.rs index 70616cb344..8794ca3e90 100644 --- a/os/StarryOS/kernel/src/syscall/fs/fd_ops.rs +++ b/os/StarryOS/kernel/src/syscall/fs/fd_ops.rs @@ -161,6 +161,18 @@ fn add_to_fd(result: OpenResult, flags: u32) -> AxResult { file = ax_fs_ng::vfs::File::new(FileBackend::Direct(loc), file.flags()); } } + // Call open() on the final device after /dev/ptmx and /dev/tty + // rewrites, so PTY open-count tracking (Tty) pairs the last-fd + // close with peer POLLHUP/EOF notification. Block devices already + // use the O_EXCL hook above, so skip them to avoid a double open(). + if let Ok(device) = file.location().entry().downcast::() { + let is_block = device + .metadata() + .is_ok_and(|m| m.node_type == NodeType::BlockDevice); + if !is_block { + device.inner().open(flags & O_EXCL != 0)?; + } + } Arc::new(File::new(file, flags)) } OpenResult::Dir(dir) => Arc::new(Directory::new(dir, flags)), @@ -252,12 +264,24 @@ fn try_open_nsfd(path: &str, flags: u32) -> Option> { Err(_) => return Some(Err(AxError::NotFound)), }; + let mnt_fs_ns = if ns_type_str == "mnt" { + let scope = proc_data.scope.read(); + let fs_context = FS_CONTEXT.scope(&scope).clone(); + drop(scope); + Some(fs_context.lock().mount_namespace().clone()) + } else { + None + }; + let nsproxy = proc_data.nsproxy.lock(); let nsfd: NsFd = match ns_type_str { "uts" => NsFd::Uts(nsproxy.uts_ns.clone()), "ipc" => NsFd::Ipc(nsproxy.ipc_ns.clone()), - "mnt" => NsFd::Mnt(nsproxy.mnt_ns.clone()), + "mnt" => NsFd::Mnt { + ns: nsproxy.mnt_ns.clone(), + fs_ns: mnt_fs_ns.unwrap(), + }, "pid" => NsFd::Pid(nsproxy.pid_ns.clone()), "net" => NsFd::Net(nsproxy.net_ns.clone()), "user" => NsFd::User(nsproxy.user_ns.clone()), @@ -479,6 +503,13 @@ pub fn sys_close_range(first: i32, last: i32, flags: u32) -> AxResult { let cloexec = flags.contains(CloseRangeFlags::CLOEXEC); let mut fd_table = FD_TABLE.write(); + // Collect closed fds and defer `release_locks_on_close` until after the + // table write lock is dropped. `release_locks_on_close()` walks every fd + // table through `fd_tables_contain_file()` (which acquires `FD_TABLE`), + // so running it under the write guard self-deadlocks on the first closed + // fd — every `dup2()`/`dup3()` that replaces an open fd (shell pipeline + // setup) hangs. Mirrors the `close_all_fds` / execve CLOEXEC pattern. + let mut closing = alloc::vec::Vec::new(); if let Some(max_index) = fd_table.ids().next_back() { for fd in first..=last.min(max_index as i32) { if cloexec { @@ -486,10 +517,14 @@ pub fn sys_close_range(first: i32, last: i32, flags: u32) -> AxResult { f.cloexec = true; } } else if let Some(f) = fd_table.remove(fd as _) { - crate::file::release_locks_on_close(f); + closing.push(f); } } } + drop(fd_table); + for f in closing { + crate::file::release_locks_on_close(f); + } Ok(0) } @@ -555,12 +590,18 @@ pub fn sys_dup3(old_fd: c_int, new_fd: c_int, flags: c_int) -> AxResult { .ok_or(AxError::BadFileDescriptor)?; f.cloexec = flags.contains(Dup3Flags::O_CLOEXEC); - if let Some(prev) = fd_table.remove(new_fd as _) { - crate::file::release_locks_on_close(prev); - } + let prev = fd_table.remove(new_fd as _); fd_table .add_at(new_fd as _, f) .map_err(|_| AxError::BadFileDescriptor)?; + drop(fd_table); + // `release_locks_on_close()` walks all fd tables via + // `fd_tables_contain_file()` (acquiring `FD_TABLE`), so it must run AFTER + // the write lock is released — otherwise every dup2()/dup3() that replaces + // an open fd self-deadlocks (shell pipeline redirection, etc.). + if let Some(prev) = prev { + crate::file::release_locks_on_close(prev); + } Ok(new_fd as _) } diff --git a/os/StarryOS/kernel/src/syscall/fs/lock.rs b/os/StarryOS/kernel/src/syscall/fs/lock.rs index d2432fcf35..832427fdb4 100644 --- a/os/StarryOS/kernel/src/syscall/fs/lock.rs +++ b/os/StarryOS/kernel/src/syscall/fs/lock.rs @@ -132,7 +132,6 @@ struct WaitingLock { end: i64, kind: LockKind, } - type PosixLockWaitTable = BTreeMap>; struct PosixLockWaitGuard { @@ -179,12 +178,15 @@ impl Drop for PosixLockWaitGuard { } } } - #[derive(Debug)] struct FlockEntry { addr: OfdAddr, weak: Weak, kind: LockKind, + /// pid that created this entry. Used to detect and prune stale + /// same-pid entries whose OFD is dead (weak.strong_count() <= 1) + /// but a residual fd-table reference masks the release. + owner_pid: Pid, } /// flock(2) entries: at most one entry per (inode, OFD). @@ -452,9 +454,6 @@ fn posix_lock_deadlock_would_occur( false } - -// ─── per-inode wait queue (F_SETLKW backbone) ────────────────────────── - /// Get-or-create the wait queue for a single inode. We never garbage /// collect entries from `LOCK_WAITERS`: a wait queue carries no per-task /// state once it is empty, and waiters always re-check conflict after @@ -819,6 +818,12 @@ enum FlockAttempt { /// downgraded — including the conflict path, because Linux's non-atomic /// conversion drops the caller's prior entry before checking peers, which /// on its own may unblock a peer parked waiting for that entry to go away. +/// +/// Before checking conflicts, stale entries owned by the current pid whose +/// OFD is dead (weak.strong_count() <= 1) are pruned. This matches Linux +/// `fs/locks.c` `locks_flock_remove_dead()` — the entry cannot be considered +/// held if the only remaining `Arc` reference to the file is the one backing +/// the `Weak` inside the entry itself. fn try_flock_once( key: InodeKey, addr: OfdAddr, @@ -830,6 +835,14 @@ fn try_flock_once( let before = entries.len(); entries.retain(|e| e.weak.strong_count() != 0); + // Prune stale same-pid entries whose OFD is dead. A dead OFD + // (weak.strong_count() <= 1) means no live fd references the file; + // the only Arc is the one backing the Weak inside this FlockEntry. + // Cross-pid entries are NOT pruned — only the owning pid can declare + // its own entry stale, to avoid a racy process freeing another + // process's still-valid lock. + let pid = current_pid(); + entries.retain(|e| !(e.owner_pid == pid && e.weak.strong_count() < 1)); let outcome = match kind { None => { // LOCK_UN: drop any entry held by this OFD. @@ -851,6 +864,7 @@ fn try_flock_once( addr, weak: Arc::downgrade(file), kind: want, + owner_pid: current_pid(), }); FlockAttempt::Done } @@ -863,6 +877,55 @@ fn try_flock_once( (outcome, mutated) } +/// Release the flock(2) entry held by `file` on `key`. Called from the +/// close/fd-release path when the last file descriptor referring to this +/// open file description is dropping its reference — at that point the OFD +/// is gone, so any flock it held must be released and waiters woken. +/// POSIX fcntl locks are handled by [`release_inode_posix_locks`] (pid-scoped, +/// not OFD-scoped). +pub fn release_flock_lock(key: InodeKey, file: &Arc) { + let addr = ofd_addr(file); + let mutated = { + let mut table = FLOCK_LOCKS.write(); + let Some(entries) = table.get_mut(&key) else { + return; + }; + let before = entries.len(); + entries.retain(|e| e.addr != addr); + let changed = entries.len() != before; + if entries.is_empty() { + table.remove(&key); + } + changed + }; + if mutated { + wake_flock_waiters(key); + } +} + +/// Release every `flock(2)` entry owned by `pid`. Called from the +/// process-exit hook, analogous to [`release_pid_locks`] for POSIX locks. +/// A process that exits without explicit `LOCK_UN` must not leave its flock +/// entries pinned in [`FLOCK_LOCKS`], because those entries would block +/// future lock attempts by other processes (or by the same pid reused). +pub fn release_pid_flock_locks(pid: Pid) { + let mut affected: Vec = Vec::new(); + { + let mut table = FLOCK_LOCKS.write(); + table.retain(|inode, entries| { + let before = entries.len(); + entries.retain(|e| e.owner_pid != pid); + if entries.len() != before { + affected.push(*inode); + } + !entries.is_empty() + }); + } + for key in affected { + wake_flock_waiters(key); + } +} + /// Implementation of `sys_flock`. Supports `LOCK_SH`, `LOCK_EX`, `LOCK_UN`, /// optionally OR'd with `LOCK_NB`. Without `LOCK_NB`, the caller is parked /// on the per-inode flock wait queue until the conflict clears or a signal diff --git a/os/StarryOS/kernel/src/syscall/fs/mod.rs b/os/StarryOS/kernel/src/syscall/fs/mod.rs index 5111eea1a4..d5233296f4 100644 --- a/os/StarryOS/kernel/src/syscall/fs/mod.rs +++ b/os/StarryOS/kernel/src/syscall/fs/mod.rs @@ -23,7 +23,10 @@ pub use self::{ inotify::*, io::*, io_uring::*, - lock::{release_inode_posix_locks, release_pid_locks, wake_flock_waiters, wake_lock_waiters}, + lock::{ + release_flock_lock, release_inode_posix_locks, release_pid_flock_locks, release_pid_locks, + wake_flock_waiters, wake_lock_waiters, + }, memfd::*, mount::*, pidfd::*, diff --git a/os/StarryOS/kernel/src/syscall/task/clone.rs b/os/StarryOS/kernel/src/syscall/task/clone.rs index 9b21afff36..51dc3bb6bb 100644 --- a/os/StarryOS/kernel/src/syscall/task/clone.rs +++ b/os/StarryOS/kernel/src/syscall/task/clone.rs @@ -143,6 +143,9 @@ impl CloneArgs { if flags.contains(CloneFlags::PIDFD | CloneFlags::DETACHED) { return Err(AxError::InvalidInput); } + if flags.contains(CloneFlags::NEWNS | CloneFlags::FS) { + return Err(AxError::InvalidInput); + } // CLONE_NEWCGROUP is not yet implemented. if flags.contains(CloneFlags::NEWCGROUP) { @@ -300,6 +303,7 @@ impl CloneArgs { if flags.contains(CloneFlags::NEWPID) { new_nsproxy.unshare_pid(); new_nsproxy.pid_ns.lock().alloc_local_pid(tid as u64); + new_nsproxy.pid_ns.lock().set_init_global_tid(tid as u64); } if flags.contains(CloneFlags::NEWNET) { new_nsproxy.unshare_net(); @@ -315,7 +319,11 @@ impl CloneArgs { let mut parent_ns = old_proc_data.nsproxy.lock(); if let Some(child_pid_ns) = parent_ns.child_pid_ns.take() { new_nsproxy.pid_ns = child_pid_ns; - new_nsproxy.pid_ns.lock().alloc_local_pid(tid as u64); + { + let mut pid_ns = new_nsproxy.pid_ns.lock(); + pid_ns.alloc_local_pid(tid as u64); + pid_ns.set_init_global_tid(tid as u64); + } } } @@ -339,7 +347,10 @@ impl CloneArgs { if flags.contains(CloneFlags::FS) { FS_CONTEXT.scope_mut(&mut scope).clone_from(&FS_CONTEXT); } else { - let fs_context = FS_CONTEXT.lock().clone(); + let mut fs_context = FS_CONTEXT.lock().clone(); + if flags.contains(CloneFlags::NEWNS) { + fs_context.unshare_mount_namespace()?; + } *FS_CONTEXT.scope_mut(&mut scope).lock() = fs_context; } } diff --git a/os/StarryOS/kernel/src/syscall/task/namespace.rs b/os/StarryOS/kernel/src/syscall/task/namespace.rs index 2f27f214e2..d25e078010 100644 --- a/os/StarryOS/kernel/src/syscall/task/namespace.rs +++ b/os/StarryOS/kernel/src/syscall/task/namespace.rs @@ -1,16 +1,27 @@ +use alloc::sync::Arc; +use core::mem; + use ax_errno::{AxError, AxResult}; +use ax_fs_ng::FS_CONTEXT; +use ax_sync::Mutex; use ax_task::current; use linux_raw_sys::general::{ - CLONE_NEWIPC, CLONE_NEWNET, CLONE_NEWNS, CLONE_NEWPID, CLONE_NEWUSER, CLONE_NEWUTS, + CLONE_FS, CLONE_NEWIPC, CLONE_NEWNET, CLONE_NEWNS, CLONE_NEWPID, CLONE_NEWUSER, CLONE_NEWUTS, }; +use scope_local::ActiveScope; use crate::{ file::{NsFd, PidFd, get_file_like}, task::AsThread, }; -const SUPPORTED_NS_FLAGS: u32 = - CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWIPC | CLONE_NEWUSER; +const SUPPORTED_NS_FLAGS: u32 = CLONE_NEWUTS + | CLONE_NEWPID + | CLONE_NEWNS + | CLONE_NEWNET + | CLONE_NEWIPC + | CLONE_NEWUSER + | CLONE_FS; /// unshare(2) — disassociate parts of the process execution context. pub fn sys_unshare(flags: u32) -> AxResult { @@ -21,25 +32,60 @@ pub fn sys_unshare(flags: u32) -> AxResult { let curr = current(); let proc_data = &curr.as_thread().proc_data; - let mut nsproxy = proc_data.nsproxy.lock(); + let want_ns = flags & CLONE_NEWNS != 0; + let want_fs = flags & CLONE_FS != 0; - if flags & CLONE_NEWUTS != 0 { - nsproxy.unshare_uts(); - } - if flags & CLONE_NEWPID != 0 { - nsproxy.prepare_child_pid_ns(); - } - if flags & CLONE_NEWNS != 0 { - nsproxy.unshare_mnt(); - } - if flags & CLONE_NEWNET != 0 { - nsproxy.unshare_net(); + // Phase 1: spinlock-protected nsproxy ops (SpinNoIrq — no sleeping). + { + let mut nsproxy = proc_data.nsproxy.lock(); + if flags & CLONE_NEWUTS != 0 { + nsproxy.unshare_uts(); + } + if flags & CLONE_NEWPID != 0 { + nsproxy.prepare_child_pid_ns(); + } + if flags & CLONE_NEWNET != 0 { + nsproxy.unshare_net(); + } + if flags & CLONE_NEWIPC != 0 { + nsproxy.unshare_ipc(); + } + if flags & CLONE_NEWUSER != 0 { + nsproxy.unshare_user(); + } } - if flags & CLONE_NEWIPC != 0 { - nsproxy.unshare_ipc(); + + // Phase 2: FsContext ops (mount-ns + CLONE_FS) require a Mutex + // (blocking), which must not be held inside SpinNoIrq. + // + // Both CLONE_NEWNS and CLONE_FS require the caller's task-local + // FS_CONTEXT to be rebound to a private copy before any mutation. + // clone(CLONE_FS) shares the same Arc> between parent + // and child, so operating directly on the shared Arc (e.g. unshare + // mount namespace or chdir) would leak to the other sharer. + if want_ns || want_fs { + let cloned_inner = FS_CONTEXT.lock().clone(); + let new_fs = Arc::new(Mutex::new(cloned_inner)); + + // scope.write() would self-deadlock because on_enter holds a + // leaked scope.read() guard for the task's lifetime. Temporarily + // release it, do the rebind, then re-acquire. + // SAFETY: the scope is task-private. The brief window with + // ActiveScope pointing to global contains no FS_CONTEXT access. + unsafe { curr.as_thread().proc_data.scope.force_read_decrement() }; + ActiveScope::set_global(); + + let mut scope = curr.as_thread().proc_data.scope.write(); + *FS_CONTEXT.scope_mut(&mut scope) = new_fs; + drop(scope); + + let read_guard = curr.as_thread().proc_data.scope.read(); + unsafe { ActiveScope::set(&read_guard) }; + mem::forget(read_guard); } - if flags & CLONE_NEWUSER != 0 { - nsproxy.unshare_user(); + if want_ns { + FS_CONTEXT.lock().unshare_mount_namespace()?; + proc_data.nsproxy.lock().unshare_mnt(); } Ok(0) @@ -116,7 +162,11 @@ fn setns_via_nsfd(nsfd: &NsFd, nstype: u32) -> AxResult { match nsfd { NsFd::Uts(ns) => nsproxy.set_ns_uts(ns.clone()), NsFd::Ipc(ns) => nsproxy.set_ns_ipc(ns.clone()), - NsFd::Mnt(ns) => nsproxy.set_ns_mnt(ns.clone()), + NsFd::Mnt { ns, fs_ns } => { + drop(nsproxy); + FS_CONTEXT.lock().set_mount_namespace(fs_ns.clone())?; + proc_data.nsproxy.lock().set_ns_mnt(ns.clone()); + } NsFd::Pid(ns) => nsproxy.set_ns_pid(ns.clone()), NsFd::Net(ns) => nsproxy.set_ns_net(ns.clone()), NsFd::User(ns) => { @@ -157,7 +207,15 @@ fn setns_via_pidfd(pidfd: &PidFd, nstype: u32) -> AxResult { } let target_proc = pidfd.process_data()?; - let target_nsproxy = target_proc.nsproxy.lock(); + let target_mnt_fs_ns = if nstype & CLONE_NEWNS != 0 { + let scope = target_proc.scope.read(); + let fs_context = FS_CONTEXT.scope(&scope).clone(); + drop(scope); + Some(fs_context.lock().mount_namespace().clone()) + } else { + None + }; + let target_nsproxy = target_proc.nsproxy.lock().clone_all(); let curr = current(); let thread = curr.as_thread(); @@ -183,22 +241,27 @@ fn setns_via_pidfd(pidfd: &PidFd, nstype: u32) -> AxResult { let mut nsproxy = proc_data.nsproxy.lock(); if nstype & CLONE_NEWUTS != 0 { - nsproxy.set_ns_uts(target_nsproxy.uts_ns.clone()); + nsproxy.set_ns_uts(target_nsproxy.uts_ns); } if nstype & CLONE_NEWIPC != 0 { - nsproxy.set_ns_ipc(target_nsproxy.ipc_ns.clone()); + nsproxy.set_ns_ipc(target_nsproxy.ipc_ns); } if nstype & CLONE_NEWNS != 0 { - nsproxy.set_ns_mnt(target_nsproxy.mnt_ns.clone()); + drop(nsproxy); + FS_CONTEXT + .lock() + .set_mount_namespace(target_mnt_fs_ns.expect("target mount namespace captured"))?; + nsproxy = proc_data.nsproxy.lock(); + nsproxy.set_ns_mnt(target_nsproxy.mnt_ns); } if nstype & CLONE_NEWPID != 0 { - nsproxy.set_ns_pid(target_nsproxy.pid_ns.clone()); + nsproxy.set_ns_pid(target_nsproxy.pid_ns); } if nstype & CLONE_NEWNET != 0 { - nsproxy.set_ns_net(target_nsproxy.net_ns.clone()); + nsproxy.set_ns_net(target_nsproxy.net_ns); } if nstype & CLONE_NEWUSER != 0 { - nsproxy.set_ns_user(target_nsproxy.user_ns.clone()); + nsproxy.set_ns_user(target_nsproxy.user_ns); } debug!( diff --git a/os/StarryOS/kernel/src/syscall/task/wait.rs b/os/StarryOS/kernel/src/syscall/task/wait.rs index eed8af810e..01c1058bda 100644 --- a/os/StarryOS/kernel/src/syscall/task/wait.rs +++ b/os/StarryOS/kernel/src/syscall/task/wait.rs @@ -1,5 +1,4 @@ use alloc::{sync::Arc, vec::Vec}; -use core::{future::poll_fn, task::Poll}; use ax_errno::{AxError, AxResult, LinuxError}; use ax_task::{ @@ -20,7 +19,7 @@ use crate::{ task::{ AsThread, JobStatus, ProcessData, decode_wait_status, get_process_data, get_task, get_zombie_cred, is_zombie_clone_child, processes, remove_process, traced_zombies_for, - unregister_zombie, zombie_wait_parent_tid, + unregister_zombie, wait_on_pollset, zombie_wait_parent_tid, }, }; @@ -108,7 +107,6 @@ fn waitid_pidfd_target(fd: i32) -> AxResult { .map_err(|_| AxError::BadFileDescriptor)?; Ok(WaitTarget::Pid(pidfd.pid())) } - fn stopped_wait_signo(data: &ProcessData, signo: Signo) -> i32 { let event = data.ptrace_event().unwrap_or(0); let mut wait_signo = if event != 0 { @@ -340,26 +338,10 @@ pub fn sys_waitpid(pid: i32, exit_code: *mut i32, options: u32) -> AxResult Poll::Ready(res), - None => { - // Registration happens from wait task context. - unsafe { - proc_data - .child_exit_event - .register(cx.waker(), axpoll::IoEvents::IN) - }; - // A child may exit between the check above and waker - // registration. Recheck after registering so that wakeup is - // not lost in that race window. - match check_children().transpose() { - Some(res) => Poll::Ready(res), - None => Poll::Pending, - } - } - } - })))? + block_on(interruptible(wait_on_pollset( + &proc_data.child_exit_event, + || check_children().transpose(), + )))? } pub fn sys_waitid( @@ -489,21 +471,8 @@ pub fn sys_waitid( } }; - block_on(interruptible(poll_fn(|cx| { - match check_children().transpose() { - Some(res) => Poll::Ready(res), - None => { - // Registration happens from wait task context. - unsafe { - proc_data - .child_exit_event - .register(cx.waker(), axpoll::IoEvents::IN) - }; - match check_children().transpose() { - Some(res) => Poll::Ready(res), - None => Poll::Pending, - } - } - } - })))? + block_on(interruptible(wait_on_pollset( + &proc_data.child_exit_event, + || check_children().transpose(), + )))? } diff --git a/os/StarryOS/kernel/src/task/mod.rs b/os/StarryOS/kernel/src/task/mod.rs index 98cda01c97..b336f70810 100644 --- a/os/StarryOS/kernel/src/task/mod.rs +++ b/os/StarryOS/kernel/src/task/mod.rs @@ -14,8 +14,10 @@ mod user; use alloc::{boxed::Box, collections::BTreeMap, string::String, sync::Arc, vec::Vec}; use core::{ cell::RefCell, + future::poll_fn, ops::Deref, sync::atomic::{AtomicBool, AtomicI32, AtomicU8, AtomicU32, AtomicUsize, Ordering}, + task::Poll, }; use ax_errno::AxResult; @@ -136,6 +138,10 @@ pub struct Thread { /// Ready to exit pub exit: Arc, + /// Woken when a signal arrives at this thread, so signalfd/epoll pollers + /// can observe newly-pending signals even when the signal is blocked. + pub signalfd_waker: PollSet, + /// Indicates whether the thread is currently accessing user memory. accessing_user_memory: AtomicBool, @@ -236,6 +242,7 @@ impl Thread { seccomp: SpinNoIrq::new(SeccompState::default()), cred: SpinNoIrq::new(cred), + signalfd_waker: PollSet::new(), fault_dump_signo: AtomicU8::new(0), kretprobe_stack: SpinNoIrq::new(alloc::vec::Vec::new()), @@ -551,6 +558,29 @@ impl VforkDone { } } +/// Waits on a [`PollSet`] after a caller-supplied condition reports no +/// immediate result. +/// +/// The condition is checked before and after waker registration, so callers +/// avoid lost wakeups. +pub async fn wait_on_pollset(poll: &PollSet, mut check: impl FnMut() -> Option) -> T { + poll_fn(move |cx| { + if let Some(value) = check() { + return Poll::Ready(value); + } + + // Registration happens from wait task context. + unsafe { poll.register(cx.waker(), IoEvents::IN) }; + + if let Some(value) = check() { + Poll::Ready(value) + } else { + Poll::Pending + } + }) + .await +} + /// A pending job-control status change awaiting report to the parent's /// `waitpid(WUNTRACED | WCONTINUED)`. #[derive(Clone, Copy)] diff --git a/os/StarryOS/kernel/src/task/ops.rs b/os/StarryOS/kernel/src/task/ops.rs index 3bfde60497..6d3bb60815 100644 --- a/os/StarryOS/kernel/src/task/ops.rs +++ b/os/StarryOS/kernel/src/task/ops.rs @@ -601,6 +601,7 @@ pub fn do_exit(exit_code: i32, group_exit: bool) { // this, a child fork → F_SETLK → exit would permanently pin the // record in FCNTL_LOCKS and block all later acquirers. crate::syscall::release_pid_locks(process.pid()); + crate::syscall::release_pid_flock_locks(process.pid()); // Snapshot children BEFORE process.exit() reparents them to init // via mem::take. Otherwise process.children() returns an empty @@ -673,6 +674,36 @@ pub fn do_exit(exit_code: i32, group_exit: bool) { } } + // If this process was the init of a non-root PID namespace, + // send SIGKILL to all remaining processes in that namespace + // (Linux: zap_pid_ns_processes). + { + let ns = thr.proc_data.nsproxy.lock(); + let pid_ns_lock = ns.pid_ns.lock(); + if pid_ns_lock.level > 0 && pid_ns_lock.init_global_tid() == Some(process.pid() as u64) + { + let ns_ptr = Arc::as_ptr(&ns.pid_ns) as usize; + drop(pid_ns_lock); + drop(ns); + + let proc_table = PROCESS_TABLE.read(); + let victims: Vec = proc_table + .values() + .filter(|pd| { + pd.proc.pid() != process.pid() + && Arc::as_ptr(&pd.nsproxy.lock().pid_ns) as usize == ns_ptr + }) + .map(|pd| pd.proc.pid()) + .collect(); + drop(proc_table); + + let sig = SignalInfo::new_kernel(Signo::SIGKILL); + for pid in victims { + let _ = send_signal_to_process(pid, Some(sig.clone())); + } + } + } + // Process exit state is published before waking pidfd/wait waiters. unsafe { thr.proc_data.exit_event.wake(axpoll::IoEvents::IN) }; diff --git a/os/StarryOS/kernel/src/task/signal.rs b/os/StarryOS/kernel/src/task/signal.rs index a612c92512..b0e131d074 100644 --- a/os/StarryOS/kernel/src/task/signal.rs +++ b/os/StarryOS/kernel/src/task/signal.rs @@ -8,6 +8,7 @@ use ax_task::{ TaskInner, current, future::{block_on, interruptible}, }; +use axpoll::IoEvents; use linux_raw_sys::general::{CLD_CONTINUED, CLD_STOPPED, CLD_TRAPPED}; use starry_process::Pid; use starry_signal::{SignalInfo, SignalOSAction, SignalSet, Signo}; @@ -494,7 +495,13 @@ pub fn with_blocked_signals( } pub(super) fn send_signal_thread_inner(task: &TaskInner, thr: &Thread, sig: SignalInfo) { - if thr.signal.send_signal(sig) { + let accepted = thr.signal.send_signal(sig); + // Always wake signalfd waiters so a signalfd monitoring for this signal + // (even a blocked one) can become readable in epoll/poll. Without this, + // a process using signalfd + SA_RESTART or signalfd + blocked signals + // would never observe newly-pending signals from the event loop. + unsafe { thr.signalfd_waker.wake(IoEvents::IN) }; + if accepted { task.interrupt(); } } @@ -516,6 +523,9 @@ pub fn send_signal_to_thread(tgid: Option, tid: Pid, sig: Option) -> AxResult<()> } } } + // Wake signalfd waiters on every thread: even blocked process-level + // signals must be visible from signalfd in an epoll event loop. + for tid in proc_data.proc.threads() { + if let Ok(task) = get_task(tid) + && let Some(thr) = task.try_as_thread() + { + unsafe { thr.signalfd_waker.wake(IoEvents::IN) }; + } + } } Ok(()) diff --git a/os/arceos/api/arceos_posix_api/src/imp/fs.rs b/os/arceos/api/arceos_posix_api/src/imp/fs.rs index 904ebba057..e6949461b1 100644 --- a/os/arceos/api/arceos_posix_api/src/imp/fs.rs +++ b/os/arceos/api/arceos_posix_api/src/imp/fs.rs @@ -260,7 +260,7 @@ pub fn sys_open(filename: *const c_char, flags: c_int, mode: ctypes::mode_t) -> /// Reference: Starry OS implementation /// Return number of bytes written on success. pub unsafe fn sys_getdents64(fd: c_int, buf: *mut u8, len: usize) -> ctypes::ssize_t { - debug!("sys_getdents64 (Linux) <= {fd} {:#x} {len}", buf as usize); + debug!("sys_getdents64 <= {fd} {:#x} {len}", buf as usize); syscall_body!(sys_getdents64, { if buf.is_null() || len == 0 { return Err(LinuxError::EINVAL); diff --git a/os/arceos/modules/axfs-ng/src/fs/ext4/rsext4/fs.rs b/os/arceos/modules/axfs-ng/src/fs/ext4/rsext4/fs.rs index 390404af7f..1839546e18 100644 --- a/os/arceos/modules/axfs-ng/src/fs/ext4/rsext4/fs.rs +++ b/os/arceos/modules/axfs-ng/src/fs/ext4/rsext4/fs.rs @@ -1,4 +1,8 @@ -use alloc::{boxed::Box, sync::Arc}; +use alloc::{ + boxed::Box, + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; use core::cell::OnceCell; use axfs_ng_vfs::{ @@ -19,6 +23,15 @@ const EXT4_ROOT_INO: u32 = 2; pub(crate) struct Ext4State { pub fs: rsext4::Ext4FileSystem, pub dev: Jbd2Dev, + /// Live `Inode` Arc reference count per inode number. + /// + /// Every `Inode::new` increments; every `Inode::drop` decrements. + /// When the count reaches 0 *and* the inode is zero-link (present in + /// `zero_link`), the inode is freed. + pub(crate) live_refs: BTreeMap, + /// Inodes whose on-disk `i_links_count` has been driven to 0. + /// They remain allocated until the last live `Inode` Arc is dropped. + pub(crate) zero_link: BTreeSet, } impl Ext4State { @@ -27,6 +40,60 @@ impl Ext4State { let dev = &mut self.dev as *mut _; unsafe { (&mut *fs, &mut *dev) } } + + /// Increment the live-reference count for `ino`. + /// + /// Called from `Inode::new` every time an `Inode` Arc is created. + pub(crate) fn inc_ref(&mut self, ino: InodeNumber) { + self.live_refs + .entry(ino) + .and_modify(|c| *c += 1) + .or_insert(1); + } + + /// Decrement the live-reference count for `ino`. + /// + /// Returns `true` when the count reaches 0 (the entry is removed). + /// The caller must also check `is_zero_link` before freeing the inode. + pub(crate) fn dec_ref(&mut self, ino: InodeNumber) -> bool { + use alloc::collections::btree_map::Entry; + match self.live_refs.entry(ino) { + Entry::Occupied(mut e) => { + let count = e.get_mut(); + *count = count.saturating_sub(1); + if *count == 0 { + e.remove(); + true + } else { + false + } + } + Entry::Vacant(_) => false, + } + } + + /// Mark an inode as zero-link (its last directory entry was removed). + /// + /// Returns `true` if there are **no** live `Inode` Arcs for this inode + /// right now — the caller should `free_inode` immediately. When + /// returning `false` the ino is inserted into `zero_link` for deferred + /// cleanup; when returning `true` it is NOT inserted (nothing to defer). + pub(crate) fn mark_zero_link(&mut self, ino: InodeNumber) -> bool { + if self.live_refs.contains_key(&ino) { + self.zero_link.insert(ino); + false + } else { + true + } + } + + pub(crate) fn is_zero_link(&self, ino: InodeNumber) -> bool { + self.zero_link.contains(&ino) + } + + pub(crate) fn clear_zero_link(&mut self, ino: InodeNumber) { + self.zero_link.remove(&ino); + } } pub struct Ext4Filesystem { @@ -50,9 +117,9 @@ impl Ext4Filesystem { let (fs, dev, readonly) = match rsext4::Ext4FileSystem::device_has_error_state(&mut dev) { Ok(true) => { warn!( - "ext4 filesystem is in error state; mounting read-only without journal replay" + "ext4 filesystem is in error state; replaying journal then mounting read-only" ); - Self::mount_readonly_no_replay(dev)? + Self::mount_readonly_fallback(dev, true)? } Ok(false) => match rsext4::mount(&mut dev) { Ok(fs) => (fs, dev, false), @@ -61,7 +128,7 @@ impl Ext4Filesystem { "ext4 journal replay failed with EUCLEAN; retrying read-only without \ journal replay" ); - Self::mount_readonly_no_replay(dev)? + Self::mount_readonly_fallback(dev, false)? } Err(err) => return Err(into_vfs_err(err)), }, @@ -70,21 +137,28 @@ impl Ext4Filesystem { "ext4 superblock check failed with EUCLEAN; retrying read-only without \ journal replay" ); - Self::mount_readonly_no_replay(dev)? + Self::mount_readonly_fallback(dev, false)? } Err(err) => return Err(into_vfs_err(err)), }; let fs = Arc::new(Self { - inner: Mutex::new(Ext4State { fs, dev }), + inner: Mutex::new(Ext4State { + fs, + dev, + live_refs: BTreeMap::new(), + zero_link: BTreeSet::new(), + }), root_dir: OnceCell::new(), readonly, }); + let root_ino = InodeNumber::new(EXT4_ROOT_INO).unwrap(); + fs.lock().inc_ref(root_ino); let _ = fs.root_dir.set(DirEntry::new_dir( |this| { DirNode::new(Inode::new( fs.clone(), - InodeNumber::new(EXT4_ROOT_INO).unwrap(), + root_ino, Some(this), Some("/".into()), )) @@ -94,12 +168,30 @@ impl Ext4Filesystem { Ok(Filesystem::new(fs)) } - fn mount_readonly_no_replay( - dev: Jbd2Dev, + /// Mount read-only as a fallback when journal replay fails or the + /// filesystem is in error state. + /// + /// Linux always replays the journal before mounting read-only when the + /// filesystem is in error state, because unreplayed journal transactions + /// leave metadata inconsistent. We mirror that behaviour. + /// + /// Crucially, we never call `into_inner()` on the `Jbd2Dev` — the block + /// cache must be preserved so that reads after mount (e.g. loading a + /// guest kernel image) can hit the cache rather than issuing fresh + /// hardware I/O that may hang on a controller left in a bad state by the + /// failed mount attempt. + fn mount_readonly_fallback( + mut dev: Jbd2Dev, + replay_journal: bool, ) -> VfsResult<(rsext4::Ext4FileSystem, Jbd2Dev, bool)> { - let mut dev = Jbd2Dev::initial_jbd2dev(0, dev.into_inner(), false); - let fs = rsext4::mount_with_options(&mut dev, MountOptions::read_only_no_journal_replay()) - .map_err(into_vfs_err)?; + let fs = rsext4::mount_with_options( + &mut dev, + MountOptions { + readonly: true, + replay_journal, + }, + ) + .map_err(into_vfs_err)?; Ok((fs, dev, true)) } diff --git a/os/arceos/modules/axfs-ng/src/fs/ext4/rsext4/inode.rs b/os/arceos/modules/axfs-ng/src/fs/ext4/rsext4/inode.rs index ea552b1fb9..248d39140f 100644 --- a/os/arceos/modules/axfs-ng/src/fs/ext4/rsext4/inode.rs +++ b/os/arceos/modules/axfs-ng/src/fs/ext4/rsext4/inode.rs @@ -4,7 +4,7 @@ use alloc::{ string::{String, ToString}, sync::Arc, }; -use core::any::Any; +use core::{any::Any, cell::Cell}; use axfs_ng_vfs::{ DeviceId, DirEntry, DirEntrySink, DirNode, DirNodeOps, FileNode, FileNodeOps, FilesystemOps, @@ -33,6 +33,10 @@ impl Inode { this: Option, path: Option, ) -> Arc { + // NOTE: callers MUST call state.inc_ref(ino) before or after + // creating the Inode Arc. We cannot lock here because many + // callers already hold the Ext4State lock (lookup_locked, + // create, link) and SpinNoIrq is not recursive. Arc::new(Self { fs, ino, @@ -81,6 +85,7 @@ impl Inode { let (ino, inode) = rsext4::dir::get_inode_with_num(fs, dev, &path) .map_err(into_vfs_err)? .ok_or(VfsError::NotFound)?; + state.inc_ref(ino); Ok(self.create_entry(ino, &inode, name)) } @@ -96,6 +101,20 @@ impl Inode { } } +impl Drop for Inode { + fn drop(&mut self) { + let mut state = self.fs.lock(); + let is_last = state.dec_ref(self.ino); + if is_last && state.is_zero_link(self.ino) { + state.clear_zero_link(self.ino); + let (fs, dev) = state.split(); + if let Ok(mut on_disk) = fs.get_inode_by_num(dev, self.ino) { + let _ = rsext4::free_inode(fs, dev, self.ino, &mut on_disk); + } + } + } +} + impl NodeOps for Inode { fn inode(&self) -> u64 { self.ino.as_u64() @@ -200,9 +219,9 @@ impl FileNodeOps for Inode { { let mut state = self.fs.lock(); let (fs, dev) = state.split(); - // Use inode-number-based write to avoid path re-resolution. - // Path-based write_file() fails with NotFound after rename/unlink, - // which causes dirty page loss when jcode atomically replaces files. + // Use inode-number-based write so open-unlinked regular files + // remain writable after their directory entry has been removed. + // Path-based write_file() fails with NotFound after unlink. rsext4::write_inode_data(dev, fs, self.ino, offset, buf).map_err(into_vfs_err)?; } Ok(buf.len()) @@ -221,15 +240,15 @@ impl FileNodeOps for Inode { } fn set_len(&self, len: u64) -> VfsResult<()> { - let mut state = self.fs.lock(); - let (fs, dev) = state.split(); - rsext4::truncate( - dev, - fs, - &self.path.clone().ok_or(VfsError::InvalidInput)?, - len, - ) - .map_err(into_vfs_err) + { + let mut state = self.fs.lock(); + let (fs, dev) = state.split(); + // An open-unlinked regular file stays alive by inode number, not by + // a directory entry. set_len must operate on the inode directly + // because path re-resolution would fail after unlink. + rsext4::truncate_inode(dev, fs, self.ino, len).map_err(into_vfs_err)?; + } + self.fs.sync_to_disk() } fn set_symlink(&self, target: &str) -> VfsResult<()> { @@ -454,6 +473,7 @@ impl DirNodeOps for Inode { }) .map_err(into_vfs_err)?; Self::update_ctime_with(fs, dev, ino)?; + state.inc_ref(ino); ino }; @@ -509,7 +529,7 @@ impl DirNodeOps for Inode { fn unlink(&self, name: &str, is_dir: bool) -> VfsResult<()> { let dir_path = self.dir_path()?; let path = join_child_path(&dir_path, name); - let mut forget_file_ino = None; + let forget_file_ino: Cell> = Cell::new(None); { let mut state = self.fs.lock(); let (fs, dev) = state.split(); @@ -524,20 +544,46 @@ impl DirNodeOps for Inode { (false, true) => return Err(VfsError::NotADirectory), _ => {} } + let mut deferred_zero_link: Option = None; if inode.is_dir() { let mut dir_inode = inode; // Ext4Inode is Copy if !rsext4::is_dir_empty(fs, dev, &mut dir_inode).map_err(into_vfs_err)? { return Err(VfsError::DirectoryNotEmpty); } rsext4::delete_dir(fs, dev, &path).map_err(into_vfs_err)?; + } else if inode.i_links_count > 1 { + // Multiple hard links remain after this one is removed. + rsext4::unlink(fs, dev, &path).map_err(into_vfs_err)?; } else { - if inode.i_links_count <= 1 { - forget_file_ino = Some(ino); + // Last (or only) link. Mark the inode zero-link, + // remove the directory entry, and defer the + // zero-link / free_inode check until after the + // fs/dev split borrow ends. + fs.modify_inode(dev, ino, |on_disk| { + on_disk.i_links_count = 0; + }) + .map_err(into_vfs_err)?; + rsext4::remove_inodeentry_from_parentdir(fs, dev, &dir_path, name) + .map_err(into_vfs_err)?; + deferred_zero_link = Some(ino); + } + // Capture the inode number for page-cache key cleanup. + // This must be set before the fs/dev borrow ends because + // deferred_zero_link is bound inside this scope. + forget_file_ino.set(deferred_zero_link); + // fs/dev borrow ends here (last use in all branches). + // `state` is accessible again. + if let Some(ino) = deferred_zero_link + && state.mark_zero_link(ino) + { + // No live Inode Arcs — free immediately. + let (fs, dev) = state.split(); + if let Ok(mut on_disk) = fs.get_inode_by_num(dev, ino) { + let _ = rsext4::free_inode(fs, dev, ino, &mut on_disk); } - rsext4::unlink(fs, dev, &path).map_err(into_vfs_err)?; } } - if let Some(ino) = forget_file_ino { + if let Some(ino) = forget_file_ino.get() { forget_cached_file_key(&*self.fs, ino.as_u64()); } self.fs.sync_to_disk() diff --git a/os/arceos/modules/axfs-ng/src/fs_core/context.rs b/os/arceos/modules/axfs-ng/src/fs_core/context.rs index a8ad0efeed..43e8bc5efd 100644 --- a/os/arceos/modules/axfs-ng/src/fs_core/context.rs +++ b/os/arceos/modules/axfs-ng/src/fs_core/context.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "vfs")] +use alloc::vec; use alloc::{ borrow::{Cow, ToOwned}, collections::vec_deque::VecDeque, @@ -5,6 +7,10 @@ use alloc::{ sync::{Arc, Weak}, vec::Vec, }; +#[cfg(feature = "vfs")] +use core::sync::atomic::AtomicU64; +#[cfg(feature = "vfs")] +use core::sync::atomic::Ordering; use ax_io::{Read, Write}; #[cfg(feature = "vfs")] @@ -34,6 +40,8 @@ pub static ROOT_FS_CONTEXT: Once = Once::new(); /// filesystem context and apply the same root / cwd fixup that Linux /// performs in `chroot_fs_refs()` after `pivot_root(2)`. static FS_REGISTRY: IrqMutex>>> = IrqMutex::new(Vec::new()); +#[cfg(feature = "vfs")] +static MOUNT_NAMESPACE_ID: AtomicU64 = AtomicU64::new(1); /// Register an `FsContext` in the global [`FS_REGISTRY`]. fn register_fs_context(ctx: &Arc>) { @@ -55,6 +63,9 @@ pub fn is_mount_busy(mp: &Arc) -> bool { }; for ctx_arc in refs { let ctx = ctx_arc.lock(); + if !ctx.mount_namespace_contains(mp) { + continue; + } if Arc::ptr_eq(ctx.root_dir().mountpoint(), mp) || Arc::ptr_eq(ctx.current_dir().mountpoint(), mp) { @@ -64,6 +75,49 @@ pub fn is_mount_busy(mp: &Arc) -> bool { false } +/// Namespace-local mount tree visible to an [`FsContext`]. +#[cfg(feature = "vfs")] +#[derive(Debug, Clone)] +pub struct MountNamespace { + id: u64, + root_mount: Arc, +} + +#[cfg(feature = "vfs")] +impl MountNamespace { + fn new(root_mount: Arc) -> Self { + Self { + id: MOUNT_NAMESPACE_ID.fetch_add(1, Ordering::Relaxed), + root_mount, + } + } + + /// Returns a kernel-local identifier for diagnostics. + pub fn id(&self) -> u64 { + self.id + } + + /// Returns the root mountpoint of this namespace. + pub fn root_mount(&self) -> &Arc { + &self.root_mount + } + + fn clone_namespace(&self) -> Arc { + Arc::new(Self::new(self.root_mount.clone_tree())) + } + + fn contains_mountpoint(&self, mountpoint: &Arc) -> bool { + let mut stack = vec![self.root_mount.clone()]; + while let Some(current) = stack.pop() { + if Arc::ptr_eq(¤t, mountpoint) { + return true; + } + stack.extend(current.children()); + } + false + } +} + scope_local::scope_local! { /// Task-local filesystem context, defaulting to a clone of [`ROOT_FS_CONTEXT`]. pub static FS_CONTEXT: Arc> = { @@ -93,6 +147,8 @@ pub struct ReadDirEntry { /// Provides `std::fs`-like interface. #[derive(Debug, Clone)] pub struct FsContext { + #[cfg(feature = "vfs")] + mnt_ns: Arc, root_dir: Location, current_dir: Location, } @@ -100,12 +156,40 @@ pub struct FsContext { impl FsContext { /// Creates a new context with `root_dir` as both root and current directory. pub fn new(root_dir: Location) -> Self { + #[cfg(feature = "vfs")] + { + let mnt_ns = Arc::new(MountNamespace::new(root_dir.mountpoint().clone())); + Self::new_in_namespace(mnt_ns, root_dir) + } + #[cfg(not(feature = "vfs"))] + { + Self { + root_dir: root_dir.clone(), + current_dir: root_dir, + } + } + } + + #[cfg(feature = "vfs")] + fn new_in_namespace(mnt_ns: Arc, root_dir: Location) -> Self { Self { root_dir: root_dir.clone(), current_dir: root_dir, + mnt_ns, } } + /// Returns the mount namespace backing this filesystem context. + #[cfg(feature = "vfs")] + pub fn mount_namespace(&self) -> &Arc { + &self.mnt_ns + } + + #[cfg(feature = "vfs")] + fn mount_namespace_contains(&self, mountpoint: &Arc) -> bool { + self.mnt_ns.contains_mountpoint(mountpoint) + } + /// Returns a reference to the root directory. pub fn root_dir(&self) -> &Location { &self.root_dir @@ -130,9 +214,33 @@ impl FsContext { Ok(Self { root_dir: self.root_dir.clone(), current_dir, + #[cfg(feature = "vfs")] + mnt_ns: self.mnt_ns.clone(), }) } + /// Rebind this context to a freshly cloned mount namespace. + #[cfg(feature = "vfs")] + pub fn unshare_mount_namespace(&mut self) -> VfsResult<()> { + let new_ns = self.mnt_ns.clone_namespace(); + self.set_mount_namespace(new_ns) + } + + /// Rebind this context to an existing mount namespace. + #[cfg(feature = "vfs")] + pub fn set_mount_namespace(&mut self, new_ns: Arc) -> VfsResult<()> { + let root_path = self.root_dir.absolute_path()?; + let current_path = self.current_dir.absolute_path()?; + let new_root_loc = new_ns.root_mount().root_location(); + let resolver = Self::new_in_namespace(new_ns.clone(), new_root_loc); + let root_dir = resolver.resolve(root_path)?; + let current_dir = resolver.resolve(current_path)?; + self.mnt_ns = new_ns; + self.root_dir = root_dir; + self.current_dir = current_dir; + Ok(()) + } + /// Attempts to resolve a possible symlink, at the current location (this /// assumes that `loc` is a child of current directory). pub fn try_resolve_symlink( @@ -318,6 +426,7 @@ impl FsContext { src_dir.rename(&src_name, &dst_dir, &dst_name) } + /// Creates a new, empty directory at the provided path. /// Creates a new, empty directory at the provided path. pub fn create_dir( &self, @@ -327,16 +436,12 @@ impl FsContext { gid: u32, ) -> VfsResult { let path = path.as_ref(); - // Empty path should return NotFound, not InvalidInput if path.as_str().is_empty() { return Err(VfsError::NotFound); } let (dir, name) = match self.resolve_nonexistent(path) { Ok(pair) => pair, Err(VfsError::InvalidInput) => { - // Path has no filename component (e.g. "/" or "."). - // Resolve it: if it exists and is a directory, return - // AlreadyExists (matching Linux EEXIST behaviour for mkdir("/")). return match self.resolve(path) { Ok(loc) if loc.node_type() == NodeType::Directory => { Err(VfsError::AlreadyExists) diff --git a/test-suit/starryos/qemu-smp1/system/bugfix-bug-tcp-concurrent-connect/src/main.c b/test-suit/starryos/qemu-smp1/system/bugfix-bug-tcp-concurrent-connect/src/main.c index 945f830c36..82a6606c42 100644 --- a/test-suit/starryos/qemu-smp1/system/bugfix-bug-tcp-concurrent-connect/src/main.c +++ b/test-suit/starryos/qemu-smp1/system/bugfix-bug-tcp-concurrent-connect/src/main.c @@ -89,6 +89,10 @@ static int wait_readable(int fd, int slot) { pfd.fd = fd; pfd.events = POLLIN; + /* + * The connection has already been accepted here. A timeout points at the + * payload readiness path rather than listen/accept queue handling. + */ errno = 0; int ret = poll(&pfd, 1, IO_TIMEOUT_MS); if (ret == 1 && (pfd.revents & (POLLIN | POLLERR | POLLHUP)) != 0) { diff --git a/test-suit/starryos/qemu-smp1/system/syscall-test-capget/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/syscall-test-capget/CMakeLists.txt index 2ab93bcbca..b6a24b060f 100644 --- a/test-suit/starryos/qemu-smp1/system/syscall-test-capget/CMakeLists.txt +++ b/test-suit/starryos/qemu-smp1/system/syscall-test-capget/CMakeLists.txt @@ -1,9 +1,13 @@ cmake_minimum_required(VERSION 3.20) project(test-capget C) + set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_EXTENSIONS OFF) + add_executable(test-capget src/main.c) -target_include_directories(test-capget PRIVATE src) -target_compile_options(test-capget PRIVATE -Wall -Wextra) +target_include_directories(test-capget PRIVATE src ${CMAKE_CURRENT_SOURCE_DIR}/../../common) +target_compile_options(test-capget PRIVATE -Wall -Wextra -Werror) +target_link_options(test-capget PRIVATE -static) + install(TARGETS test-capget RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/syscall-test-capset/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/syscall-test-capset/CMakeLists.txt index c5a9d335ad..ea421dcbce 100644 --- a/test-suit/starryos/qemu-smp1/system/syscall-test-capset/CMakeLists.txt +++ b/test-suit/starryos/qemu-smp1/system/syscall-test-capset/CMakeLists.txt @@ -1,9 +1,13 @@ cmake_minimum_required(VERSION 3.20) project(test-capset C) + set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_EXTENSIONS OFF) + add_executable(test-capset src/main.c) -target_include_directories(test-capset PRIVATE src) -target_compile_options(test-capset PRIVATE -Wall -Wextra) +target_include_directories(test-capset PRIVATE src ${CMAKE_CURRENT_SOURCE_DIR}/../../common) +target_compile_options(test-capset PRIVATE -Wall -Wextra -Werror) +target_link_options(test-capset PRIVATE -static) + install(TARGETS test-capset RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/syscall-test-raw-msg-peek/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/syscall-test-raw-msg-peek/CMakeLists.txt index 207a6fa0b9..6e84d332de 100644 --- a/test-suit/starryos/qemu-smp1/system/syscall-test-raw-msg-peek/CMakeLists.txt +++ b/test-suit/starryos/qemu-smp1/system/syscall-test-raw-msg-peek/CMakeLists.txt @@ -1,9 +1,13 @@ cmake_minimum_required(VERSION 3.20) project(test-raw-msg-peek C) + set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_EXTENSIONS OFF) + add_executable(test-raw-msg-peek src/main.c) -target_include_directories(test-raw-msg-peek PRIVATE src) +target_include_directories(test-raw-msg-peek PRIVATE src ${CMAKE_CURRENT_SOURCE_DIR}/../../common) target_compile_options(test-raw-msg-peek PRIVATE -Wall -Wextra -Werror) +target_link_options(test-raw-msg-peek PRIVATE -static) + install(TARGETS test-raw-msg-peek RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/syscall-test-raw-msg-peek/src/test_framework.h b/test-suit/starryos/qemu-smp1/system/syscall-test-raw-msg-peek/src/test_framework.h index 81c950802b..2f47878f32 100644 --- a/test-suit/starryos/qemu-smp1/system/syscall-test-raw-msg-peek/src/test_framework.h +++ b/test-suit/starryos/qemu-smp1/system/syscall-test-raw-msg-peek/src/test_framework.h @@ -1,9 +1,23 @@ #pragma once +/* 必须在最前面定义,确保 pipe2/gettid 等可用 */ #ifndef _GNU_SOURCE #define _GNU_SOURCE #endif +/* + * StarryOS Syscall Test Framework + * + * 极简独立测试框架:每个文件测一个 syscall,独立编译运行。 + * 目标:出错时精确定位到 源文件:行号 -> 哪个调用 -> 什么结果 + * + * 用法: + * TEST_START("测试名"); + * CHECK(call == expected, "描述"); + * CHECK_ERR(call, EBADF, "描述"); + * TEST_DONE(); + */ + #include #include #include @@ -12,17 +26,21 @@ static int __pass = 0; static int __fail = 0; +/* ---- 核心: 带文件名+行号的检查宏 ---- */ + +/* 检查条件为真 */ #define CHECK(cond, msg) do { \ if (cond) { \ - printf(" PASS | %s:%d | %s\n", __FILE__, __LINE__, msg); \ + printf(" PASS | %s:%d | %s\n", __FILE__, __LINE__, msg); \ __pass++; \ } else { \ printf(" FAIL | %s:%d | %s | errno=%d (%s)\n", \ __FILE__, __LINE__, msg, errno, strerror(errno)); \ __fail++; \ } \ -} while(0) +} while (0) +/* 检查 syscall 返回特定值 */ #define CHECK_RET(call, expected, msg) do { \ errno = 0; \ long _r = (long)(call); \ @@ -33,11 +51,12 @@ static int __fail = 0; __pass++; \ } else { \ printf(" FAIL | %s:%d | %s | expected=%ld got=%ld | errno=%d (%s)\n", \ - __FILE__, __LINE__, msg, _e, _r, errno, strerror(errno));\ + __FILE__, __LINE__, msg, _e, _r, errno, strerror(errno)); \ __fail++; \ } \ -} while(0) +} while (0) +/* 检查 syscall 失败且 errno 符合预期 */ #define CHECK_ERR(call, exp_errno, msg) do { \ errno = 0; \ long _r = (long)(call); \ @@ -47,19 +66,21 @@ static int __fail = 0; __pass++; \ } else { \ printf(" FAIL | %s:%d | %s | expected errno=%d got ret=%ld errno=%d (%s)\n", \ - __FILE__, __LINE__, msg, (int)(exp_errno), _r, errno, strerror(errno));\ + __FILE__, __LINE__, msg, (int)(exp_errno), _r, errno, \ + strerror(errno)); \ __fail++; \ } \ -} while(0) +} while (0) +/* ---- 测试边界 ---- */ #define TEST_START(name) \ - printf("================================================\n"); \ + printf("================================================\n"); \ printf(" TEST: %s\n", name); \ printf(" FILE: %s\n", __FILE__); \ printf("================================================\n") #define TEST_DONE() \ - printf("------------------------------------------------\n"); \ + printf("------------------------------------------------\n"); \ printf(" DONE: %d pass, %d fail\n", __pass, __fail); \ - printf("================================================\n\n"); \ + printf("================================================\n\n"); \ return __fail > 0 ? 1 : 0 diff --git a/test-suit/starryos/qemu-smp1/system/syscall-test-truncate/src/test_framework.h b/test-suit/starryos/qemu-smp1/system/syscall-test-truncate/src/test_framework.h index 1bafd3e70e..2f47878f32 100644 --- a/test-suit/starryos/qemu-smp1/system/syscall-test-truncate/src/test_framework.h +++ b/test-suit/starryos/qemu-smp1/system/syscall-test-truncate/src/test_framework.h @@ -1,57 +1,78 @@ #pragma once +/* 必须在最前面定义,确保 pipe2/gettid 等可用 */ #ifndef _GNU_SOURCE #define _GNU_SOURCE #endif +/* + * StarryOS Syscall Test Framework + * + * 极简独立测试框架:每个文件测一个 syscall,独立编译运行。 + * 目标:出错时精确定位到 源文件:行号 -> 哪个调用 -> 什么结果 + * + * 用法: + * TEST_START("测试名"); + * CHECK(call == expected, "描述"); + * CHECK_ERR(call, EBADF, "描述"); + * TEST_DONE(); + */ + +#include #include #include #include -#include static int __pass = 0; static int __fail = 0; +/* ---- 核心: 带文件名+行号的检查宏 ---- */ + +/* 检查条件为真 */ #define CHECK(cond, msg) do { \ if (cond) { \ printf(" PASS | %s:%d | %s\n", __FILE__, __LINE__, msg); \ __pass++; \ } else { \ - printf(" FAIL | %s:%d | %s | errno=%d (%s)\n", \ + printf(" FAIL | %s:%d | %s | errno=%d (%s)\n", \ __FILE__, __LINE__, msg, errno, strerror(errno)); \ __fail++; \ } \ -} while(0) +} while (0) +/* 检查 syscall 返回特定值 */ #define CHECK_RET(call, expected, msg) do { \ errno = 0; \ long _r = (long)(call); \ long _e = (long)(expected); \ if (_r == _e) { \ - printf(" PASS | %s:%d | %s (ret=%ld)\n", \ + printf(" PASS | %s:%d | %s (ret=%ld)\n", \ __FILE__, __LINE__, msg, _r); \ __pass++; \ } else { \ printf(" FAIL | %s:%d | %s | expected=%ld got=%ld | errno=%d (%s)\n", \ - __FILE__, __LINE__, msg, _e, _r, errno, strerror(errno));\ + __FILE__, __LINE__, msg, _e, _r, errno, strerror(errno)); \ __fail++; \ } \ -} while(0) +} while (0) +/* 检查 syscall 失败且 errno 符合预期 */ #define CHECK_ERR(call, exp_errno, msg) do { \ errno = 0; \ long _r = (long)(call); \ if (_r == -1 && errno == (exp_errno)) { \ - printf(" PASS | %s:%d | %s (errno=%d as expected)\n", \ + printf(" PASS | %s:%d | %s (errno=%d as expected)\n", \ __FILE__, __LINE__, msg, errno); \ __pass++; \ } else { \ printf(" FAIL | %s:%d | %s | expected errno=%d got ret=%ld errno=%d (%s)\n", \ - __FILE__, __LINE__, msg, (int)(exp_errno), _r, errno, strerror(errno));\ + __FILE__, __LINE__, msg, (int)(exp_errno), _r, errno, \ + strerror(errno)); \ __fail++; \ } \ -} while(0) +} while (0) +/* ---- 测试边界 ---- */ #define TEST_START(name) \ printf("================================================\n"); \ printf(" TEST: %s\n", name); \ @@ -60,6 +81,6 @@ static int __fail = 0; #define TEST_DONE() \ printf("------------------------------------------------\n"); \ - printf(" DONE: %d pass, %d fail\n", __pass, __fail); \ + printf(" DONE: %d pass, %d fail\n", __pass, __fail); \ printf("================================================\n\n"); \ return __fail > 0 ? 1 : 0 diff --git a/test-suit/starryos/qemu-smp1/system/test-ext4-inode-unique/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-ext4-inode-unique/CMakeLists.txt new file mode 100644 index 0000000000..73caf9d76d --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-ext4-inode-unique/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.20) +project(test-ext4-inode-unique C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-ext4-inode-unique src/main.c) +target_compile_options(test-ext4-inode-unique PRIVATE -Wall -Wextra -Werror) +target_link_options(test-ext4-inode-unique PRIVATE -static) +install(TARGETS test-ext4-inode-unique RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-ext4-inode-unique/src/main.c b/test-suit/starryos/qemu-smp1/system/test-ext4-inode-unique/src/main.c new file mode 100644 index 0000000000..15c40b1678 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-ext4-inode-unique/src/main.c @@ -0,0 +1,132 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include + +#define FILE_COUNT 2048 +#define PATH_LEN 128 + +struct file_identity { + char path[PATH_LEN]; + int fd; + struct stat st_path; + struct stat st_fd; +}; + +static int fail_errno(const char *what) +{ + printf("EXT4_INODE_UNIQUE_FAILED: %s: errno=%d (%s)\n", what, errno, strerror(errno)); + return 1; +} + +static int same_identity(const struct stat *a, const struct stat *b) +{ + return a->st_dev == b->st_dev && a->st_ino == b->st_ino; +} + +static void print_identity(const char *label, const struct stat *st) +{ + printf("EXT4_INODE_UNIQUE_DIAG: %s dev=%llu ino=%llu mode=%o nlink=%lu\n", + label, + (unsigned long long)st->st_dev, + (unsigned long long)st->st_ino, + (unsigned int)st->st_mode, + (unsigned long)st->st_nlink); +} + +static int create_and_stat(struct file_identity *id) +{ + id->fd = open(id->path, O_CREAT | O_EXCL | O_RDWR, 0600); + if (id->fd < 0) { + return fail_errno("open O_CREAT|O_EXCL"); + } + + if (write(id->fd, id->path, strlen(id->path)) < 0) { + return fail_errno("write marker"); + } + + if (fsync(id->fd) != 0) { + return fail_errno("fsync marker"); + } + + if (stat(id->path, &id->st_path) != 0) { + return fail_errno("stat path"); + } + + if (fstat(id->fd, &id->st_fd) != 0) { + return fail_errno("fstat fd"); + } + + if (!same_identity(&id->st_path, &id->st_fd)) { + printf("EXT4_INODE_UNIQUE_FAILED: stat/fstat identity mismatch for %s\n", id->path); + print_identity("stat", &id->st_path); + print_identity("fstat", &id->st_fd); + return 1; + } + + close(id->fd); + id->fd = -1; + + if (id->st_path.st_ino < 16 || id->st_path.st_ino % 64 == 0) { + print_identity(id->path, &id->st_path); + } + return 0; +} + +int main(void) +{ + const char *store_dir = "/nix/store"; + const char *dir = "/nix/store/ext4-inode-unique"; + struct file_identity *files = calloc(FILE_COUNT, sizeof(*files)); + + if (files == NULL) { + return fail_errno("calloc file identities"); + } + + if (mkdir("/nix", 0755) != 0 && errno != EEXIST) { + return fail_errno("mkdir /nix"); + } + if (mkdir(store_dir, 0755) != 0 && errno != EEXIST) { + return fail_errno("mkdir /nix/store"); + } + + for (size_t i = 0; i < FILE_COUNT; i++) { + snprintf(files[i].path, sizeof(files[i].path), "%s/file-%04zu.lock", dir, i); + files[i].fd = -1; + unlink(files[i].path); + } + rmdir(dir); + + if (mkdir(dir, 0700) != 0) { + return fail_errno("mkdir test dir"); + } + + for (size_t i = 0; i < FILE_COUNT; i++) { + if (create_and_stat(&files[i]) != 0) { + return 1; + } + } + + for (size_t i = 0; i < FILE_COUNT; i++) { + for (size_t j = i + 1; j < FILE_COUNT; j++) { + if (same_identity(&files[i].st_path, &files[j].st_path)) { + printf("EXT4_INODE_UNIQUE_FAILED: duplicate identity for %s and %s\n", + files[i].path, + files[j].path); + print_identity(files[i].path, &files[i].st_path); + print_identity(files[j].path, &files[j].st_path); + return 1; + } + } + } + + free(files); + + printf("EXT4_INODE_UNIQUE_ALL_PASSED\n"); + return 0; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-ext4-unlink-pagecache/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-ext4-unlink-pagecache/CMakeLists.txt new file mode 100644 index 0000000000..4d2e8d1037 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-ext4-unlink-pagecache/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.20) +project(test-ext4-unlink-pagecache C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-ext4-unlink-pagecache src/main.c) +target_compile_options(test-ext4-unlink-pagecache PRIVATE -Wall -Wextra -Werror) +install(TARGETS test-ext4-unlink-pagecache RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-ext4-unlink-pagecache/src/main.c b/test-suit/starryos/qemu-smp1/system/test-ext4-unlink-pagecache/src/main.c new file mode 100644 index 0000000000..cdd35325a9 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-ext4-unlink-pagecache/src/main.c @@ -0,0 +1,284 @@ +#include +#include +#include +#include +#include +#include + +#define REUSE_MAX_TRIES 1024 +#define PREALLOC_COUNT 512 + +static int fails; + +static void pass(const char *msg) +{ + printf(" PASS: %s\n", msg); +} + +static void fail(const char *msg) +{ + printf(" FAIL: %s (errno=%d: %s)\n", msg, errno, strerror(errno)); + fails++; +} + +/* + * Regression: verify that when an inode is freed after unlink+close, + * its page-cache key is cleaned up so a new file reusing the same + * inode number does not see stale cached data. + * + * Strategy: + * 1. pre-allocate a batch of files in /tmp to consume free inodes + * in the same block group, making our target inode more likely + * to be at the front of the free list. + * 2. create target file, write old payload, stat to get st_ino + * 3. open a second fd (keep inode alive after unlink) + * 4. unlink + * 5. close the second fd (inode freed, page-cache must be evicted) + * 6. loop-create temp files until same st_ino is reused (max 1024 tries) + * 7. write a DIFFERENT payload to the reused-inode file + * 8. read back — must match the new payload, NOT the old one + * + * If inode reuse is not triggered after max tries, the test FAILS + * with a diagnostic message — it does NOT silently pass. + */ +int main(void) +{ + const char *path = "/tmp/ext4-unlink-pagecache.tmp"; + const char *old_payload = "OLD STALE DATA FROM UNLINKED INODE"; + const char *new_payload = "FRESH DATA — page-cache must be clean"; + char buf[256] = {0}; + int fd = -1; + ino_t old_ino = 0; + + printf("=== ext4 unlink page-cache regression ===\n"); + unlink(path); + + /* + * Phase 0: pre-allocate files to consume free inodes in the same + * block group. This pushes the inode allocator to recycle recently + * freed inodes more aggressively in the reuse loop below. + */ + { + int prealloc_fds[PREALLOC_COUNT]; + int i, kept = 0; + printf(" INFO: pre-allocating %d files to warm inode allocator\n", + PREALLOC_COUNT); + for (i = 0; i < PREALLOC_COUNT; i++) { + char tmp_path[64]; + snprintf(tmp_path, sizeof(tmp_path), + "/tmp/ext4-pre-%d.tmp", i); + int pf = open(tmp_path, O_RDWR | O_CREAT | O_TRUNC, 0644); + if (pf < 0) { + printf(" INFO: pre-alloc stopped at %d (errno=%d)\n", i, errno); + break; + } + prealloc_fds[kept++] = pf; + } + printf(" INFO: pre-allocated %d files\n", kept); + pass("pre-allocate inode pool"); + + /* Now create the target file while pre-alloc files still hold inodes */ + fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { + for (i = 0; i < kept; i++) { + char tmp_path[64]; + snprintf(tmp_path, sizeof(tmp_path), + "/tmp/ext4-pre-%d.tmp", i); + close(prealloc_fds[i]); + unlink(tmp_path); + } + fail("open temp file for write"); + goto out; + } + pass("open temp file for write"); + + ssize_t n = write(fd, old_payload, strlen(old_payload)); + if (n != (ssize_t)strlen(old_payload)) { + for (i = 0; i < kept; i++) { + char tmp_path[64]; + snprintf(tmp_path, sizeof(tmp_path), + "/tmp/ext4-pre-%d.tmp", i); + close(prealloc_fds[i]); + unlink(tmp_path); + } + fail("write old payload"); + goto out_close_fd; + } + pass("write old payload"); + + { + struct stat st; + if (fstat(fd, &st) != 0) { + for (i = 0; i < kept; i++) { + char tmp_path[64]; + snprintf(tmp_path, sizeof(tmp_path), + "/tmp/ext4-pre-%d.tmp", i); + close(prealloc_fds[i]); + unlink(tmp_path); + } + fail("fstat to get inode"); + goto out_close_fd; + } + old_ino = st.st_ino; + } + printf(" INFO: old file inode=%lu\n", (unsigned long)old_ino); + close(fd); + + /* Release pre-alloc files — their inodes go back to the free list */ + for (i = 0; i < kept; i++) { + close(prealloc_fds[i]); + } + for (i = 0; i < PREALLOC_COUNT; i++) { + char tmp_path[64]; + snprintf(tmp_path, sizeof(tmp_path), + "/tmp/ext4-pre-%d.tmp", i); + unlink(tmp_path); + } + pass("release pre-alloc files (inodes back to free list)"); + } + + /* Step 2: open a second fd to keep the inode alive */ + fd = open(path, O_RDONLY); + if (fd < 0) { + fail("open file for read (keep inode alive)"); + goto out; + } + pass("open file for read (keep inode alive)"); + + /* Step 3: unlink while fd is still open */ + if (unlink(path) != 0) { + fail("unlink while fd open"); + goto out_close_fd; + } + pass("unlink while fd open"); + + /* Step 4: close the fd — inode freed, page-cache key evicted */ + close(fd); + fd = -1; + pass("close fd (inode freed, page-cache must be evicted)"); + + /* + * Step 5: loop-create temp files until the same inode number is reused. + * + * With the pre-allocated inode pool released above, the ext4 inode + * allocator has many recently freed inodes in the same block group. + * It should recycle our target inode within a few allocations. + */ + { + int reused = 0; + int tries; + for (tries = 0; tries < REUSE_MAX_TRIES; tries++) { + char tmp_path[64]; + snprintf(tmp_path, sizeof(tmp_path), + "/tmp/ext4-reuse-%d.tmp", tries); + + int tmp_fd = open(tmp_path, O_RDWR | O_CREAT | O_TRUNC, 0644); + if (tmp_fd < 0) { + printf(" INFO: cannot create temp file %d (errno=%d), stop loop\n", + tries, errno); + break; + } + + struct stat tmp_st; + if (fstat(tmp_fd, &tmp_st) != 0) { + close(tmp_fd); + unlink(tmp_path); + continue; + } + + if (tmp_st.st_ino == old_ino) { + reused = 1; + printf(" INFO: inode %lu reused at attempt %d\n", + (unsigned long)old_ino, tries + 1); + fd = tmp_fd; + break; + } + + close(tmp_fd); + unlink(tmp_path); + } + + if (!reused) { + printf(" FAIL: inode %lu not reused after %d tries\n", + (unsigned long)old_ino, tries); + printf(" The page-cache-key cleanup cannot be verified\n"); + printf(" without inode reuse. This may indicate an inode\n"); + printf(" allocator change or an unusually sparse filesystem.\n"); + fails++; + goto out; + } + } + + /* Step 6: write a DIFFERENT payload to the reused-inode file */ + { + ssize_t n = write(fd, new_payload, strlen(new_payload)); + if (n != (ssize_t)strlen(new_payload)) { + fail("write new payload to reused-inode file"); + goto out_close_fd; + } + } + pass("write new payload to reused-inode file"); + + /* Step 7: seek to beginning and read back */ + if (lseek(fd, 0, SEEK_SET) != 0) { + fail("seek reused-inode file"); + goto out_close_fd; + } + pass("seek reused-inode file"); + + memset(buf, 0, sizeof(buf)); + { + ssize_t n = read(fd, buf, sizeof(buf) - 1); + if (n < 0) { + fail("read reused-inode file"); + goto out_close_fd; + } + buf[n] = '\0'; + + if (n != (ssize_t)strlen(new_payload)) { + printf(" FAIL: expected %zu bytes, got %zd\n", + strlen(new_payload), n); + fails++; + goto out_close_fd; + } + } + pass("read reused-inode file"); + + /* Step 8: verify new payload, not old */ + if (strcmp(buf, new_payload) != 0) { + printf(" FAIL: payload mismatch\n"); + printf(" expected: %s\n", new_payload); + printf(" got: %s\n", buf); + if (strstr(buf, old_payload) != NULL) { + fail("reused-inode file contains STALE DATA — page-cache leak"); + } else { + fail("reused-inode file has unexpected content"); + } + goto out_close_fd; + } + pass("reused-inode file has correct new payload (no stale data)"); + +out_close_fd: + if (fd >= 0) + close(fd); +out: + /* Cleanup loop temp files */ + { + int tries; + for (tries = 0; tries < REUSE_MAX_TRIES; tries++) { + char tmp_path[64]; + snprintf(tmp_path, sizeof(tmp_path), + "/tmp/ext4-reuse-%d.tmp", tries); + unlink(tmp_path); + } + } + unlink(path); + + printf("\n=== Results: %s ===\n", fails == 0 ? "pass" : "fail"); + if (fails == 0) { + printf("TEST PASSED\n"); + return 0; + } + printf("TEST FAILED\n"); + return 1; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-fcntl-lock-lifecycle/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-fcntl-lock-lifecycle/CMakeLists.txt new file mode 100644 index 0000000000..452843bd6d --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-fcntl-lock-lifecycle/CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.20) +project(test-fcntl-lock-lifecycle C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-fcntl-lock-lifecycle src/main.c) +target_include_directories(test-fcntl-lock-lifecycle PRIVATE src ${CMAKE_CURRENT_SOURCE_DIR}/../common) +target_compile_options(test-fcntl-lock-lifecycle PRIVATE -Wall -Wextra -Werror) +target_link_options(test-fcntl-lock-lifecycle PRIVATE -static) +install(TARGETS test-fcntl-lock-lifecycle RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-fcntl-lock-lifecycle/src/main.c b/test-suit/starryos/qemu-smp1/system/test-fcntl-lock-lifecycle/src/main.c new file mode 100644 index 0000000000..9294bc7553 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-fcntl-lock-lifecycle/src/main.c @@ -0,0 +1,203 @@ +/* + * Focused StarryOS regression test for advisory file lock lifecycle. + * + * Covers four scenarios from plan.md L3: + * 1. child F_SETLK + close fd → parent F_SETLKW wakes + * 2. child F_SETLK + exit → parent F_SETLKW wakes + * 3. child FD_CLOEXEC + exec → parent F_SETLKW wakes + * 4. OFD F_OFD_SETLKW last-close → waiter wakes + * + * Each scenario prints a unique pass marker. + * Final marker: FCNTL_LOCK_LIFECYCLE_ALL_PASSED + */ +#define _GNU_SOURCE +#include "../common/test_framework.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOCK_FILE "/tmp/fcntl-lock-test" + +static int create_lock_file(void) +{ + int fd = open(LOCK_FILE, O_CREAT | O_RDWR | O_TRUNC, 0644); + CHECK(fd >= 0, "create lock test file"); + if (fd >= 0) { + (void)write(fd, "locktest\n", 9); + } + return fd; +} + +static void set_write_lock(int fd, int cmd, int *out_rc) +{ + struct flock fl; + memset(&fl, 0, sizeof(fl)); + fl.l_type = F_WRLCK; + fl.l_whence = SEEK_SET; + fl.l_start = 0; + fl.l_len = 0; + *out_rc = fcntl(fd, cmd, &fl); +} + +/* ── Scenario 1: child F_SETLK + close fd → parent F_SETLKW wakes ── */ +static void test_child_lock_close_parent_wakes(void) +{ + TEST_START("child F_SETLK + close fd wakes parent F_SETLKW"); + + int fd = create_lock_file(); + if (fd < 0) return; + + pid_t child = fork(); + CHECK(child >= 0, "fork for lock/close scenario"); + if (child == 0) { + int rc; + set_write_lock(fd, F_SETLK, &rc); + CHECK(rc == 0, "child acquires lock"); + close(fd); + _exit(0); + } + + /* parent: wait for child to acquire lock, then try F_SETLKW */ + usleep(100000); /* let child lock */ + + int rc; + set_write_lock(fd, F_SETLKW, &rc); + CHECK(rc == 0, "parent acquires lock after child close"); + + /* reap child */ + int status; + waitpid(child, &status, 0); + + close(fd); + unlink(LOCK_FILE); + printf("FCNTL_LOCK_CLOSE_WAKE_PASSED\n"); +} + +/* ── Scenario 2: child F_SETLK + exit → parent F_SETLKW wakes ── */ +static void test_child_lock_exit_parent_wakes(void) +{ + TEST_START("child F_SETLK + exit wakes parent F_SETLKW"); + + int fd = create_lock_file(); + if (fd < 0) return; + + pid_t child = fork(); + CHECK(child >= 0, "fork for lock/exit scenario"); + if (child == 0) { + int rc; + set_write_lock(fd, F_SETLK, &rc); + CHECK(rc == 0, "child acquires lock before exit"); + /* fd is NOT closed — exit should release the lock */ + _exit(0); + } + + usleep(100000); + + int rc; + set_write_lock(fd, F_SETLKW, &rc); + CHECK(rc == 0, "parent acquires lock after child exit"); + + int status; + waitpid(child, &status, 0); + + close(fd); + unlink(LOCK_FILE); + printf("FCNTL_LOCK_EXIT_WAKE_PASSED\n"); +} + +/* ── Scenario 3: child FD_CLOEXEC + exec → parent F_SETLKW wakes ── */ +static void test_child_cloexec_exec_parent_wakes(void) +{ + TEST_START("child FD_CLOEXEC + exec wakes parent F_SETLKW"); + + int fd = create_lock_file(); + if (fd < 0) return; + + /* set CLOEXEC on fd */ + int flags = fcntl(fd, F_GETFD); + fcntl(fd, F_SETFD, flags | FD_CLOEXEC); + + pid_t child = fork(); + CHECK(child >= 0, "fork for cloexec/exec scenario"); + if (child == 0) { + int rc; + set_write_lock(fd, F_SETLK, &rc); + CHECK(rc == 0, "child acquires lock before exec"); + + /* exec /bin/true — fd with CLOEXEC should be closed */ + execl("/bin/true", "true", NULL); + _exit(127); + } + + usleep(100000); + + int rc; + set_write_lock(fd, F_SETLKW, &rc); + CHECK(rc == 0, "parent acquires lock after child exec+cloexec close"); + + int status; + waitpid(child, &status, 0); + + close(fd); + unlink(LOCK_FILE); + printf("FCNTL_LOCK_CLOEXEC_EXEC_WAKE_PASSED\n"); +} + +/* ── Scenario 4: OFD lock last-close → waiter wakes ── */ +static void test_ofd_lock_last_close_wakes(void) +{ + TEST_START("OFD lock last-close wakes waiter"); + + int fd = create_lock_file(); + if (fd < 0) return; + + pid_t child = fork(); + CHECK(child >= 0, "fork for OFD lock scenario"); + if (child == 0) { + struct flock fl; + memset(&fl, 0, sizeof(fl)); + fl.l_type = F_WRLCK; + fl.l_whence = SEEK_SET; + fl.l_start = 0; + fl.l_len = 0; + int rc = fcntl(fd, F_OFD_SETLK, &fl); + CHECK(rc == 0, "child acquires OFD lock"); + close(fd); + _exit(0); + } + + usleep(100000); + + struct flock fl; + memset(&fl, 0, sizeof(fl)); + fl.l_type = F_WRLCK; + fl.l_whence = SEEK_SET; + fl.l_start = 0; + fl.l_len = 0; + int rc = fcntl(fd, F_OFD_SETLKW, &fl); + CHECK(rc == 0, "parent acquires OFD lock after child close"); + + int status; + waitpid(child, &status, 0); + + close(fd); + unlink(LOCK_FILE); + printf("FCNTL_OFD_LOCK_LAST_CLOSE_WAKE_PASSED\n"); +} + +int main(void) +{ + test_child_lock_close_parent_wakes(); + test_child_lock_exit_parent_wakes(); + test_child_cloexec_exec_parent_wakes(); + test_ofd_lock_last_close_wakes(); + + printf("FCNTL_LOCK_LIFECYCLE_ALL_PASSED\n"); + return 0; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-flock-cloexec/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-flock-cloexec/CMakeLists.txt new file mode 100644 index 0000000000..4eb61f7180 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-flock-cloexec/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.20) +project(test-flock-cloexec C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-flock-cloexec src/main.c) +target_compile_options(test-flock-cloexec PRIVATE -Wall -Wextra -Werror) +install(TARGETS test-flock-cloexec RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-flock-cloexec/src/main.c b/test-suit/starryos/qemu-smp1/system/test-flock-cloexec/src/main.c new file mode 100644 index 0000000000..0572f15573 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-flock-cloexec/src/main.c @@ -0,0 +1,104 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOCK_PATH "/tmp/flock-cloexec.lock" + +static void fail(const char *msg) +{ + printf("FLOCK_CLOEXEC_FAILED: %s errno=%d (%s)\n", msg, errno, strerror(errno)); + exit(1); +} + +static void checked_write(int fd, const char *msg) +{ + if (write(fd, msg, 1) != 1) { + fail("pipe write"); + } +} + +static void checked_read(int fd) +{ + char ch; + if (read(fd, &ch, 1) != 1) { + fail("pipe read"); + } +} + +static void child_lock_cloexec_exec(int pipe_write) +{ + int fd = open(LOCK_PATH, O_CREAT | O_RDWR | O_TRUNC, 0600); + if (fd < 0) { + fail("child open lock file"); + } + if (flock(fd, LOCK_EX) != 0) { + fail("child flock lock"); + } + int flags = fcntl(fd, F_GETFD); + if (flags < 0) { + fail("child F_GETFD"); + } + if (fcntl(fd, F_SETFD, flags | FD_CLOEXEC) != 0) { + fail("child F_SETFD FD_CLOEXEC"); + } + + checked_write(pipe_write, "R"); + close(pipe_write); + execl("/bin/sleep", "sleep", "1", NULL); + fail("exec /bin/sleep"); +} + +int main(void) +{ + unlink(LOCK_PATH); + + int pipefd[2]; + if (pipe(pipefd) != 0) { + fail("pipe"); + } + + pid_t child = fork(); + if (child < 0) { + fail("fork"); + } + if (child == 0) { + close(pipefd[0]); + child_lock_cloexec_exec(pipefd[1]); + } + + close(pipefd[1]); + checked_read(pipefd[0]); + close(pipefd[0]); + + usleep(200000); + + int fd = open(LOCK_PATH, O_RDWR); + if (fd < 0) { + fail("parent open lock file"); + } + if (flock(fd, LOCK_EX | LOCK_NB) != 0) { + fail("parent flock after child exec CLOEXEC"); + } + printf("FLOCK_CLOEXEC_PARENT_LOCK_PASSED\n"); + + int status = 0; + if (waitpid(child, &status, 0) < 0) { + fail("waitpid"); + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + printf("FLOCK_CLOEXEC_FAILED: child status=%d\n", status); + return 1; + } + + close(fd); + unlink(LOCK_PATH); + printf("FLOCK_CLOEXEC_ALL_PASSED\n"); + return 0; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-lock-close-range-cloexec/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-lock-close-range-cloexec/CMakeLists.txt new file mode 100644 index 0000000000..8995d2f415 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-lock-close-range-cloexec/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.20) +project(test-lock-close-range-cloexec C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-lock-close-range-cloexec src/main.c) +target_compile_options(test-lock-close-range-cloexec PRIVATE -Wall -Wextra -Werror) +install(TARGETS test-lock-close-range-cloexec RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-lock-close-range-cloexec/src/main.c b/test-suit/starryos/qemu-smp1/system/test-lock-close-range-cloexec/src/main.c new file mode 100644 index 0000000000..6910126861 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-lock-close-range-cloexec/src/main.c @@ -0,0 +1,151 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef CLOSE_RANGE_CLOEXEC +#define CLOSE_RANGE_CLOEXEC (1U << 2) +#endif + +#define FCNTL_LOCK_PATH "/tmp/lock-close-range-fcntl.lock" +#define FLOCK_LOCK_PATH "/tmp/lock-close-range-flock.lock" + +static void fail(const char *msg) +{ + printf("LOCK_CLOSE_RANGE_CLOEXEC_FAILED: %s errno=%d (%s)\n", msg, errno, strerror(errno)); + exit(1); +} + +static void checked_write(int fd) +{ + char ch = 'R'; + if (write(fd, &ch, 1) != 1) { + fail("pipe write"); + } +} + +static void checked_read(int fd) +{ + char ch; + if (read(fd, &ch, 1) != 1) { + fail("pipe read"); + } +} + +static void set_fcntl_lock(int fd, int cmd) +{ + struct flock fl = { + .l_type = F_WRLCK, + .l_whence = SEEK_SET, + .l_start = 0, + .l_len = 0, + }; + if (fcntl(fd, cmd, &fl) != 0) { + fail("fcntl lock"); + } +} + +static int try_fcntl_lock(int fd) +{ + struct flock fl = { + .l_type = F_WRLCK, + .l_whence = SEEK_SET, + .l_start = 0, + .l_len = 0, + }; + return fcntl(fd, F_SETLK, &fl); +} + +static void child_exec_after_close_range(const char *path, int pipe_write, int use_flock) +{ + int fd = open(path, O_CREAT | O_RDWR | O_TRUNC, 0600); + if (fd < 0) { + fail("child open lock file"); + } + + if (use_flock) { + if (flock(fd, LOCK_EX) != 0) { + fail("child flock lock"); + } + } else { + set_fcntl_lock(fd, F_SETLK); + } + + if (syscall(SYS_close_range, (unsigned int)fd, (unsigned int)fd, CLOSE_RANGE_CLOEXEC) != 0) { + fail("child close_range CLOEXEC"); + } + + checked_write(pipe_write); + close(pipe_write); + execl("/bin/sleep", "sleep", "1", NULL); + fail("exec /bin/sleep"); +} + +static void run_case(const char *path, int use_flock) +{ + unlink(path); + + int pipefd[2]; + if (pipe(pipefd) != 0) { + fail("pipe"); + } + + pid_t child = fork(); + if (child < 0) { + fail("fork"); + } + if (child == 0) { + close(pipefd[0]); + child_exec_after_close_range(path, pipefd[1], use_flock); + } + + close(pipefd[1]); + checked_read(pipefd[0]); + close(pipefd[0]); + + usleep(200000); + + int fd = open(path, O_RDWR); + if (fd < 0) { + fail("parent open lock file"); + } + if (use_flock) { + if (flock(fd, LOCK_EX | LOCK_NB) != 0) { + fail("parent flock after close_range CLOEXEC exec"); + } + printf("LOCK_CLOSE_RANGE_FLOCK_PASSED\n"); + } else { + if (try_fcntl_lock(fd) != 0) { + fail("parent fcntl after close_range CLOEXEC exec"); + } + printf("LOCK_CLOSE_RANGE_FCNTL_PASSED\n"); + } + + int status = 0; + if (waitpid(child, &status, 0) < 0) { + fail("waitpid"); + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + printf("LOCK_CLOSE_RANGE_CLOEXEC_FAILED: child status=%d\n", status); + exit(1); + } + + close(fd); + unlink(path); +} + +int main(void) +{ + run_case(FCNTL_LOCK_PATH, 0); + run_case(FLOCK_LOCK_PATH, 1); + printf("LOCK_CLOSE_RANGE_CLOEXEC_ALL_PASSED\n"); + return 0; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-nix-builder-lifecycle/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-nix-builder-lifecycle/CMakeLists.txt new file mode 100644 index 0000000000..950a019511 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-nix-builder-lifecycle/CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.20) +project(test-nix-builder-lifecycle C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-nix-builder-lifecycle src/main.c) +target_include_directories(test-nix-builder-lifecycle PRIVATE src ${CMAKE_CURRENT_SOURCE_DIR}/../common) +target_compile_options(test-nix-builder-lifecycle PRIVATE -Wall -Wextra -Werror) +target_link_options(test-nix-builder-lifecycle PRIVATE -static) +install(TARGETS test-nix-builder-lifecycle RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-nix-builder-lifecycle/src/main.c b/test-suit/starryos/qemu-smp1/system/test-nix-builder-lifecycle/src/main.c new file mode 100644 index 0000000000..21084eb176 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-nix-builder-lifecycle/src/main.c @@ -0,0 +1,229 @@ +/* + * Focused StarryOS regression test for Nix builder process lifecycle. + * + * Replicates the fork/exec/wait topology that Nix uses to launch a builder + * process, including multi-threaded scenarios matching Nix's thread pool. + * + * Scenarios: + * 1. fork + exec /bin/sh → wait4 collects exit (single-thread) + * 2. fork + exec with env → waitpid collects exit + * 3. double fork (worker→builder) nested process topology + * 4. fork + exec + waitpid from a secondary thread (Nix thread pool pattern) + * + * Final marker: NIX_BUILDER_LIFECYCLE_ALL_PASSED + */ +#define _GNU_SOURCE +#include "../common/test_framework.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +#define MARKER_DIR "/tmp" +#define MARKER_FILE MARKER_DIR "/builder-lifecycle-marker" +#define MARKER_FILE2 MARKER_DIR "/builder-lifecycle-marker2" + +/* ── Scenario 1: fork + exec /bin/sh → wait4 ── */ +static void test_fork_exec_shell_wait4(void) +{ + TEST_START("fork + exec /bin/sh -c echo OK → wait4 collects exit"); + + unlink(MARKER_FILE); + + pid_t pid = fork(); + CHECK(pid >= 0, "fork for builder scenario"); + if (pid < 0) return; + + if (pid == 0) { + char *const argv[] = { "/bin/sh", "-c", + "echo BUILDER_OK > " MARKER_FILE, NULL }; + execve("/bin/sh", argv, environ); + _exit(126); + } + + int status = 0; + struct rusage usage; + errno = 0; + pid_t waited = wait4(pid, &status, 0, &usage); + CHECK(waited == pid, "wait4 returns builder child pid"); + if (waited == pid) { + CHECK(WIFEXITED(status), "builder child exits normally"); + if (WIFEXITED(status)) { + CHECK(WEXITSTATUS(status) == 0, "builder child exit status 0"); + } + } + + int fd = open(MARKER_FILE, O_RDONLY); + CHECK(fd >= 0, "builder marker file exists"); + if (fd >= 0) { + char buf[64] = {0}; + ssize_t n = read(fd, buf, sizeof(buf) - 1); + CHECK(n > 0, "builder marker file non-empty"); + close(fd); + } + unlink(MARKER_FILE); + + printf("NIX_BUILDER_FORK_EXEC_WAIT4_PASSED\n"); +} + +/* ── Scenario 2: fork + exec with explicit env ── */ +static void test_fork_exec_with_env_wait4(void) +{ + TEST_START("fork + exec /bin/sh with env → wait4"); + + unlink(MARKER_FILE); + + pid_t pid = fork(); + CHECK(pid >= 0, "fork for env scenario"); + if (pid < 0) return; + + if (pid == 0) { + char *const argv[] = { "/bin/sh", "-c", + "echo BUILDER_ENV_OK > " MARKER_FILE, NULL }; + execve("/bin/sh", argv, environ); + _exit(126); + } + + int status = 0; + pid_t waited = waitpid(pid, &status, 0); + CHECK(waited == pid, "waitpid returns env builder pid"); + if (waited == pid) { + CHECK(WIFEXITED(status), "env builder exits normally"); + } + + unlink(MARKER_FILE); + printf("NIX_BUILDER_FORK_EXEC_ENV_PASSED\n"); +} + +/* ── Scenario 3: double fork (worker→builder) nested topology ── */ +static void test_double_fork_builder_topology(void) +{ + TEST_START("double fork worker→builder topology"); + + unlink(MARKER_FILE); + + pid_t worker = fork(); + CHECK(worker >= 0, "fork worker process"); + if (worker < 0) return; + + if (worker == 0) { + pid_t builder = fork(); + CHECK(builder >= 0, "worker forks builder"); + if (builder < 0) _exit(1); + + if (builder == 0) { + char *const argv[] = { "/bin/sh", "-c", + "echo NESTED_BUILDER_OK > " MARKER_FILE, NULL }; + execve("/bin/sh", argv, environ); + _exit(126); + } + + int bstatus = 0; + pid_t bwaited = waitpid(builder, &bstatus, 0); + CHECK(bwaited == builder, "worker waitpid collects builder"); + if (bwaited == builder) { + CHECK(WIFEXITED(bstatus), "builder exits normally in worker"); + } + _exit(WIFEXITED(bstatus) ? WEXITSTATUS(bstatus) : 1); + } + + int wstatus = 0; + pid_t wwaited = waitpid(worker, &wstatus, 0); + CHECK(wwaited == worker, "parent waitpid collects worker"); + if (wwaited == worker) { + CHECK(WIFEXITED(wstatus), "worker exits normally"); + if (WIFEXITED(wstatus)) { + CHECK(WEXITSTATUS(wstatus) == 0, "worker exit status 0"); + } + } + + int fd = open(MARKER_FILE, O_RDONLY); + CHECK(fd >= 0, "nested builder marker exists"); + if (fd >= 0) close(fd); + unlink(MARKER_FILE); + + printf("NIX_BUILDER_DOUBLE_FORK_PASSED\n"); +} + +/* ── Scenario 4: fork+exec+waitpid from a secondary thread ── */ +struct thread_fork_ctx { + int ok; + char marker_path[128]; +}; + +static void *thread_fork_exec_wait(void *arg) +{ + struct thread_fork_ctx *ctx = (struct thread_fork_ctx *)arg; + ctx->ok = 0; + + unlink(ctx->marker_path); + + pid_t pid = fork(); + if (pid < 0) return NULL; + + if (pid == 0) { + char cmd[256]; + snprintf(cmd, sizeof(cmd), "echo THREAD_BUILDER_OK > %s", ctx->marker_path); + char *argv[] = { "/bin/sh", "-c", cmd, NULL }; + execve("/bin/sh", argv, environ); + _exit(126); + } + + int status = 0; + pid_t waited = waitpid(pid, &status, 0); + if (waited != pid) return NULL; + if (!WIFEXITED(status)) return NULL; + if (WEXITSTATUS(status) != 0) return NULL; + + int fd = open(ctx->marker_path, O_RDONLY); + if (fd < 0) return NULL; + char buf[64] = {0}; + ssize_t n = read(fd, buf, sizeof(buf) - 1); + close(fd); + unlink(ctx->marker_path); + if (n <= 0) return NULL; + + ctx->ok = 1; + return NULL; +} + +static void test_thread_fork_exec_wait(void) +{ + TEST_START("secondary thread fork + exec /bin/sh → waitpid"); + + struct thread_fork_ctx ctx; + snprintf(ctx.marker_path, sizeof(ctx.marker_path), "%s", MARKER_FILE2); + + pthread_t thr; + int rc = pthread_create(&thr, NULL, thread_fork_exec_wait, &ctx); + CHECK(rc == 0, "pthread_create for builder thread"); + if (rc != 0) return; + + void *ret = NULL; + rc = pthread_join(thr, &ret); + CHECK(rc == 0, "pthread_join builder thread"); + if (rc == 0) { + CHECK(ctx.ok == 1, "thread fork+exec+waitpid completed successfully"); + } + + printf("NIX_BUILDER_THREAD_FORK_WAIT_PASSED\n"); +} + +int main(void) +{ + test_fork_exec_shell_wait4(); + test_fork_exec_with_env_wait4(); + test_double_fork_builder_topology(); + test_thread_fork_exec_wait(); + + printf("NIX_BUILDER_LIFECYCLE_ALL_PASSED\n"); + return 0; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-open-unlink-write/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-open-unlink-write/CMakeLists.txt new file mode 100644 index 0000000000..fedbf9ae9e --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-open-unlink-write/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.20) +project(test-open-unlink-write C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-open-unlink-write src/main.c) +target_compile_options(test-open-unlink-write PRIVATE -Wall -Wextra -Werror) +install(TARGETS test-open-unlink-write RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-open-unlink-write/src/main.c b/test-suit/starryos/qemu-smp1/system/test-open-unlink-write/src/main.c new file mode 100644 index 0000000000..7dabe68904 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-open-unlink-write/src/main.c @@ -0,0 +1,90 @@ +#include +#include +#include +#include +#include +#include + +static int fails; + +static void pass(const char *msg) +{ + printf(" PASS: %s\n", msg); +} + +static void fail(const char *msg) +{ + printf(" FAIL: %s (errno=%d: %s)\n", msg, errno, strerror(errno)); + fails++; +} + +int main(void) +{ + const char *path = "/tmp/open-unlink-write.tmp"; + const char *payload = "open fd survives unlink\n"; + char buf[64] = {0}; + + printf("=== open-unlink-write regression ===\n"); + unlink(path); + + int fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { + fail("open temp file"); + goto out; + } + pass("open temp file"); + + if (unlink(path) != 0) { + fail("unlink open file"); + goto out_close; + } + pass("unlink open file"); + + int fd2 = open(path, O_RDONLY); + if (fd2 >= 0 || errno != ENOENT) { + if (fd2 >= 0) { + close(fd2); + } + fail("path lookup fails after unlink"); + goto out_close; + } + pass("path lookup fails after unlink"); + + ssize_t n = write(fd, payload, strlen(payload)); + if (n != (ssize_t)strlen(payload)) { + fail("write through unlinked fd"); + goto out_close; + } + pass("write through unlinked fd"); + + if (lseek(fd, 0, SEEK_SET) != 0) { + fail("seek unlinked fd"); + goto out_close; + } + pass("seek unlinked fd"); + + n = read(fd, buf, sizeof(buf) - 1); + if (n != (ssize_t)strlen(payload) || strcmp(buf, payload) != 0) { + fail("read data through unlinked fd"); + goto out_close; + } + pass("read data through unlinked fd"); + + struct stat st; + if (fstat(fd, &st) != 0 || st.st_nlink != 0) { + fail("fstat unlinked fd reports nlink 0"); + goto out_close; + } + pass("fstat unlinked fd reports nlink 0"); + +out_close: + close(fd); +out: + printf("\n=== Results: %s ===\n", fails == 0 ? "pass" : "fail"); + if (fails == 0) { + printf("TEST PASSED\n"); + return 0; + } + printf("TEST FAILED\n"); + return 1; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-pidfd-poll-exit/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-pidfd-poll-exit/CMakeLists.txt new file mode 100644 index 0000000000..ae5bbdd9a4 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-pidfd-poll-exit/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.20) +project(test-pidfd-poll-exit C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-pidfd-poll-exit src/main.c) +target_compile_options(test-pidfd-poll-exit PRIVATE -Wall -Wextra -Werror) +install(TARGETS test-pidfd-poll-exit RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-pidfd-poll-exit/src/main.c b/test-suit/starryos/qemu-smp1/system/test-pidfd-poll-exit/src/main.c new file mode 100644 index 0000000000..b4f04d1b1d --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-pidfd-poll-exit/src/main.c @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +// Focused regression: pidfd poll readiness is reported only after the target +// process exits. This matches Linux pidfd semantics and catches inverted +// readiness that can confuse event loops supervising child builders. + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef __NR_pidfd_open +#error "__NR_pidfd_open required from " +#endif + +static int tests_pass; +static int tests_fail; + +#define TEST(cond, msg) \ + do { \ + if (cond) { \ + tests_pass++; \ + printf(" PASS: %s\n", msg); \ + } else { \ + tests_fail++; \ + printf(" FAIL: %s (%s:%d errno=%d)\n", msg, __FILE__, __LINE__, \ + errno); \ + } \ + } while (0) + +static int x_pidfd_open(pid_t pid, unsigned int flags) { + return (int)syscall(__NR_pidfd_open, pid, flags); +} + +static void test_pidfd_poll_exit_readiness(void) { + printf("Test 1: pidfd poll becomes readable only after child exit\n"); + + int sync_pipe[2]; + TEST(pipe(sync_pipe) == 0, "sync pipe created"); + + pid_t child = fork(); + TEST(child >= 0, "fork succeeded"); + if (child == 0) { + close(sync_pipe[1]); + char c; + (void)!read(sync_pipe[0], &c, 1); + close(sync_pipe[0]); + _exit(42); + } + + close(sync_pipe[0]); + + int pfd = x_pidfd_open(child, 0); + TEST(pfd >= 0, "pidfd_open(child) succeeded before exit"); + + struct pollfd p = {.fd = pfd, .events = POLLIN}; + errno = 0; + int ret = poll(&p, 1, 50); + TEST(ret == 0, "pidfd is not readable while child is alive"); + TEST(p.revents == 0, "pidfd has no revents while child is alive"); + + TEST(write(sync_pipe[1], "x", 1) == 1, "released child"); + close(sync_pipe[1]); + + p.revents = 0; + errno = 0; + ret = poll(&p, 1, 1000); + TEST(ret == 1, "pidfd poll returns after child exits"); + TEST((p.revents & POLLIN) != 0, "pidfd reports POLLIN after child exit"); + fprintf(stderr, " INFO: pidfd revents=0x%x (POLLIN=0x%x)\n", p.revents, + POLLIN); + + int status = 0; + TEST(waitpid(child, &status, 0) == child, "waitpid reaped child"); + TEST(WIFEXITED(status) && WEXITSTATUS(status) == 42, + "child exit status preserved"); + + close(pfd); +} + +int main(void) { + printf("=== pidfd-poll-exit regression ===\n"); + + test_pidfd_poll_exit_readiness(); + + printf("\n=== Results: %d pass, %d fail ===\n", tests_pass, tests_fail); + if (tests_fail == 0) { + printf("TEST PASSED\n"); + } else { + printf("TEST FAILED\n"); + } + return tests_fail > 0 ? 1 : 0; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-pipe-poll-close/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-pipe-poll-close/CMakeLists.txt new file mode 100644 index 0000000000..f0d061cd94 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-pipe-poll-close/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.20) +project(test-pipe-poll-close C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-pipe-poll-close src/main.c) +target_include_directories(test-pipe-poll-close PRIVATE src) +target_compile_options(test-pipe-poll-close PRIVATE -Wall -Wextra -Werror) +install(TARGETS test-pipe-poll-close RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-pipe-poll-close/src/main.c b/test-suit/starryos/qemu-smp1/system/test-pipe-poll-close/src/main.c new file mode 100644 index 0000000000..f6dc1746c8 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-pipe-poll-close/src/main.c @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: Apache-2.0 +// Focused regression: verify that poll/epoll correctly detects a pipe close +// when the write end is dropped (child process exits). +// +// Mimics the Nix build monitoring pattern: Nix creates a pipe, forks a +// builder, and monitors the read end via epoll. When the builder exits, +// epoll must return EPOLLIN|EPOLLHUP so Nix can detect completion. + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int tests_pass; +static int tests_fail; + +#define TEST(cond, msg) \ + do { \ + if (cond) { \ + tests_pass++; \ + printf(" PASS: %s\n", msg); \ + } else { \ + tests_fail++; \ + printf(" FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); \ + } \ + } while (0) + + +// ─── Test 1: poll detects pipe close (HUP) ────────────────────────────── +static void test_poll_pipe_close(void) { + printf("Test 1: poll detects pipe close when write end drops\n"); + int pipefd[2]; + TEST(pipe(pipefd) == 0, "pipe created"); + + pid_t child = fork(); + TEST(child >= 0, "fork succeeded"); + + if (child == 0) { + // Child: close read end, write, then exit + close(pipefd[0]); + const char *msg = "hello"; + (void)!write(pipefd[1], msg, strlen(msg)); + close(pipefd[1]); + _exit(0); + } + + // Parent: close write end, wait for child to finish writing + close(pipefd[1]); + + // Ensure child has written before polling, avoiding arch-dependent + // scheduler race where poll(200ms) fires before the child is scheduled. + waitpid(child, NULL, 0); + + // First read the data the child wrote + char buf[64] = {0}; + struct pollfd pfd = {.fd = pipefd[0], .events = POLLIN}; + int ret = poll(&pfd, 1, 200); + TEST(ret == 1, "poll returned 1 after child wrote data"); + TEST(pfd.revents & POLLIN, "POLLIN set after child writes"); + + ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1); + TEST(n == 5, "read got 5 bytes from pipe"); + TEST(strcmp(buf, "hello") == 0, "read correct data"); + + // Now poll again — pipe should show POLLHUP since write end is closed + // and no data remains + pfd.revents = 0; + ret = poll(&pfd, 1, 200); + TEST(ret == 1, "poll returned 1 after pipe close"); + TEST((pfd.revents & (POLLIN | POLLHUP)) != 0, + "POLLIN or POLLHUP set after pipe close (Linux: both)"); + TEST(pfd.revents & POLLHUP, "POLLHUP set after pipe close"); + + // Per Linux behaviour, a closed empty pipe should also set POLLIN + // because read() would return 0 (EOF) without blocking. + fprintf(stderr, " INFO: revents=0x%x (POLLIN=0x%x POLLHUP=0x%x)\n", + pfd.revents, POLLIN, POLLHUP); + + // Verify EOF + n = read(pipefd[0], buf, sizeof(buf)); + TEST(n == 0, "read returns 0 (EOF) after pipe close"); + + close(pipefd[0]); + waitpid(child, NULL, 0); +} + +// ─── Test 2: epoll detects pipe close ─────────────────────────────────── +static void test_epoll_pipe_close(void) { + printf("Test 2: epoll detects pipe close when write end drops\n"); + int pipefd[2]; + TEST(pipe(pipefd) == 0, "pipe created"); + + int epfd = epoll_create1(0); + TEST(epfd >= 0, "epoll_create1 succeeded"); + + pid_t child = fork(); + TEST(child >= 0, "fork succeeded"); + + if (child == 0) { + // Child: close read end, write, then exit + close(pipefd[0]); + const char *msg = "world"; + (void)!write(pipefd[1], msg, strlen(msg)); + close(pipefd[1]); + _exit(0); + } + + // Parent: close write end, add read end to epoll + close(pipefd[1]); + + struct epoll_event ev = {.events = EPOLLIN, .data.fd = pipefd[0]}; + TEST(epoll_ctl(epfd, EPOLL_CTL_ADD, pipefd[0], &ev) == 0, + "epoll_ctl ADD succeeded"); + + // Wait for first event: data available + struct epoll_event events[4]; + int nfds = epoll_wait(epfd, events, 4, 200); + TEST(nfds == 1, "epoll_wait returned 1 event when data is ready"); + TEST(events[0].data.fd == pipefd[0], "event is for pipe fd"); + TEST(events[0].events & EPOLLIN, "EPOLLIN set after child writes data"); + + // Read data + char buf[64] = {0}; + ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1); + TEST(n == 5, "read 5 bytes via epoll wake"); + TEST(strcmp(buf, "world") == 0, "correct data via epoll"); + + // Now wait for close event — after draining data and child exited, + // epoll should report EPOLLHUP + nfds = epoll_wait(epfd, events, 4, 200); + TEST(nfds == 1, "epoll_wait returned event after pipe close"); + TEST(events[0].data.fd == pipefd[0], "close event is for pipe fd"); + fprintf(stderr, " INFO: epoll events=0x%x (EPOLLIN=0x%x EPOLLHUP=0x%x)\n", + events[0].events, EPOLLIN, EPOLLHUP); + + // Linux: EPOLLHUP is set; EPOLLIN may or may not be set depending on + // kernel version. epoll_wait returning at all for the close is the + // essential behaviour. + TEST((events[0].events & (EPOLLIN | EPOLLHUP)) != 0, + "EPOLLIN or EPOLLHUP set after pipe close"); + TEST(events[0].events & EPOLLHUP, "EPOLLHUP set after pipe close"); + + // Verify EOF + n = read(pipefd[0], buf, sizeof(buf)); + TEST(n == 0, "read returns 0 (EOF) after pipe close"); + + close(pipefd[0]); + close(epfd); + waitpid(child, NULL, 0); +} + +// ─── Test 3: epoll EPOLLIN-only interest still receives close event ───── +static void test_epoll_in_only_detects_close(void) { + printf("Test 3: epoll EPOLLIN-only detects pipe close (Nix pattern)\n"); + int pipefd[2]; + TEST(pipe(pipefd) == 0, "pipe created"); + + int epfd = epoll_create1(0); + TEST(epfd >= 0, "epoll_create1 succeeded"); + + + pid_t child = fork(); + TEST(child >= 0, "fork succeeded"); + + if (child == 0) { + // Child: close read end, write marker, exit + close(pipefd[0]); + (void)!write(pipefd[1], "x", 1); + (void)!fsync(pipefd[1]); + close(pipefd[1]); + _exit(0); + } + + // Parent: close write end, monitor read end with EPOLLIN only + close(pipefd[1]); + + struct epoll_event ev = {.events = EPOLLIN, .data.fd = pipefd[0]}; + TEST(epoll_ctl(epfd, EPOLL_CTL_ADD, pipefd[0], &ev) == 0, + "epoll_ctl ADD with EPOLLIN only"); + + // First event: data + struct epoll_event events[4]; + int nfds = epoll_wait(epfd, events, 4, 200); + TEST(nfds == 1, "EPOLLIN-only: first epoll_wait got data event"); + TEST(events[0].events & EPOLLIN, "EPOLLIN-only: EPOLLIN set for data"); + + // Drain data + char c; + TEST(read(pipefd[0], &c, 1) == 1, "EPOLLIN-only: read 1 byte"); + + // Now the child has exited and closed its write end. + // We monitor with EPOLLIN only — this is exactly what Nix does. + // The epoll must wake us up even though we only asked for EPOLLIN. + // Verify the child has exited + int status; + waitpid(child, &status, 0); + + nfds = epoll_wait(epfd, events, 4, 200); + // We must get at least 1 event — the pipe close. + TEST(nfds >= 1, "EPOLLIN-only: epoll_wait returned after pipe close"); + if (nfds >= 1) { + fprintf(stderr, + " INFO: EPOLLIN-only close events=0x%x (EPOLLIN=0x%x " + "EPOLLHUP=0x%x)\n", + events[0].events, EPOLLIN, EPOLLHUP); + TEST((events[0].events & (EPOLLIN | EPOLLHUP)) != 0, + "EPOLLIN-only: got EPOLLIN or EPOLLHUP on close"); + } + + close(pipefd[0]); + close(epfd); +} + +// ─── Test 4: poll_smoke — 2-fd poll where one fd closes ───────────────── +static void test_poll_two_fds_one_closes(void) { + printf("Test 4: poll with 2 fds, one pipe closes (Nix multi-fd pattern)\n"); + int pipefd[2]; + TEST(pipe(pipefd) == 0, "pipe created"); + + pid_t child = fork(); + TEST(child >= 0, "fork succeeded"); + + if (child == 0) { + close(pipefd[0]); + (void)!write(pipefd[1], "!", 1); + (void)!fsync(pipefd[1]); + close(pipefd[1]); + _exit(0); + } + + close(pipefd[1]); + + // Also open another fd (e.g. a "control" fd via pipe-to-self) + int ctrlfd[2]; + TEST(pipe(ctrlfd) == 0, "control pipe created"); + + struct pollfd pfds[2] = { + {.fd = pipefd[0], .events = POLLIN}, + {.fd = ctrlfd[0], .events = POLLIN}, + }; + + // First poll: data on pipefd + int ret = poll(pfds, 2, 200); + TEST(ret >= 1, "multi-fd: poll got initial event(s)"); + TEST(pfds[0].revents & POLLIN, "multi-fd: pipe fd has POLLIN"); + + // Drain + char c; + (void)!read(pipefd[0], &c, 1); + + // Wait for child exit + waitpid(child, NULL, 0); + + // Poll again: pipe should report HUP + pfds[0].revents = 0; + pfds[1].revents = 0; + ret = poll(pfds, 2, 200); + TEST(ret >= 1, "multi-fd: poll detected pipe close"); + TEST((pfds[0].revents & (POLLIN | POLLHUP)) != 0, + "multi-fd: pipe fd reports POLLIN or POLLHUP after close"); + fprintf(stderr, " INFO: multi-fd pipe revents=0x%x ctrl revents=0x%x\n", + pfds[0].revents, pfds[1].revents); + + close(pipefd[0]); + close(ctrlfd[0]); + close(ctrlfd[1]); +} + +// ─── Test 5: epoll LT + close without draining first ──────────────────── +// Edge case: fd added to epoll, child writes and exits, parent hasn't +// drained yet. epoll must deliver both IN (data) and HUP (close). +static void test_epoll_lt_close_with_data(void) { + printf("Test 6: epoll LT delivers both data and close in one event\n"); + int pipefd[2]; + TEST(pipe(pipefd) == 0, "pipe created"); + + int epfd = epoll_create1(0); + TEST(epfd >= 0, "epoll_create1 succeeded"); + + pid_t child = fork(); + TEST(child >= 0, "fork succeeded"); + + if (child == 0) { + close(pipefd[0]); + (void)!write(pipefd[1], "data", 4); + (void)!fsync(pipefd[1]); + close(pipefd[1]); + _exit(0); + } + + close(pipefd[1]); + + struct epoll_event ev = {.events = EPOLLIN, .data.fd = pipefd[0]}; + TEST(epoll_ctl(epfd, EPOLL_CTL_ADD, pipefd[0], &ev) == 0, + "epoll_ctl ADD succeeded"); + + // Wait for child to exit + waitpid(child, NULL, 0); + + // Now epoll should report events — both data and close + struct epoll_event events[4]; + int nfds = epoll_wait(epfd, events, 4, 200); + TEST(nfds == 1, "epoll_wait returned 1 event (data+close combined)"); + TEST(events[0].data.fd == pipefd[0], + "event is for the pipe read end"); + fprintf(stderr, + " INFO: LT data+close events=0x%x (EPOLLIN=0x%x EPOLLHUP=0x%x)\n", + events[0].events, EPOLLIN, EPOLLHUP); + TEST((events[0].events & (EPOLLIN | EPOLLHUP)) != 0, + "got EPOLLIN or EPOLLHUP for data+close"); + // EPOLLIN must be set because there is unconsumed data + TEST(events[0].events & EPOLLIN, + "EPOLLIN set (data present in buffer)"); + + // Read data + char buf[64] = {0}; + ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1); + TEST(n == 4, "read 4 bytes"); + TEST(strcmp(buf, "data") == 0, "correct data"); + + // After draining, epoll should report HUP + nfds = epoll_wait(epfd, events, 4, 200); + TEST(nfds == 1, "after drain: epoll_wait returned close event"); + fprintf(stderr, " INFO: after-drain close events=0x%x\n", + events[0].events); + TEST((events[0].events & (EPOLLIN | EPOLLHUP)) != 0, + "after drain: got EPOLLIN or EPOLLHUP"); + + close(pipefd[0]); + close(epfd); +} + +int main(void) { + printf("=== pipe-poll-close regression ===\n"); + + test_poll_pipe_close(); + test_epoll_pipe_close(); + test_epoll_in_only_detects_close(); + test_poll_two_fds_one_closes(); + test_epoll_lt_close_with_data(); + + printf("\n=== Results: %d pass, %d fail ===\n", tests_pass, tests_fail); + if (tests_fail == 0) { + printf("TEST PASSED\n"); + } else { + printf("TEST FAILED\n"); + } + return tests_fail > 0 ? 1 : 0; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-pty-master-close/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-pty-master-close/CMakeLists.txt new file mode 100644 index 0000000000..8ae634b247 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-pty-master-close/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.20) +project(test-pty-master-close C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-pty-master-close src/main.c) +target_compile_options(test-pty-master-close PRIVATE -Wall -Wextra -Werror) +install(TARGETS test-pty-master-close RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-pty-master-close/src/main.c b/test-suit/starryos/qemu-smp1/system/test-pty-master-close/src/main.c new file mode 100644 index 0000000000..c7bf250910 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-pty-master-close/src/main.c @@ -0,0 +1,294 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int fails; + +static void pass(const char *msg) +{ + printf(" PASS: %s\n", msg); +} + +static void fail(const char *msg) +{ + printf(" FAIL: %s (errno=%d: %s)\n", msg, errno, strerror(errno)); + fails++; +} + +static ssize_t read_line_timeout(int fd, char *buf, size_t len, int timeout_ms) +{ + size_t off = 0; + while (off + 1 < len) { + struct pollfd pfd = { + .fd = fd, + .events = POLLIN, + .revents = 0, + }; + int rc = poll(&pfd, 1, timeout_ms); + if (rc <= 0) { + return rc; + } + char ch = 0; + ssize_t n = read(fd, &ch, 1); + if (n <= 0) { + return n; + } + buf[off++] = ch; + if (ch == '\n') { + break; + } + } + buf[off] = '\0'; + return (ssize_t)off; +} + +static void check_nix_like_child(void) +{ + int master = posix_openpt(O_RDWR | O_NOCTTY); + if (master < 0) { + fail("nix-like posix_openpt"); + return; + } + if (grantpt(master) != 0 || unlockpt(master) != 0) { + fail("nix-like grantpt/unlockpt"); + close(master); + return; + } + char *slave_name = ptsname(master); + if (slave_name == NULL) { + fail("nix-like ptsname"); + close(master); + return; + } + + pid_t pid = fork(); + if (pid < 0) { + fail("nix-like fork"); + close(master); + return; + } + if (pid == 0) { + int slave = open(slave_name, O_RDWR | O_NOCTTY); + if (slave < 0) { + _exit(101); + } + struct termios term; + if (tcgetattr(slave, &term) != 0) { + _exit(102); + } + cfmakeraw(&term); + if (tcsetattr(slave, TCSANOW, &term) != 0) { + _exit(103); + } + if (dup2(slave, STDERR_FILENO) < 0) { + _exit(104); + } + close(slave); + if (setsid() < 0) { + _exit(105); + } + if (dup2(STDERR_FILENO, STDOUT_FILENO) < 0) { + _exit(106); + } + int null_fd = open("/dev/null", O_RDWR); + if (null_fd < 0 || dup2(null_fd, STDIN_FILENO) < 0) { + _exit(107); + } + close(null_fd); + if (write(STDERR_FILENO, "\002\n", 2) != 2) { + _exit(108); + } + const char setup_log[] = + "SETUP_LOG_BEGIN " + "abcdefghijklmnopqrstuvwxyz0123456789 " + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 " + "pty-buffer-must-not-drop-bytes-after-sentinel\n"; + if (write(STDERR_FILENO, setup_log, sizeof(setup_log) - 1) != + (ssize_t)(sizeof(setup_log) - 1)) { + _exit(109); + } + execl("/bin/sh", "sh", "-c", "echo CHILD_OK >&2", (char *)NULL); + _exit(110); + } + + char line[256]; + ssize_t n = read_line_timeout(master, line, sizeof(line), 1000); + if (n <= 0 || line[0] != '\002') { + fail("nix-like parent reads setup sentinel"); + goto out_wait; + } + pass("nix-like parent reads setup sentinel"); + + n = read_line_timeout(master, line, sizeof(line), 1000); + if (n <= 0 || strstr(line, "pty-buffer-must-not-drop-bytes-after-sentinel") == NULL) { + fail("nix-like parent reads long setup log after sentinel"); + goto out_wait; + } + pass("nix-like parent reads long setup log after sentinel"); + + n = read_line_timeout(master, line, sizeof(line), 5000); + if (n <= 0 || strstr(line, "CHILD_OK") == NULL) { + fail("nix-like parent reads child stderr after sentinel"); + goto out_wait; + } + pass("nix-like parent reads child stderr after sentinel"); + +out_wait: + { + int status = 0; + waitpid(pid, &status, 0); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fail("nix-like child exits cleanly"); + } else { + pass("nix-like child exits cleanly"); + } + } + close(master); +} + +int main(void) +{ + printf("=== pty-master-close regression ===\n"); + + int master = posix_openpt(O_RDWR | O_NOCTTY); + if (master < 0) { + fail("posix_openpt"); + goto out; + } + pass("posix_openpt"); + + if (grantpt(master) != 0) { + fail("grantpt"); + goto out_master; + } + pass("grantpt"); + + if (unlockpt(master) != 0) { + fail("unlockpt"); + goto out_master; + } + pass("unlockpt"); + + char *slave_name = ptsname(master); + if (slave_name == NULL) { + fail("ptsname"); + goto out_master; + } + pass("ptsname"); + + int slave = open(slave_name, O_RDWR | O_NOCTTY); + if (slave < 0) { + fail("open slave"); + goto out_master; + } + pass("open slave"); + + int slave_dup = dup(slave); + if (slave_dup < 0) { + fail("dup slave"); + goto out_slave; + } + pass("dup slave"); + + if (close(slave) != 0) { + fail("close slave"); + goto out_slave_dup; + } + slave = -1; + pass("close one slave fd"); + + struct pollfd pfd = { + .fd = master, + .events = POLLIN, + .revents = 0, + }; + int poll_rc = poll(&pfd, 1, 100); + if (poll_rc != 0) { + fail("master must not report close while dup slave is still open"); + goto out_slave_dup; + } + pass("master stays pending while dup slave is open"); + + const char setup_done[] = "\002\n"; + ssize_t written = write(slave_dup, setup_done, sizeof(setup_done) - 1); + if (written != (ssize_t)(sizeof(setup_done) - 1)) { + fail("write setup sentinel through dup slave"); + goto out_slave_dup; + } + pass("write setup sentinel through dup slave"); + + pfd.revents = 0; + poll_rc = poll(&pfd, 1, 1000); + if (poll_rc != 1 || (pfd.revents & POLLIN) == 0) { + fail("master poll reports sentinel data"); + goto out_slave_dup; + } + pass("master poll reports sentinel data"); + + char setup_line[8] = {0}; + ssize_t setup_read = read_line_timeout(master, setup_line, sizeof(setup_line), 1000); + if (setup_read <= 0 || setup_line[0] != '\002') { + fail("master reads setup sentinel line"); + goto out_slave_dup; + } + pass("master reads setup sentinel line"); + + if (close(slave_dup) != 0) { + fail("close dup slave"); + goto out_master; + } + slave_dup = -1; + pass("close last slave fd"); + + pfd.revents = 0; + poll_rc = poll(&pfd, 1, 1000); + if (poll_rc != 1) { + fail("master poll returns after slave close"); + goto out_master; + } + pass("master poll returns after slave close"); + + if ((pfd.revents & (POLLIN | POLLHUP | POLLERR)) == 0) { + fail("master poll reports close readiness"); + goto out_master; + } + printf(" INFO: master revents=0x%x\n", pfd.revents); + pass("master poll reports close readiness"); + + char byte = 0; + errno = 0; + ssize_t n = read(master, &byte, 1); + if (n == 0 || (n < 0 && errno == EIO)) { + pass("master read completes after slave close"); + } else { + fail("master read should complete with EOF or EIO after slave close"); + } + + check_nix_like_child(); + +out_slave_dup: + if (slave_dup >= 0) { + close(slave_dup); + } +out_slave: + if (slave >= 0) { + close(slave); + } +out_master: + close(master); +out: + printf("\n=== Results: %s ===\n", fails == 0 ? "pass" : "fail"); + if (fails == 0) { + printf("TEST PASSED\n"); + return 0; + } + printf("TEST FAILED\n"); + return 1; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-pty-openpt/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-pty-openpt/CMakeLists.txt new file mode 100644 index 0000000000..ffef5219e6 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-pty-openpt/CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.20) +project(test-pty-openpt C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-pty-openpt src/main.c) +target_include_directories(test-pty-openpt PRIVATE src) +target_compile_options(test-pty-openpt PRIVATE -Wall -Wextra -Werror) +target_link_options(test-pty-openpt PRIVATE -static) +install(TARGETS test-pty-openpt RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-pty-openpt/src/main.c b/test-suit/starryos/qemu-smp1/system/test-pty-openpt/src/main.c new file mode 100644 index 0000000000..3a01878ea7 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-pty-openpt/src/main.c @@ -0,0 +1,107 @@ +/* + * Focused StarryOS PTY diagnostic: test /dev/ptmx open + grantpt/unlockpt. + * + * Nix uses open("/dev/ptmx") → grantpt() → unlockpt() → ptsname() → open(slave) + * to create pseudo-terminals for builder processes. If any of these fail, + * the builder process never starts. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +#define PASS(msg) printf("PTY_PASS: %s\n", msg) +#define FAIL(msg) printf("PTY_FAIL: %s (errno=%d: %s)\n", msg, errno, strerror(errno)) +#define DIAG(msg) printf("PTY_DIAG: %s\n", msg) + +int main(void) +{ + /* 1. Check if /dev/ptmx exists */ + struct stat st; + if (stat("/dev/ptmx", &st) != 0) { + FAIL("stat /dev/ptmx"); + printf("PTY_DIAG_COMPLETE\n"); + return 1; + } + if (!S_ISCHR(st.st_mode)) { + printf("PTY_FAIL: /dev/ptmx is not a character device (mode=0%o)\n", st.st_mode); + printf("PTY_DIAG_COMPLETE\n"); + return 1; + } + PASS("stat /dev/ptmx is char dev"); + + /* 2. Open /dev/ptmx */ + int master = open("/dev/ptmx", O_RDWR | O_NOCTTY); + if (master < 0) { + FAIL("open /dev/ptmx O_RDWR|O_NOCTTY"); + /* Also try without O_NOCTTY */ + master = open("/dev/ptmx", O_RDWR); + if (master >= 0) { + DIAG("open /dev/ptmx O_RDWR succeeded (O_NOCTTY failed)"); + } else { + printf("PTY_FAIL: open /dev/ptmx O_RDWR also failed (errno=%d: %s)\n", + errno, strerror(errno)); + printf("PTY_DIAG_COMPLETE\n"); + return 1; + } + } + PASS("open /dev/ptmx"); + + /* 3. grantpt() */ + if (grantpt(master) != 0) { + FAIL("grantpt"); + close(master); + printf("PTY_DIAG_COMPLETE\n"); + return 1; + } + PASS("grantpt"); + + /* 4. unlockpt() */ + if (unlockpt(master) != 0) { + FAIL("unlockpt"); + close(master); + printf("PTY_DIAG_COMPLETE\n"); + return 1; + } + PASS("unlockpt"); + + /* 5. ptsname() */ + char *slave_name = ptsname(master); + if (slave_name == NULL) { + FAIL("ptsname"); + close(master); + printf("PTY_DIAG_COMPLETE\n"); + return 1; + } + printf("PTY_PASS: ptsname = %s\n", slave_name); + + /* 6. Open the slave */ + int slave = open(slave_name, O_RDWR | O_NOCTTY); + if (slave < 0) { + /* Try without O_NOCTTY */ + slave = open(slave_name, O_RDWR); + } + if (slave < 0) { + FAIL("open slave pty"); + close(master); + printf("PTY_DIAG_COMPLETE\n"); + return 1; + } + PASS("open slave pty"); + + /* 7. Check /dev/pts/ directory */ + if (stat("/dev/pts/", &st) != 0) { + DIAG("stat /dev/pts/ failed — devpts may not be mounted"); + } else { + PASS("stat /dev/pts/ exists"); + } + + close(slave); + close(master); + printf("PTY_ALL_PASSED\n"); + return 0; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-unshare-fs/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-unshare-fs/CMakeLists.txt new file mode 100644 index 0000000000..75e84b4c45 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-unshare-fs/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.20) +project(test-unshare-fs C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-unshare-fs src/main.c) +target_compile_options(test-unshare-fs PRIVATE -Wall -Wextra -Werror) +install(TARGETS test-unshare-fs RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-unshare-fs/src/main.c b/test-suit/starryos/qemu-smp1/system/test-unshare-fs/src/main.c new file mode 100644 index 0000000000..a6a1bfee44 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-unshare-fs/src/main.c @@ -0,0 +1,163 @@ +/* + * test-unshare-fs — verify unshare(CLONE_FS). + * + * nixpkgs 测试可能用到 — unshare(CLONE_FS) 是 Nix fetchTarball + * 下载线程的前置依赖。 + * + * Scenarios: + * 1. unshare(CLONE_FS) on independent task → returns 0. + * 2. clone(CLONE_FS) → share cwd → child unshare(CLONE_FS) → cwd + * isolation: child chdir must not affect parent cwd. + * 3. unshare(0xDEAD) → EINVAL. + * + * Note: uses clone(CLONE_FS | SIGCHLD), NOT fork(). In this kernel + * fork() does NOT share FS_CONTEXT, so a fork-based test would pass + * even when CLONE_FS sharing + unshare isolation is broken. + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define fail(fmt, ...) do { \ + fprintf(stderr, "FAIL | %s:%d | " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__); \ + exit(1); \ +} while(0) + +#define pass(fmt, ...) \ + printf(" PASS | %s:%d | " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__) + +#define check(cond, fmt, ...) do { \ + if (cond) pass(fmt, ##__VA_ARGS__); else fail(fmt, ##__VA_ARGS__); \ +} while(0) + +/* Minimal stack for clone(CLONE_FS) child — 64KiB should be enough for + chdir / getcwd / _exit; SIGCHLD so waitpid works. */ +#define STACK_SIZE (64 * 1024) + +struct clone_arg { + int *shared; + int barrier; +}; + +static int clone_child(void *arg) { + struct clone_arg *a = (struct clone_arg *)arg; + + /* After clone(CLONE_FS), parent and child share the same + FS_CONTEXT. chdir here should be visible to the parent. */ + int rc = chdir("/tmp"); + check(rc == 0, "clone child (shared FS) chdir to /tmp (rc=%d, errno=%d)", + rc, errno); + + /* Signal parent: I've chdir'd. */ + a->shared[0] = 1; + while (a->shared[1] == 0) usleep(10000); + + /* Parent should have observed /tmp while FS_CONTEXT was shared. */ + while (a->shared[2] == 0) usleep(10000); + + /* Now unshare(CLONE_FS) — break the shared FS_CONTEXT. */ + rc = unshare(CLONE_FS); + check(rc == 0, "clone child unshare(CLONE_FS) (rc=%d, errno=%d)", rc, errno); + + /* Now cwd changes here must be invisible to parent. */ + rc = chdir("/usr"); + check(rc == 0, "child chdir to /usr after unshare(CLONE_FS) (rc=%d, errno=%d)", + rc, errno); + + char cwd[256]; + check(getcwd(cwd, sizeof(cwd)) != NULL, "child getcwd after unshare"); + check(strcmp(cwd, "/usr") == 0, + "child cwd is /usr after unshare+chdir: %s", cwd); + + /* Tell parent: child finished its side. */ + a->shared[3] = 1; + _exit(0); + return 0; +} + +static void test_unshare_fs_basic(void) { + int rc = unshare(CLONE_FS); + check(rc == 0, "unshare(CLONE_FS) on independent task (rc=%d, errno=%d)", + rc, errno); + + char cwd[256]; + check(getcwd(cwd, sizeof(cwd)) != NULL, "getcwd after unshare(CLONE_FS)"); + check(cwd[0] == '/', "cwd valid after unshare(CLONE_FS): %s", cwd); + + printf("UNSHARE_FS_BASIC_PASSED\n"); +} + +static void test_clone_fs_unshare_isolation(void) { + int *shared = mmap(NULL, 4096, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_ANONYMOUS, -1, 0); + check(shared != MAP_FAILED, "mmap shared page"); + shared[0] = 0; shared[1] = 0; shared[2] = 0; shared[3] = 0; + + /* Allocate stack on heap for the clone child. */ + char *stack = mmap(NULL, STACK_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); + check(stack != MAP_FAILED, "mmap child stack"); + /* clone(2) takes the *top* of the stack on x86_64. */ + char *stack_top = stack + STACK_SIZE; + + /* Set up parent baseline cwd. */ + mkdir("/tmp/unshare-fs-dirP", 0755); + int rc = chdir("/tmp/unshare-fs-dirP"); + check(rc == 0, "parent chdir to /tmp/unshare-fs-dirP"); + + struct clone_arg arg = { .shared = shared, .barrier = 0 }; + pid_t pid = clone(clone_child, (void *)stack_top, + CLONE_FS | CLONE_VM | SIGCHLD, &arg); + check(pid > 0, "clone(CLONE_FS|CLONE_VM|SIGCHLD) returned pid=%d", pid); + + /* Wait for child to chdir to /tmp (shared FS_CONTEXT). */ + while (shared[0] == 0) usleep(10000); + + /* Verify parent sees child's chdir — proving FS_CONTEXT was shared. */ + char cwd[256]; + check(getcwd(cwd, sizeof(cwd)) != NULL, "parent getcwd after child shared chdir"); + check(strcmp(cwd, "/tmp") == 0, + "parent observes child chdir (shared FS): cwd=%s", cwd); + + /* Tell child to proceed with unshare. */ + shared[1] = 1; + shared[2] = 1; + + /* Wait for child to unshare(CLONE_FS) + chdir /usr. */ + while (shared[3] == 0) usleep(10000); + + /* Verify parent cwd is still /tmp — isolation works. */ + check(getcwd(cwd, sizeof(cwd)) != NULL, "parent getcwd after child unshare"); + check(strcmp(cwd, "/tmp") == 0, + "parent cwd unchanged after child unshare+chdir: %s", cwd); + + waitpid(pid, NULL, 0); + munmap(stack, STACK_SIZE); + munmap(shared, 4096); + rmdir("/tmp/unshare-fs-dirP"); + + printf("UNSHARE_FS_CLONE_ISOLATION_PASSED\n"); +} + +static void test_unshare_invalid_flags(void) { + int rc = unshare(0xDEAD); + check(rc == -1 && errno == EINVAL, + "unshare(0xDEAD) returns EINVAL (rc=%d, errno=%d)", rc, errno); + printf("UNSHARE_FS_INVALID_PASSED\n"); +} + +int main(void) { + test_unshare_fs_basic(); + test_clone_fs_unshare_isolation(); + test_unshare_invalid_flags(); + printf("UNSHARE_FS_ALL_PASSED\n"); + return 0; +} diff --git a/test-suit/starryos/qemu-smp1/system/test-unshare-mount-ns/CMakeLists.txt b/test-suit/starryos/qemu-smp1/system/test-unshare-mount-ns/CMakeLists.txt new file mode 100644 index 0000000000..636ce5b60b --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-unshare-mount-ns/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.20) +project(test-unshare-mount-ns C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) +add_executable(test-unshare-mount-ns src/main.c) +target_include_directories(test-unshare-mount-ns PRIVATE src) +target_compile_options(test-unshare-mount-ns PRIVATE -Wall -Wextra -Werror) +install(TARGETS test-unshare-mount-ns RUNTIME DESTINATION usr/bin/starry-test-suit) diff --git a/test-suit/starryos/qemu-smp1/system/test-unshare-mount-ns/src/main.c b/test-suit/starryos/qemu-smp1/system/test-unshare-mount-ns/src/main.c new file mode 100644 index 0000000000..acc5d82dd2 --- /dev/null +++ b/test-suit/starryos/qemu-smp1/system/test-unshare-mount-ns/src/main.c @@ -0,0 +1,244 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef __NR_setns +#error "__NR_setns required from " +#endif + +#define BASE "/tmp/nix-prereq-mnt-ns" +#define SOURCE BASE "/source" +#define TARGET BASE "/target" +#define SOURCE_MARKER SOURCE "/setns-visible" +#define TARGET_MARKER TARGET "/setns-visible" + +#define FAIL(msg) \ + do { \ + fprintf(stderr, "FAIL | %s:%d | %s: %s\n", __FILE__, __LINE__, msg, \ + strerror(errno)); \ + exit(1); \ + } while (0) + +#define PASS(msg) \ + do { \ + printf(" PASS | %s:%d | %s\n", __FILE__, __LINE__, msg); \ + } while (0) + +static int xsetns(int fd, int nstype) { + return (int)syscall(__NR_setns, fd, nstype); +} + +static void write_all(int fd, const char *buf, size_t len, const char *what) { + size_t done = 0; + while (done < len) { + ssize_t n = write(fd, buf + done, len - done); + if (n < 0) + FAIL(what); + done += (size_t)n; + } +} + +static void read_one(int fd, const char *what) { + char byte; + ssize_t n = read(fd, &byte, 1); + if (n != 1) + FAIL(what); +} + +static void prepare_tree(void) { + mkdir(BASE, 0755); + mkdir(SOURCE, 0755); + mkdir(TARGET, 0755); + + int fd = open(SOURCE_MARKER, O_CREAT | O_WRONLY | O_TRUNC, 0644); + if (fd < 0) + FAIL("create source marker"); + write_all(fd, "mount namespace marker\n", 23, "write source marker"); + if (close(fd) < 0) + FAIL("close source marker"); + + if (access(SOURCE, F_OK) < 0) + FAIL("source directory exists"); + if (access(TARGET, F_OK) < 0) + FAIL("target directory exists"); + if (access(TARGET_MARKER, F_OK) == 0) { + errno = EEXIST; + FAIL("parent target starts without marker"); + } + if (errno != ENOENT) + FAIL("check parent target marker absence"); +} + +/* ── clone(CLONE_FS) + unshare(CLONE_NEWNS) isolation test ──────────── */ + +#define CLONE_BASE "/tmp/nix-prereq-mnt-ns-cf" +#define CLONE_SRC CLONE_BASE "/src" +#define CLONE_DST CLONE_BASE "/dst" +#define CLONE_MARKER CLONE_SRC "/cf-marker" +#define CLONE_DONE CLONE_DST "/cf-marker" +#define CLONE_STACK_SIZE (64 * 1024) + +static volatile int clone_ns_child_done; + +static int clone_child_ns(void *arg) { + (void)arg; + if (unshare(CLONE_NEWNS) < 0) _exit(1); + if (mount(CLONE_SRC, CLONE_DST, "none", MS_BIND, NULL) < 0) _exit(2); + if (access(CLONE_DONE, F_OK) < 0) _exit(3); + clone_ns_child_done = 1; + _exit(0); + return 0; +} + +static void test_clone_fs_unshare_new_ns(void) { + printf("\n--- clone(CLONE_FS) + child unshare(CLONE_NEWNS) ---\n"); + + clone_ns_child_done = 0; + + mkdir(CLONE_BASE, 0755); + mkdir(CLONE_SRC, 0755); + mkdir(CLONE_DST, 0755); + int fd = open(CLONE_MARKER, O_CREAT | O_WRONLY | O_TRUNC, 0644); + if (fd < 0) FAIL("clone: create marker"); + write_all(fd, "clone ns marker\n", 16, "clone: write marker"); + close(fd); + + char *stack = mmap(NULL, CLONE_STACK_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (stack == MAP_FAILED) FAIL("clone: mmap stack"); + + /* + * clone(CLONE_FS | CLONE_VM | SIGCHLD) — parent and child share + * Arc>, child privatises before unshare(NEWNS). + */ + pid_t pid = clone(clone_child_ns, (void *)(stack + CLONE_STACK_SIZE), + CLONE_FS | CLONE_VM | SIGCHLD, NULL); + if (pid < 0) FAIL("clone: clone(CLONE_FS|CLONE_VM|SIGCHLD)"); + + while (!clone_ns_child_done) usleep(10000); + PASS("clone child unshared NEWNS + bind mount"); + + /* + * With the fix (namespace.rs force_read_decrement path): + * child privatised FsContext before unshare_mount_namespace(). + * Parent's FsContext is unchanged → parent does NOT see the bind mount. + */ + if (access(CLONE_DONE, F_OK) == 0) { + fprintf(stderr, "FAIL | %s:%d | parent sees clone child mount " + "(NEWNS on shared FsContext leaked)\n", __FILE__, __LINE__); + exit(1); + } + PASS("parent does not see clone child mount (isolation ok)"); + + int status; + if (waitpid(pid, &status, 0) < 0) FAIL("clone: waitpid"); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) + FAIL("clone: child non-zero exit"); + + munmap(stack, CLONE_STACK_SIZE); + rmdir(CLONE_DST); + unlink(CLONE_MARKER); + rmdir(CLONE_SRC); + rmdir(CLONE_BASE); + + printf("UNSHARE_MOUNT_NS_CLONE_ISOLATION_PASSED\n"); +} + +/* ── original fork-based test ──────────────────────────────────────── */ + +static void child_body(int ready_fd, int release_fd) { + if (unshare(CLONE_NEWNS) < 0) + FAIL("unshare(CLONE_NEWNS)"); + PASS("child unshared mount namespace"); + + if (mount(SOURCE, TARGET, "none", MS_BIND, NULL) < 0) + FAIL("bind mount source onto target"); + if (access(TARGET_MARKER, F_OK) < 0) + FAIL("child sees marker through namespace-local mount"); + PASS("child sees namespace-local bind mount"); + + write_all(ready_fd, "R", 1, "signal child mount ready"); + read_one(release_fd, "wait parent setns check"); + + if (umount2(TARGET, MNT_DETACH) < 0) + FAIL("detach child bind mount"); + exit(0); +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + printf("================================================\n"); + printf(" TEST: unshare/setns(CLONE_NEWNS) mount view\n"); + printf("================================================\n"); + + /* Scenario 1: clone(CLONE_FS) + child unshare(CLONE_NEWNS) → parent + must NOT see child's namespace-local bind mount. */ + test_clone_fs_unshare_new_ns(); + + /* Scenario 2: fork() + child unshare(CLONE_NEWNS) + setns. */ + prepare_tree(); + + int ready_pipe[2]; + int release_pipe[2]; + if (pipe(ready_pipe) < 0) + FAIL("pipe ready"); + if (pipe(release_pipe) < 0) + FAIL("pipe release"); + + pid_t child = fork(); + if (child < 0) + FAIL("fork"); + + if (child == 0) { + close(ready_pipe[0]); + close(release_pipe[1]); + child_body(ready_pipe[1], release_pipe[0]); + } + + close(ready_pipe[1]); + close(release_pipe[0]); + read_one(ready_pipe[0], "wait child mount ready"); + + if (access(TARGET_MARKER, F_OK) == 0) { + errno = EEXIST; + FAIL("parent original namespace must not see child bind mount"); + } + if (errno != ENOENT) + FAIL("check original namespace target marker absence"); + PASS("parent original namespace does not see child bind mount"); + + char ns_path[64]; + snprintf(ns_path, sizeof(ns_path), "/proc/%d/ns/mnt", child); + int nsfd = open(ns_path, O_RDONLY | O_CLOEXEC); + if (nsfd < 0) + FAIL("open child /proc//ns/mnt"); + if (xsetns(nsfd, CLONE_NEWNS) < 0) + FAIL("setns child mount namespace"); + if (close(nsfd) < 0) + FAIL("close nsfd"); + if (access(TARGET_MARKER, F_OK) < 0) + FAIL("parent sees child bind mount after setns"); + PASS("setns(CLONE_NEWNS) switches to target mount view"); + + write_all(release_pipe[1], "D", 1, "release child"); + + int status; + if (waitpid(child, &status, 0) < 0) + FAIL("waitpid child"); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) + FAIL("child exited non-zero"); + PASS("child exited cleanly"); + + printf("UNSHARE_MOUNT_NS_ALL_PASSED\n"); + return 0; +}