feat(netsurf): NetSurf-on-NONOS foundation — toolchain, relibc Pal, capsule ops, display de-risk gate #27

Open
nonos-sync wants to merge 72 commits from nonos-sync/gh-259 into main AGit
Member

NetSurf-on-NONOS foundation — toolchain, relibc Pal, capsule ops, display de-risk gate

Brings up the foundation for running NetSurf as a signed NONOS capsule via a relibc nonos Pal backend + clang x86_64-unknown-nonos toolchain (not a hand-rolled engine). Long-lived branch built across multiple sessions; the latest arc (Phase 4 Task 4.0) is freshly landed and pixel-proven. 96 commits off main.

Phase status

Phase What Proof
0 — toolchain clang x86_64-unknown-nonos + relibc compiles; signed clang/relibc C capsule boots, enters CPL=3, runs mainwrite→serial [C-PROOF] PASS
1 — relibc Pal de-stub full relibc startup (malloc/stdio/environ); time/mem/getrandom/fs real over IPC [RELIBC-TEST] PASS (fs+malloc+clock)
2 — capsule ops VFS random-access (OP_SEEK/PREAD/PWRITE) + net.sockets nonblock/poll/timeout server + full relibc socket+epoll backend [C-NET] PASS (E2E TCP echo over SLIRP)
3 — futex determined a no-op (single-threaded runtime already proven; NetSurf dep shelf is single-threaded) — confirmed at the Phase-5 link decision recorded
4 — display shim Task 4.0 (de-risk gate, this arc): pure-C capsule paints a gradient through the real compositor path, presented to screen [NSFB-PROBE] PASS + screendump

This arc — Phase 4 Task 4.0 (display-shim de-risk gate)

Before vendoring NetSurf + libnsfb, the verified surface/compositor contract is proven end-to-end. capsule_nsfb_probe (pure C) does mmap → mk_surface_register → mk_surface_share → OP_DISPLAY_INFO → paint ARGB gradient → OP_SCENE_SUBMIT → OP_DAMAGE_COMMIT, and the gradient reaches the screen.

  • Gate: make NONOS_DEV=1 nonos-mk-nsfbprobe-smoke-test → serial [NSFB-PROBE] PASS and a QMP screendump whose four corners are channel-correct (black / red / green / yellow) — first boot, under hvf.
  • Implementation: hand-rolled C inline-asm syscall wrappers (clobber set defuses the NONOS rdi/rsi/rdx convention), SurfaceDescriptor 40B byte-exact (_Static_assert-locked), NCMP framing per the audited contract, caps 0x1119 hand-synced (Capsule.mk ↔ spawn.rs), kernel mirror of the capsule_c_proof pattern, microkernel-nsfb-probe-smoketest profile (core + compositor + virtio-gpu + probe).
  • Confirmed-real compositor-readiness race fixed (mk_service_lookup NOENT + OP_DISPLAY_INFO timeout while the compositor is in wait_for_setup) via a bounded retry.
  • Genuine, non-vacuous boot gate: a black / inverted / channel-swapped / missing screendump all FAIL closed; PASS requires both the serial marker and all four corner thresholds.

Built under subagent-driven-development: each sub-task implement → independent review → fix → complete; a final whole-arc review came back 0 Critical / 0 Important with the four-layer contract source-verified byte-exact.

Test state

  • The per-phase boot smoketests are the verification harness (no cargo test for the kernel image). Phase gates [C-PROOF] / [RELIBC-TEST] / [C-NET] / [NSFB-PROBE] all pass.
  • The host cargo check --features std (#[panic_handler] not found) is pre-existing on the base tree (reproduces without this branch's changes) — tracked separately, not a regression introduced here.

Not in this PR (follow-on)

Phase 4.1+ (vendor netsurf-all-3.11 + the nonos libnsfb backend nonos.c delegating to the proven surface path) and Phase 5 (cross-build OpenSSL/curl/freetype + link netsurf-fb + package).


Opened on GitHub by senseix21 as pull request 259. Review and merge happen there while this repository is kept in step from GitHub; this copy follows it, and is marked merged or closed when it is.

## NetSurf-on-NONOS foundation — toolchain, relibc Pal, capsule ops, display de-risk gate Brings up the foundation for running **NetSurf** as a signed NONOS capsule via a **relibc `nonos` Pal backend + clang `x86_64-unknown-nonos` toolchain** (not a hand-rolled engine). Long-lived branch built across multiple sessions; the latest arc (Phase 4 Task 4.0) is freshly landed and pixel-proven. 96 commits off `main`. ### Phase status | Phase | What | Proof | |-------|------|-------| | **0 — toolchain** | clang `x86_64-unknown-nonos` + relibc compiles; signed clang/relibc C capsule boots, enters CPL=3, runs `main`→`write`→serial | ✅ `[C-PROOF] PASS` | | **1 — relibc Pal de-stub** | full relibc startup (malloc/stdio/environ); time/mem/getrandom/fs real over IPC | ✅ `[RELIBC-TEST] PASS` (fs+malloc+clock) | | **2 — capsule ops** | VFS random-access (`OP_SEEK`/`PREAD`/`PWRITE`) + `net.sockets` nonblock/poll/timeout server + full relibc socket+epoll backend | ✅ `[C-NET] PASS` (E2E TCP echo over SLIRP) | | **3 — futex** | determined a **no-op** (single-threaded runtime already proven; NetSurf dep shelf is single-threaded) — confirmed at the Phase-5 link | ✅ decision recorded | | **4 — display shim** | **Task 4.0 (de-risk gate, this arc):** pure-C capsule paints a gradient **through the real compositor path**, presented to screen | ✅ `[NSFB-PROBE] PASS` + screendump | ### This arc — Phase 4 Task 4.0 (display-shim de-risk gate) Before vendoring NetSurf + libnsfb, the verified surface/compositor contract is proven end-to-end. `capsule_nsfb_probe` (pure C) does `mmap → mk_surface_register → mk_surface_share → OP_DISPLAY_INFO → paint ARGB gradient → OP_SCENE_SUBMIT → OP_DAMAGE_COMMIT`, and the gradient reaches the screen. - **Gate:** `make NONOS_DEV=1 nonos-mk-nsfbprobe-smoke-test` → serial `[NSFB-PROBE] PASS` **and** a QMP screendump whose four corners are channel-correct (black / red / green / yellow) — first boot, under hvf. - **Implementation:** hand-rolled C inline-asm syscall wrappers (clobber set defuses the NONOS rdi/rsi/rdx convention), `SurfaceDescriptor` 40B byte-exact (`_Static_assert`-locked), NCMP framing per the audited contract, caps `0x1119` hand-synced (Capsule.mk ↔ spawn.rs), kernel mirror of the `capsule_c_proof` pattern, `microkernel-nsfb-probe-smoketest` profile (core + compositor + virtio-gpu + probe). - **Confirmed-real compositor-readiness race fixed** (`mk_service_lookup` NOENT + `OP_DISPLAY_INFO` timeout while the compositor is in `wait_for_setup`) via a bounded retry. - **Genuine, non-vacuous boot gate:** a black / inverted / channel-swapped / missing screendump all FAIL closed; PASS requires both the serial marker and all four corner thresholds. Built under subagent-driven-development: each sub-task implement → independent review → fix → complete; a final whole-arc review came back **0 Critical / 0 Important** with the four-layer contract source-verified byte-exact. ### Test state - The per-phase boot smoketests are the verification harness (no `cargo test` for the kernel image). Phase gates `[C-PROOF]` / `[RELIBC-TEST]` / `[C-NET]` / `[NSFB-PROBE]` all pass. - The host `cargo check --features std` (`#[panic_handler] not found`) is **pre-existing on the base tree** (reproduces without this branch's changes) — tracked separately, not a regression introduced here. ### Not in this PR (follow-on) Phase 4.1+ (vendor `netsurf-all-3.11` + the `nonos` libnsfb backend `nonos.c` delegating to the proven surface path) and Phase 5 (cross-build OpenSSL/curl/freetype + link `netsurf-fb` + package). --- Opened on GitHub by senseix21 as [pull request 259](https://github.com/NON-OS/nonos-micro-kernel/pull/259). Review and merge happen there while this repository is kept in step from GitHub; this copy follows it, and is marked merged or closed when it is.
relibc selects its platform backend with cfg(target_os="nonos") and
auto-detects TARGET from the spec's llvm-target. Mirror the userland
x86_64 spec but set os=nonos so the relibc nonos arm is selected when
building C userland against this target.
The NONOS syscall ABI passes a 4-byte ASCII tag op in rax, args in
rdi,rsi,rdx,r10,r8,r9, returns a signed rax (negative = errno) and
clobbers rcx,r11. Provide tag4 + the MK_* op tags and syscall0..6
inline-asm wrappers that the Pal slice (Task 0.3) builds on.
third_party/redox is gitignored; author the backend as tracked source
and graft it in. apply.sh copies platform/nonos/* into the vendored
relibc and idempotently patches the platform selector (cfg arm) and
config.mk (toolchain TARGET block), mirroring nonos-std/apply.sh, so
make TARGET=x86_64-unknown-nonos headers builds the nonos arm.
Without this, relibc's `make libs` attempts to compile and cross-execute
nonos test binaries on the host and fails. Also adds a comment above the
vendored-relibc-version-specific mod.rs anchor to flag it for re-check
on a relibc re-import.
write→MkDebug, exit→MkExit, getpid→MkGetPid, mmap→MkMmap, munmap→MkMunmap,
brk→ENOSYS (dlmalloc uses mmap, never brk). Remaining required methods land
as ENOSYS stubs in following commits.
faccessat/openat/read/write-family/dirents/links/stat + mmap-family
(mlock/mprotect/mremap/msync/madvise/nanosleep) + getpagesize=4096.
clock/timer + id constants (getuid/getgid/getpid-family=0, umask noop) +
process ops (fork/execve/spawn/waitpid/rlct_*/futex). Completes the required
Pal surface (0 E0046); Phase 1 de-stubs from here.
All of PalSocket(15)/PalSignal(12)/PalEpoll(3)/PalPtrace(1) stubbed to
ENOSYS (sigsuspend returns Errno directly). Completes the sub-trait surface.
relibc builds with -D unsafe-op-in-unsafe-fn (edition 2024); bare asm! in an
unsafe fn is E0133. Wrap each syscallN asm! in an explicit unsafe block.
apply.sh now copies socket/signal/epoll/ptrace.rs, replaces relibc's
thread_local ERRNO with a Sync errno static for nonos (no per-thread TLS;
plain static Cell isn't Sync), and writes an idempotent config.mk
`override CARGOFLAGS` that passes the json target spec + -Z json-target-spec
(cargo build-std ignores RUST_TARGET_PATH for custom targets).
The default `check_against_libc_crate` feature pulls in the `libc` crate
(aliased `__libc_only_for_layout_checks`) to size-assert every struct, but
`libc` has no `x86_64-unknown-nonos` arm so the layout types are missing.
Drop default features (keep `no_trace`) for the nonos build. Also export
`OBJCOPY=llvm-objcopy` so relibc's `renamesyms.sh` post-step resolves on a
host with only LLVM binutils.
relibc gates per-target_os: header `sys` submodules (where O_*/CLOCK_*/AT_*/
EPOLL_* live), `auxv_defs`, the `termios` struct + cfspeed accessors, the
passwd/group/shadow field SEPARATOR, PAGE_SIZE, SHM_PATH, the pty path
selection, and the sigevent struct — all linux/redox only. nonos targets
generic x86_64 SysV, so point these at the existing linux definitions via a
`#[cfg(any(target_os = "linux", target_os = "nonos"))]` broaden. Idempotent
graft patches; the vendored relibc stays gitignored.
These need nonos-specific behavior, not a linux reuse: `ioctl` returns -1
(no tty ioctls) and gates the now-unused ResultExt import off; the TCB gets
`OsSpecific = ()`, the shared `os_new`, and a no-op `os_arch_activate`
(the kernel sets no per-thread FS base, and errno is a Sync static, so TLS
activation is a no-op); `ld_so::init` is enabled for nonos with `tp = 0`
instead of the linux ARCH_GET_FS arch_prctl (no `syscall!` macro on nonos).
With this, librelibc.a compiles and post-processes clean for nonos.
CARGOFLAGS applies to every relibc sub-crate build, but crt0/crti/crtn/ld_so
have no `no_trace` feature, so `--features no_trace` aborts their object
builds. `--no-default-features` alone already excludes check_against_libc_crate
(the real fix); trace logging is a runtime no-op without a registered logger.
With this, libc.a + crt0.o/crti.o/crtn.o all build for nonos.
The kernel enters a capsule at the ELF entry with all GPRs zeroed and an
empty stack — no argc/argv/envp/auxv, unlike a SysV process start. relibc's
generic x86_64 _start does `mov rdi, rsp` and hands that straight to
relibc_start_v1, which would read garbage argc off the empty stack. Add a
nonos _start that synthesizes argc=0, an empty argv/envp, and an AT_NULL
auxv (16-byte aligned: entry rsp ≡ 8 mod 16) before calling relibc_crt0,
and exclude nonos from the generic arm. crt0.o builds; _start disassembles
to the synthesized frame.
build-sysroot.sh grafts the relibc nonos backend, builds the static
artifacts (libc.a + crt0/crti/crtn objects) and cbindgen headers for
x86_64-unknown-nonos, and stages them into toolchain/nonos-c/sysroot/
{lib,include}. Static-link only: libc.so/ld.so are not built (the capsule
link is fully static), so it builds the specific targets, not `all`. The
sysroot itself is a build artifact and gitignored.
Replace -fno-PIC with -fPIE so compiled objects carry PIC relocs
compatible with -pie linking. Remove -static, which conflicts with
-static-pie passed at link time. Add -fno-builtin to prevent clang
from redirecting write() to _write() (a reserved name libc.a does
not export). Add -fuse-ld=lld so clang finds lld when used as a
combined compile+link driver in future contexts.
Adds the first clang-built C capsule to NONOS: capsule_c_proof prints
[C-PROOF] PASS via write(1,...) using relibc from the nonos-c sysroot.

Files added:
- userland/capsule_c_proof/src/main.c: minimal C entry point
- userland/capsule_c_proof/Capsule.mk: slug c-proof, ports 4504/4505,
  caps 0x19; uses CAPSULE_PREBUILT_BIN so capsule.mk stages and signs
  the clang ELF rather than invoking cargo
- Makefile: include line next to capsule_std_proof

The nonos-mk submodule is bumped to pick up capsule-c.mk (new
build rule). Build artifacts are gitignored via .gitignore.
Mirror capsule_std_proof exactly for the new c_proof capsule:
- src/userspace/capsule_c_proof/{mod,embed,spawn}.rs — embed/spawn mirror
- src/userspace/mod.rs — capsule_c_proof module declaration
- Cargo.toml — nonos-capsule-c-proof feature + microkernel-c-proof profile
- src/userspace/init/entry.rs — feature-gated run_c_proof() + call in run_init()

Service port 4504/4505. Empty-slice fallbacks keep nonos-mk-check clean with
the feature off; signed trust artifacts are out of scope (controller handles).
Phase 0 boot-gate infrastructure for the clang/relibc C capsule:
nonos-mk-cproof-prod builds the microkernel-c-proof kernel (verified-spawn,
proof-io + c_proof embedded); nonos-mk-boot-cproof / nonos-mk-cproof-test
run tests/boot/cproof.sh, which boots under QEMU/hvf and greps [C-PROOF] PASS.
Kernel builds clean under nonos-production with c_proof embedded; the boot
gate itself is pending a valid per-capsule ZK attestation trailer (see
CONTEXT.md — capsule attestation is mid NZKCAPS1->NZKCAPS2 migration).
relibc's full startup is TLS/pthread-coupled and faults on nonos (no FS
base): ld_so::static_init panics on the empty auxv, and init_array's
pthread::init needs a TCB. The plan defers TLS to Phase 3, so for the
Phase-0 C-capsule proof: ld_so::init is a no-op for nonos (tp=1 routes to
the "TCB already present" branch), Tcb::current() returns None (no FS-base
read to fault on), and the nonos crt0 _start calls main directly then
MkExit — bypassing relibc_start_v1's TLS/stdio/pthread init. relibc's libc
functions (write -> MkDebug) still work; full relibc startup lands when TLS
is brought up.
MkDebug requires Capability::Debug (cap_table/mk.rs), so the write(1,...)
serial marker needs it — add Debug to the hand-synced cap lists (spawn.rs
requested_caps + Capsule.mk 0x19 -> 0x119). Add a cfg(nonos-dev-unverified-
capsules) spawn variant using CapsuleSpec/capsule_spawn::spawn so the
smoketest can boot c_proof without the (mid-migration) ZK attestation; the
verified spawn_verified path is retained for nonos-production.
microkernel-c-proof-smoketest (core + nonos-dev-unverified-capsules +
c-proof) and nonos-mk-cproof-smoke-{prod,test} prove the clang/relibc C
capsule boots and runs without ZK attestation, which is mid NZKCAPS1->2
migration. The harness builds via CPROOF_PROD_TARGET; run with NONOS_DEV=1.
Phase-0 used a minimal crt0 that bypassed relibc_start_v1 (no allocator),
which is insufficient for Phase 1 (malloc/stdio). Restore the crt0 ->
relibc_crt0 -> relibc_start_v1 path and no-op pthread::init for nonos (it
ended in Tcb::current().expect_notls(), which panics with no TCB). Now the
full startup runs on nonos — alloc_init (heap), io_init (stdio), environ —
with TLS/pthread skipped (deferred to Phase 3). c_proof re-proven through
full startup: [C-PROOF] PASS.
Exerciser capsule that tests malloc (1MB + 4KB) and clock_gettime on the
relibc runtime. Emits [RELIBC-TEST] PASS on success. Ports 4506/4507,
caps 0x179 (CoreExec|IPC|Memory|Crypto|FileSystem|Debug).
Mirrors capsule_c_proof pattern: embed.rs with empty-slice fallbacks
when feature is off, spawn.rs with both verified and unverified-capsules
variants, requested_caps() = CoreExec|IPC|Memory|Crypto|FileSystem|Debug
(0x179). Ports 4506/4507. run_relibc_test() added to init/entry.rs after
run_c_proof(); no-ops when feature is absent.
nonos-capsule-relibc-test = [] capsule embed feature. Profile
microkernel-relibc-test-smoketest = microkernel-core +
nonos-dev-unverified-capsules + nonos-capsule-relibc-test for the
unverified smoke gate. Makefile: include capsule_relibc_test/Capsule.mk,
nonos-mk-relibc-smoke-prod, nonos-mk-relibc-smoke-test targets.
Wire clock_gettime, clock_getres, gettimeofday, and nanosleep to the
MTMS/MYLD syscalls.  now_ms() converts the ms-since-epoch i64 return
into sec/nsec/usec fields; nanosleep busy-yields via MK_YIELD until the
deadline.  suseconds_t cast applied: nonos target resolves it as c_int
(not c_long) per the #[cfg(not(target_os="linux"))] branch.
Implement getrandom via MK_CRYPTO_RANDOM (CRND syscall): chunks the
buffer in ≤4096-byte slices (kernel cap), returns Err(EIO) on negative
return.  Flip mprotect/madvise/msync/mlock/munlock/mlockall/munlockall
from ENOSYS to Ok(()) — they are advisory on NONOS; mremap stays ENOSYS.
The kernel syscall path (arch/x86_64/asm/syscall.S) saves/restores
rbp/r11/rcx/r10/r9/r8/rax but NOT rdi/rsi/rdx — it overwrites them in the
arg shuffle and never restores, so a syscall clobbers rdi/rsi/rdx in
addition to the hardware rcx/r11. syscall0/1/2 only declared rcx/r11, so the
compiler kept caller values (e.g. clock_gettime's `tp` in rsi across the
MkTimeMillis call) → wild write to a kernel address (#PF). Declare the
clobbers. (write() survived only by not holding a reg across its syscall.)
cbindgen.globdefs.toml had no nonos mapping, so the reused-linux constants
(broadened to any(linux, nonos)) emitted only `#if defined(__linux__)` and
were invisible to C compiled with -D__nonos__ (e.g. CLOCK_MONOTONIC). Add
the target_os=nonos -> __nonos__ mapping so the headers guard them with
`defined(__linux__) || defined(__nonos__)`.
Exposes the mk_ipc_call syscall (tag "MICL") so the nonos Pal can
reach the vfs capsule over IPC. syscall6 already handles the full
6-arg ABI (endpoint, req_ptr, req_len, resp_ptr, resp_len, timeout_ms).

Refs: Phase 1 Task 1.1
fs.rs provides: 20-byte VFS wire-protocol header builder + syscall6
round-trip (vfs_call); a 64-slot static fd table behind SyncUnsafeCell
(fd_alloc/fd_vfs/fd_free/fd_dup/fd_set); VFS op and flag constants.
apply.sh now copies fs.rs into the vendored relibc tree.

Refs: Phase 1 Task 1.1
Wire write(), close(), read(), and dup2() through the new fs module:
write routes fd>2 to OP_WRITE; close sends OP_CLOSE and frees the slot;
read issues OP_READ capped at 65536 bytes; dup2 reuses the same vfs_fd
via fd_set. Imports and `mod fs` declaration added.

Refs: Phase 1 Task 1.2
openat maps O_CREAT/O_TRUNC/O_APPEND to vfs flags and allocates a relibc
fd via fd_alloc. fstatat (path-only; fd-based returns ENOSYS) parses the
OP_STAT response for st_size and st_mode. faccessat probes existence via
OP_STAT. mkdirat sends OP_MKDIR.

Refs: Phase 1 Task 1.2
unlinkat sends OP_UNLINK with pid+path_len+path. renameat2 sends
OP_RENAME with pid+old_len+old+new_len+new; ignores rename flags
(no RENAME_EXCHANGE/NOREPLACE at vfs layer yet).

Refs: Phase 1 Task 1.2
dup2 previously overwrote the fd-table slot at fildes2 without closing
whatever was open there, leaking the displaced vfs fd. POSIX requires
closing fildes2 first; the fix checks fd_vfs(fildes2) and issues
OP_CLOSE via Self::close() (which already handles the fd-free) before
installing the new mapping. The fildes==fildes2 identity guard that
returns early was already present.

vfs_call only asserts ret>=24 (status present). Callers that consume
bytes past the status field could silently get zeroed data on a
truncated response. Added per-caller minimum-length guards using the
data_len return value: OP_WRITE/OP_OPEN require data_len>=4 (ret>=28),
OP_STAT in fstatat requires data_len>=12 (ret>=36). All three return
Err(Errno(EIO)) on underrun. OP_READ is unchanged (data_len=0 is a
valid EOF). faccessat uses OP_STAT but only reads status so no guard
needed there.

Build: librelibc.a x86_64-unknown-nonos release — 0 errors, 13.70s.
relibc's stdio locks the FILE mutex on fwrite/fread; the mutex calls
Sys::futex_wake().unwrap() (sync/mod.rs:103). Our futex stubs returned
ENOSYS → unwrap panic ("Errno(38)") on the first fwrite. nonos is
single-threaded (no real contention, no per-thread TLS), so futex_wait
returns Ok (spurious-wakeup; caller re-checks the atomic) and futex_wake
returns Ok(0) (no waiters). The plan's "futex→ENOSYS until Phase 3" breaks
relibc's locking; a no-op is the correct single-threaded behavior.
Extend the exerciser to create+write+read a vfs file and verify the bytes,
and add vfs (+ramfs+proof-io, nonos-zk-rollout to tolerate their stale
attestation trailers) to microkernel-relibc-test-smoketest so the vfs
service is running. Proven: [RELIBC-TEST] PASS with fs+malloc+clock under
NONOS_DEV=1 nonos-mk-relibc-smoke-test.
Add MK_THREAD_SPAWN syscall tag and an x86_64 rlct_trampoline in
lowlevel.rs. The trampoline pops the seven words relibc's pthread_create
arranges on the new thread stack (entry fn + 6 args) and calls the entry
exactly as the linux clone inline-asm child path does, then falls through
to MK_EXIT. De-stub rlct_clone: call syscall2(MK_THREAD_SPAWN,
rlct_trampoline, stack) with the pre-built stack; return OsTid {} on
success (nonos OsTid has no fields — thread_id is linux-only).

Concerns: pthread_create panics before reaching rlct_clone on nonos
because Tcb::current() is patched to return None and pthread::init() is
a no-op; per-thread TLS is Phase-3 work.
De-stub three remaining thread helpers:
- sched_yield: syscall0(MK_YIELD); Ok(())
- exit_thread: direct syscall1(MK_EXIT, 0) + loop{} (was delegating
  through Self::exit; now explicit so the call site is unambiguous)
- current_os_tid: calls MK_GETPID and returns OsTid {} — all threads
  report the process pid in Phase 1; per-thread tid requires Phase 3
  (kernel must set FS base per thread and expose a gettid syscall).
  OsTid for target_os="nonos" has no fields, so the pid is intentionally
  discarded.
relibc's lseek backend needs a server-side seek op so the NOVF
filesystem capsule can track per-fd positions independently of
read/write advancement. Adds OP_SEEK=11 (SEEK_SET/CUR/END, POSIX
semantics: past-EOF allowed, negative result is EINVAL) plus a
StoreError::Invalid variant, and hardens store.read to clamp pos
before slicing so a post-seek pos>len yields a 0-byte EOF read
instead of a panic.
Adds OP_PREAD=12 and OP_PWRITE=13 to the capsule_vfs NOVF protocol.
Unlike OP_READ/OP_WRITE, these operations take an explicit byte offset
and leave the fd's pos field untouched — POSIX pread/pwrite semantics.
This is required by the relibc nonos Pal so that pread(2)/pwrite(2) can
be forwarded to the VFS capsule without disturbing the sequential file
position maintained by the fd, enabling independent concurrent access
patterns that relibc expects.
De-stubs the three positional-IO Pal methods in the nonos relibc backend.
The capsule_vfs server already landed OP_SEEK=11, OP_PREAD=12, OP_PWRITE=13
over the NOVF protocol; without the matching client side, C code that calls
lseek/pread/pwrite would get ENOSYS, breaking fseek and any positional read.

Wire format follows the confirmed server layout:
- lseek: payload [pid:4][vfs_fd:4][whence:2][reserved:2][offset:8]; response body = u64 new_pos
- pread:  payload [pid:4][vfs_fd:4][offset:8][max:4]; response body = raw data
- pwrite: payload [pid:4][vfs_fd:4][offset:8][data...]; response body = u32 written

ftruncate stays ENOSYS (no server op, not needed for the gate).
Extends the relibc-test boot exerciser with a raw open/lseek/pread/pwrite
round-trip over the vfs NOVF service, validating Phase 2 Deliverable A
(VFS OP_SEEK/OP_PREAD/OP_PWRITE + the matching relibc clients) end-to-end.

Asserts the POSIX positional-IO invariant the new ops exist to provide:
lseek SET/CUR/END move the fd offset, while pread/pwrite at an explicit
offset leave it untouched (proven by the interleaved read that must still
see the byte at the lseek-advanced position). Gate: NONOS_DEV=1
nonos-mk-relibc-smoke-test -> [RELIBC-TEST] PASS.
write/openat/fstatat checked the response data_len (returning EIO on a
short body) before checking the NOVF status field. On a server error the
body is empty, so a real errno (EACCES, ENOSPC, ENOENT, ...) was masked as
EIO, breaking errno-based error handling for the C callers. Reorder so the
status<0 check runs first, matching read/lseek/pread/pwrite. Error-path
only; no happy-path behavior change.
Sockets need a per-handle nonblock bit before connect/recv/send can
return EAGAIN instead of blocking. Adds OP_SETFLAGS(13)/OP_GETFLAGS(14)
to read/write FLAG_NONBLOCK on each socket; the bit is stored as a bool
on Socket (which stays Copy) and initialised to false in Socket::new.
OP_12 is intentionally left free for the upcoming OP_POLL handler.
recv_socket previously discarded the transport errno via map_err(|_| E_NO_TRANSPORT),
masking net.tcp E_RX_EMPTY(11) and net.udp E_RX_EMPTY(8) as the generic E_NO_TRANSPORT(6).
The relibc PAL (Phase 2B.5) needs a distinct errno to translate to POSIX EAGAIN; this adds
E_WOULD_BLOCK=11 (free slot in net.sockets errno space) and routes empty-rx transport errors
to it via a per-transport map_recv_err helper. Non-empty transport errors still surface as
E_NO_TRANSPORT. The Mixnet arm is unchanged (out of scope). The server returns E_WOULD_BLOCK
unconditionally on empty; blocking-vs-retry is the relibc client's responsibility (2B.5).
Adds a per-socket receive timeout so the server bounds the wait instead
of returning E_WOULD_BLOCK immediately on an empty socket. When timeout_ms
is set via OP_SETTIMEOUT the recv handler retries up to the deadline using
mk_yield before giving up; timeout_ms == 0 preserves the existing
single-attempt behaviour.
OP_POLL (Task 2B.3b) will probe the transport and buffer any bytes it
pulls so a later recv can return them without re-entering the deadline
loop. This task adds that buffer: a static BTreeMap keyed by (pid,handle)
storing a Vec<u8> per socket, plus a take() drain that recv calls before
the deadline loop. The stash cannot live on Socket because Socket is
Copy and Vec<u8> is not. No put() is added here — that lands in 2B.3b
— so until then take() always returns 0 and the drain is inert-but-
wired. take() is called by recv, so no dead_code warning fires.
POLLIN buffers a transport probe into the per-(pid,handle) rx stash so
a subsequent OP_RECV returns those bytes without hitting the network
again. POLLOUT reads net.tcp OP_STATE==Established(3) for Stream
sockets; Datagram is always writable. A bounded wait loop via
timeout_ms yields until the deadline; timeout_ms==0 returns after the
first poll_once.

revents is a u16 LE written at tx[20..22] with payload_len=2.
OP_POLL=12 fills the reserved slot between OP_SETSOCKOPT(11) and
OP_SETFLAGS(13).
Greenfield socket backend for Phase 2B. Lays the shared runtime
that every later socket method reuses: a 64-slot fd table at the
disjoint high range (base 256) so the Phase-1 vfs fd table (fds
3..66) is untouched; an nskt_call IPC helper speaking the NSKT
wire protocol (magic 0x4E534B54, endpoint 4460, errno@8-9 in
response, body@20) to net.sockets; and a POSIX errno map for the
net.sockets-specific error codes.

De-stubs socket() (OP_SOCKET=2, AF_INET(2)→wire family 4) and
wires close() dispatch in mod.rs to route socket fds through
socket::close_fd (OP_CLOSE=9) before the vfs path.
Parse sockaddr_in (family@0 host u16, port@2 big-endian→host, ip@4..7
octets), reject null address or address_len < 16 (EINVAL), non-AF_INET
family (EAFNOSUPPORT), missing fd table entry (EBADF). Build OP_CONNECT
payload: handle@0 LE u32, ip@4 four octets, port@8 LE u16. On success
from net.sockets, a non-blocking socket returns EINPROGRESS per POSIX
so the caller can poll(POLLOUT) for ESTABLISHED; blocking returns Ok(0).
bind/listen/accept and all other PalSocket methods remain ENOSYS — NetSurf
needs the client path only (socket→connect→send→recv).
Add sock_send/sock_recv as public helpers in socket.rs.  sock_send
builds OP_SEND payload with handle@0 LE + data@4, capped at 65536
bytes, and treats E_OK as "all bytes accepted".  sock_recv sends an
OP_RECV request with handle@0 LE and reads the response body from
resp[20..20+n], bounding n by min(n, buf.len()) so no overread is
possible; E_WOULD_BLOCK surfaces as EAGAIN automatically via
map_errno.

sendto/recvfrom in the PalSocket impl are de-stubbed to call these
helpers after constructing a safe slice from the raw pointer args
(unsafe confined to the from_raw_parts/from_raw_parts_mut calls).

write/read in mod.rs now check is_socket_fd first: in write after
the fildes==1||2 MK_DEBUG block and before fd_vfs; in read before
fd_vfs.  This lets C send/recv (which route through sendto/recvfrom
with a null addr) flow correctly over the NSKT cap.
F_SETFL sets the per-fd nonblock bit in socket_rt (consumed by
connect → EINPROGRESS) and pushes OP_SETFLAGS to net.sockets with
handle@0 / flags@4 where FLAG_NONBLOCK=1 (not POSIX 0x800).
F_GETFL returns O_RDWR|O_NONBLOCK when the bit is set.
Non-socket fds still return ENOSYS via the same fall-through.
poll/select in relibc route exclusively via PalEpoll — there is no
Pal::poll. This builds the first half of that path: a fixed instance
registry (epoll_rt.rs, fd base 512, 16 instances × 32 registrations,
all Copy for const-init) plus epoll_create1 and epoll_ctl (ADD/MOD via
set, DEL via remove). The fd base 512 is disjoint from sockets
(256..319) and vfs (3..66). close() dispatches epoll fds to
epoll_rt::destroy before the vfs path. epoll_pwait remains ENOSYS;
the for_each accessor is defined here for the follow-on 2B.5e-2 task
(epoll_pwait→net.sockets OP_POLL).
For each fd registered in the epoll instance, issues an immediate
OP_POLL (timeout_ms=0) to net.sockets, translates EPOLLIN/EPOLLOUT
↔ POLLIN(1)/POLLOUT(2) wire bits, and writes typed epoll_event results
up to maxevents. The outer wait loop exits when events are ready,
timeout==0 (immediate), or the millisecond deadline is reached; negative
timeout blocks until ready. Yields between passes via MK_YIELD.

Closes the last stub in the relibc epoll surface needed for C poll()
and select() to function over NONOS sockets.
Adds the C socket exerciser capsule and all kernel wiring needed to
embed and spawn it in a microkernel-cnet-smoketest profile. The capsule
exercises the full socket→fcntl(O_NONBLOCK)→connect→poll→write→poll→
read→close path against a TCP echo peer at 10.0.2.200:7, proving the
relibc socket backend (Phase 2B.5) is reachable from C userland.

Cap hand-sync: Capsule.mk CAPSULE_REQUIRED_CAPS=0x119 matches
spawn.rs requested_caps() = CoreExec|IPC|Memory|Debug (no Crypto/FS).
Ports 4508/4509 are unoccupied. run_c_net() fires after
spawn_plan::spawn_network() so net.sockets is live before c_net connects.
Signing, keys, boot harness, and QEMU run are deferred to C2.

Closes Phase-C1 of the NetSurf-on-NONOS relibc arc.
The c_net exerciser spawns alongside the net stack, so its first connect
races DHCP/net.tcp bring-up. Retry connect (60x, 500ms via poll(NULL,0))
until it returns EINPROGRESS, letting the stack acquire an IP first. Adds
the nonos-mk-cnet-smoke-test target driving tests/boot/cnet.sh.

make NONOS_DEV=1 nonos-mk-cnet-smoke-test -> [C-NET] PASS: relibc
socket->fcntl(O_NONBLOCK)->connect->poll(POLLOUT)->send->poll(POLLIN)->
recv echo->close, proven E2E under QEMU.
OP_POLL's POLLIN probe buffers bytes in the per-(pid,handle) rx stash. If a
client polls a socket ready then closes it without ever calling recv, the
stashed Vec was orphaned in the BTreeMap forever (handles are monotonic, so
the key is never revisited) — a bounded-per-socket but unbounded-over-lifetime
leak. close now calls stash::clear after SOCKETS.close. Found by the final
whole-branch review; no correctness/misdelivery impact, purely the leak.
epoll_pwait was safe for every finite timeout, but maxevents<=0 combined with
an infinite timeout (-1) could spin forever: max=0 pins the ready count at 0
and neither timeout exit fires. POSIX mandates EINVAL for maxevents<=0; add the
guard at entry. No real caller (poll/select pass a positive batch) hit this;
found by the final whole-branch review.
Phase-4 display-shim de-risk begins with a pure-C surface probe that
drives the verified surface/compositor ABI before vendoring NetSurf.
Hand-rolled inline-asm syscall wrapper marks rdi/rsi/rdx/r10/r8/r9 as
in-out operands plus rcx/r11/memory clobbers, because the NONOS kernel
does not preserve the arg registers across a syscall (the relibc
39f3aeb3c landmine). SurfaceDescriptor mirrors the 40-byte
wire.toml layout (byte_len@16, base_va@24, flags@32).
NCMP request/reply builders for the four ops the probe needs:
OP_DISPLAY_INFO (validates format==1 and nonzero dims before trust),
OP_SCENE_SUBMIT (32B body, handle u64@0 so x starts @8), OP_DAMAGE_COMMIT
(16B), OP_SCENE_REMOVE (8B). Compositor port resolved by name via
mk_service_lookup, never hardcoded. Status checked on every reply.
main() runs the full surface lifecycle: lookup compositor, OP_DISPLAY_INFO,
mmap the ARGB8888 buffer, register+share the surface, paint a known
red/green gradient with volatile 32-bit writes, then submit+damage the
full-screen scene. Emits [NSFB-PROBE] PASS via write(1,...) -> MkDebug on
success or [NSFB-PROBE] FAIL:<step>:<rc> on any failed step, then stays
idle-resident (yield loop) so the boot harness can screendump the gradient.
Capsule.mk caps = 0x1119 (CoreExec|IPC|Memory|Debug|GraphicsSurfaceCreate),
hand-synced target for the kernel-side requested_caps() in Task 4.0b.
Phase 4 Task 4.0b wires the userland nsfb_probe C capsule into the
kernel so the framebuffer probe can be embedded and spawned. This
adds the kernel-side mirror (embed bytes + verified spawn path),
mirroring capsule_c_proof. The unverified smoke path and the init
call site land in follow-up commits to keep each file change small.

Each file stays within the 75-line per-file change limit; spawn.rs
is built across two commits because the full builder exceeds it.
The smoke profile spawns nsfb_probe through the legacy unverified
path (CapsuleSpec) while the NZKCAPS2 capsule-attestation migration
is owned elsewhere, matching c_proof/c_net. Completes the spawn
builder begun in the scaffold commit.
run_init brings up the compositor via spawn_display_core, then the
virtio-gpu driver via spawn_drivers. nsfb_probe registers a surface
and reads OP_DISPLAY_INFO, so it must spawn after both the
compositor is serving and the GPU framebuffer exists. Placing the
gated run_nsfb_probe() right after spawn_drivers satisfies that
ordering; it is a no-op when the capsule feature is off.
Adds the nonos-capsule-nsfb-probe embed flag plus two composing
profiles: microkernel-nsfb-probe (verified) and the unverified
microkernel-nsfb-probe-smoketest. Both pull in the compositor and
virtio-gpu driver so a framebuffer exists for the probe to present
to; the smoke profile adds nonos-zk-rollout so the graphics
capsules' real ZK trailers stay non-fatal under the unverified
spawn path, matching the cnet/relibc smoketests.
The probe spawns right after spawn_drivers(), before the compositor is
guaranteed to be serving. mk_service_lookup("compositor") returns
ERRNO_NOENT immediately when the endpoint is not yet installed, and the
first OP_DISPLAY_INFO call blocks only up to the kernel's 5000ms timeout
then returns ERRNO_TIMEDOUT while the compositor is still in
wait_for_setup(). Both failure modes hit the probe on the first attempt
with no retry, so it would FAIL+hang on a transient startup race.

Wrap lookup + OP_DISPLAY_INFO in a bounded retry (60 attempts, yielding
between tries) mirroring capsule_c_net's connect-retry. Stale replies to
timed-out display_info requests are dropped by the kernel's reply
no-call guard, so re-issuing the call cannot desync later submit/damage
replies. FAIL is emitted only after the bound is exhausted, preserving
the original lookup/display_info markers; the success path is unchanged.
Add nonos-mk-nsfbprobe-smoke-prod/-test and include the nsfb_probe
Capsule.mk so $(nsfb-probe_ARTIFACTS) resolves. The prod target depends
on the compositor, virtio-gpu, and nsfb_probe artifacts the
microkernel-nsfb-probe-smoketest profile embeds, so a source change to
any of them auto-rebuilds and re-signs before the kernel link (the
re-sign landmine). The test target hands the harness the prod target.
surface_descriptor is passed by pointer to SYS_SURFACE_REGISTER and is
the template Phase 4.2 libnsfb/src/surface/nonos.c will copy; a layout
drift would silently mis-deliver the frame buffer address to the kernel.
Four _Static_assert checks lock sizeof (40) and the three u64 field
offsets (byte_len=16, base_va=24, flags=32) as compile errors.
This pull request has changes conflicting with the target branch.
  • .gitignore
  • Makefile
  • nonos-mk
  • src/userspace/init/entry.rs
  • userland/capsule_net_sockets/src/clients/tcp.rs
  • userland/capsule_net_sockets/src/protocol/mod.rs
  • userland/capsule_net_sockets/src/protocol/ops.rs
  • userland/capsule_net_sockets/src/server/handlers/dispatch.rs
  • userland/capsule_net_sockets/src/server/handlers/mod.rs
  • userland/capsule_net_sockets/src/server/handlers/poll.rs
  • userland/capsule_net_sockets/src/server/handlers/recv.rs
  • userland/capsule_vfs/src/protocol/mod.rs
  • userland/capsule_vfs/src/protocol/types.rs
  • userland/capsule_vfs/src/server/dispatch.rs
  • userland/capsule_vfs/src/server/handlers/mod.rs
  • userland/capsule_vfs/src/server/handlers/seek.rs
  • userland/capsule_vfs/src/server/handlers/util.rs
  • userland/capsule_vfs/src/store/fdtable.rs
  • userland/capsule_vfs/src/store/fdtable/seek.rs
  • userland/capsule_vfs/src/store/fdtable/types.rs
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin +refs/pull/27/head:nonos-sync/gh-259
git switch nonos-sync/gh-259
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
NON-OS/nonos-micro-kernel!27
No description provided.