Proposal: Go Language Support via Custom GOOS
Running Go programs natively on capOS by implementing a GOOS=capos target
in the Go runtime.
Motivation
Go is the implementation language of CUE, the configuration language planned for system manifests. Beyond CUE, Go has a large ecosystem of systems software (container runtimes, network tools, observability agents) that would be valuable to run on capOS without rewriting.
The userspace-binaries proposal (Part 3) places Go in Tier 4 (“managed
runtimes, much later”) and suggests WASI as the pragmatic path. This proposal
explores the native alternative: a custom GOOS=capos that lets Go programs
run directly on capOS hardware, without a WASM interpreter in between.
Why Go is Hard
Go’s runtime is a userspace operating system. It manages its own:
- Goroutine scheduler — M:N threading (M OS threads, N goroutines), work-stealing, preemption via signals or cooperative yield points
- Garbage collector — concurrent, tri-color mark-sweep, requires write barriers, stop-the-world pauses, and memory management syscalls
- Stack management — segmented/copying stacks with guard pages, grow/shrink on demand
- Network poller — epoll/kqueue-based async I/O for
net.Conn - Memory allocator — mmap-based, spans, mcache/mcentral/mheap hierarchy
- Signal handling — goroutine preemption, crash reporting, profiling
Each of these assumes a specific OS interface. The Go runtime calls ~40 distinct syscalls on Linux. capOS currently has 2.
Syscall Surface Required
The Go runtime’s Linux syscall usage, grouped by subsystem:
Memory Management (critical, blocks everything)
| Go runtime needs | Linux syscall | capOS equivalent |
|---|---|---|
| Heap allocation | mmap(MAP_ANON) | FrameAllocator cap + page table manipulation |
| Heap deallocation | munmap | Unmap + free frames |
| Stack guard pages | mmap(PROT_NONE) + mprotect | Map page with no permissions |
| GC needs contiguous arenas | mmap with hints | Allocate contiguous frames, map contiguously |
| Commit/decommit pages | madvise(DONTNEED) | Unmap or zero pages |
capOS needs: A sys_mmap-like capability or syscall that can:
- Map anonymous pages at arbitrary user addresses
- Set per-page permissions (R, W, X, none)
- Allocate contiguous virtual ranges
- Decommit without unmapping (for GC arena management)
This could be a VirtualMemory capability:
interface VirtualMemory {
# Map anonymous pages at hint address (0 = kernel chooses)
map @0 (hint :UInt64, size :UInt64, prot :UInt32) -> (addr :UInt64);
# Unmap pages
unmap @1 (addr :UInt64, size :UInt64) -> ();
# Change permissions on mapped range
protect @2 (addr :UInt64, size :UInt64, prot :UInt32) -> ();
# Decommit (release physical frames, keep virtual range reserved)
decommit @3 (addr :UInt64, size :UInt64) -> ();
}
Threading (critical for goroutines)
| Go runtime needs | Linux syscall | capOS equivalent |
|---|---|---|
| Create OS thread | clone(CLONE_THREAD) | Thread cap or in-process threading primitive |
| Thread-local storage | arch_prctl(SET_FS) | Per-thread FS base (kernel sets on context switch) |
| Block thread | futex(WAIT) | Futex cap or kernel-side futex |
| Wake thread | futex(WAKE) | Futex cap |
| Thread exit | exit(thread) | Thread exit syscall |
capOS needs: Threading support within a process. Options:
Option A: Kernel threads. The kernel manages threads (multiple execution contexts sharing one address space). Each thread has its own stack, register state, and FS base, but shares page tables and cap table with the process. This is what Linux does and what Go expects.
Option B: User-level threading. The process manages its own threads (like green threads). The kernel only sees one execution context per process. Go’s scheduler already does M:N threading, so it could work with a single OS thread per process — but the GC’s stop-the-world relies on being able to stop other OS threads, and the network poller blocks an OS thread.
Option A is simpler for Go compatibility. Option B is more capability-aligned (threads are a process-internal concern) but requires Go runtime modifications.
Synchronization
| Go runtime needs | Linux syscall | capOS equivalent |
|---|---|---|
| Futex wait | futex(FUTEX_WAIT) | Futex authority cap, ABI selected by measurement |
| Futex wake | futex(FUTEX_WAKE) | Futex authority cap, ABI selected by measurement |
| Atomic compare-and-swap | CPU instructions | Already available (no kernel support needed) |
Futexes are a kernel primitive (block/wake on a userspace address). capOS should expose futex authority through a capability from the start. The ABI is still a measurement question: generic capnp/ring method if overhead is close to a compact path, otherwise a compact capability-authorized operation.
Time
| Go runtime needs | Linux syscall | capOS equivalent |
|---|---|---|
| Monotonic clock | clock_gettime(MONOTONIC) | Timer cap .now() |
| Wall clock | clock_gettime(REALTIME) | Timer cap or RTC driver |
| Sleep | nanosleep or futex with timeout | Timer cap .sleep() or futex timeout |
| Timer events | timer_create / timerfd | Timer cap with callback or poll |
Timer cap already planned. Go needs monotonic time for goroutine scheduling
and wall time for time.Now().
I/O
| Go runtime needs | Linux syscall | capOS equivalent |
|---|---|---|
| Network I/O | epoll_create, epoll_ctl, epoll_wait | Async cap invocation or poll cap |
| File I/O | read, write, open, close | Namespace + Store caps (via POSIX layer) |
| Stdout/stderr | write(1, ...), write(2, ...) | Console cap |
| Pipe (runtime internal) | pipe2 | IPC caps or in-process channel |
Go’s network poller (netpoll) is pluggable per-OS — each GOOS provides
its own implementation. For capOS, it would use async capability invocations
or a polling interface over socket caps.
Signals (for preemption)
| Go runtime needs | Linux syscall | capOS equivalent |
|---|---|---|
| Goroutine preemption | tgkill + SIGURG | Thread preemption mechanism |
| Crash handling | sigaction(SIGSEGV) | Page fault notification |
| Profiling | sigaction(SIGPROF) + setitimer | Profiling cap (optional) |
Go 1.14+ uses asynchronous preemption: the runtime sends SIGURG to a
thread to interrupt a long-running goroutine. On capOS, alternatives:
- Cooperative preemption only. Go inserts yield points at function prologues and loop back-edges. This works but means tight loops without function calls won’t yield. Acceptable for initial support.
- Timer interrupt notification. The kernel notifies the process (via a cap invocation or a signal-like mechanism) when a time quantum expires. The notification handler in the Go runtime triggers goroutine preemption.
Implementation Strategy
Phase 1: Minimal GOOS (single-threaded, cooperative)
Fork the Go toolchain, add GOOS=capos GOARCH=amd64. Implement the minimum
runtime changes:
What to implement:
osinit()— read Timer cap from CapSet for monotonic clocksysAlloc/sysFree/sysReserve/sysMap— translate to VirtualMemory capnewosproc()— stub (single OS thread, M:N scheduler still works with M=1)futexsleep/futexwake— spin-based fallback (no real futex yet)nanotime/walltime— Timer capwrite()(for runtime debug output) — Console capexit/exitThread— sys_exitnetpoll— stub returning “nothing ready” (no async I/O)
What to stub/disable:
- Signals (no SIGURG preemption, cooperative only)
- Multi-threaded GC (single-thread STW is fine initially)
- CGo (no C interop)
- Profiling
- Core dumps
Deliverable: GOOS=capos go build ./cmd/hello produces an ELF that
runs on capOS, prints “Hello, World!”, and exits.
Estimated effort: ~2000-3000 lines of Go runtime code (mostly in
runtime/os_capos.go, runtime/sys_capos_amd64.s,
runtime/mem_capos.go). Reference: runtime/os_js.go (WASM target) is
~400 lines; runtime/os_linux.go is ~700 lines. capOS sits between these.
Phase 2: Kernel Threading + Futex
Add kernel support for:
- Multiple threads per process (shared address space, separate stacks)
- Futex authority capability and measured wait/wake ABI
- FS base per-thread (for goroutine-local storage)
Update Go runtime:
newosproc()creates a real kernel threadfutexsleep/futexwakeuse the selected futex capability ABI- GC runs concurrently across threads
- Enable
GOMAXPROCS > 1
Deliverable: Go programs use multiple CPU cores. GC is concurrent.
Phase 3: Network Poller
Implement runtime/netpoll_capos.go:
- Register socket caps with the poller
- Use an async notification mechanism (capability-based
poll()or notification cap) net.Dial(),net.Listen(),http.Get()work
This depends on the networking stack being available as capabilities.
Deliverable: Go HTTP client/server runs on capOS.
Phase 4: CUE on capOS
With Go working, CUE runs natively. This enables:
- Runtime manifest evaluation (not just build-time)
- Dynamic service reconfiguration via CUE expressions
- CUE-based policy enforcement in the capability layer
Kernel Prerequisites
| Prerequisite | Roadmap Stage | Why |
|---|---|---|
| Capability syscalls | Stage 4 (sync path done) | Go runtime invokes caps (VirtualMemory, Timer, Console) |
| Scheduling | Stage 5 (core done) | Go needs timer interrupts for goroutine preemption fallback |
| IPC + cap transfer | Stage 6 | Go programs are service processes that export/import caps |
| VirtualMemory capability | Stage 5 | mmap equivalent for Go’s memory allocator and GC |
| Thread support | Extends Stage 5 | Multiple execution contexts per process |
| Futex authority capability | Extends Stage 5 | Go runtime synchronization |
VirtualMemory Capability
This is the biggest new kernel primitive. Go’s allocator requires:
- Reserve large virtual ranges without committing physical memory (Go reserves 256 TB of virtual space on 64-bit systems)
- Commit pages within reserved ranges (back with physical frames)
- Decommit pages (release frames, keep virtual range reserved)
- Set permissions (RW for data, none for guard pages)
The existing page table code (kernel/src/mem/paging.rs) supports mapping
and unmapping individual pages. It needs to be extended with:
- Virtual range reservation (mark ranges as reserved in some bitmap/tree)
- Lazy commit (map as
PROT_NONEinitially, page fault handler commits on demand — or explicit commit via cap call) - Permission changes on existing mappings
Thread Support
Extending the process model (kernel/src/process.rs). See the
SMP proposal for the PerCpu struct layout (per-CPU
kernel stack, saved registers, FS base); Thread extends this for
multi-thread-per-process. See also the In-Process Threading section in
ROADMAP.md for the
roadmap-level view.
#![allow(unused)]
fn main() {
struct Process {
pid: u64,
address_space: AddressSpace, // shared by all threads
caps: CapTable, // shared by all threads
threads: Vec<Thread>,
}
struct Thread {
tid: u64,
state: ThreadState,
kernel_stack: VirtAddr,
saved_regs: RegisterState, // rsp, rip, etc.
fs_base: u64, // for thread-local storage
}
}
The scheduler (Stage 5) schedules threads, not processes. Each thread gets its own kernel stack and register save area. Context switch saves/restores thread state. Page table switch only happens when switching between threads of different processes.
Alternative: Go via WASI
For comparison, the WASI path from the userspace-binaries proposal:
| Native GOOS | WASI | |
|---|---|---|
| Performance | Native speed | ~2-5x overhead (wasm interpreter/JIT) |
| Go compatibility | Full (after Phase 3) | Limited (WASI Go support is experimental) |
| Goroutines | Real M:N scheduling | Single-threaded (WASI has no threads yet) |
| Net I/O | Native async via poller | Blocking only (WASI sockets are sync) |
| Kernel work | VirtualMemory, threads, futex | None (wasm runtime handles it) |
| Go runtime fork | Yes (maintain a fork) | No (upstream GOOS=wasip1) |
| GC | Full concurrent GC | Conservative GC (wasm has no stack scanning) |
| Maintenance burden | High (track Go releases) | Low (upstream supported) |
WASI is easier but limited. Go on WASI (GOOS=wasip1) is officially
supported but experimental — no goroutine parallelism, no async I/O, limited
stdlib. For running CUE (which is CPU-bound evaluation, no I/O, single
goroutine), WASI might be sufficient.
Native GOOS is harder but complete. Full Go with goroutines, concurrent
GC, network I/O, and the entire stdlib. Required for Go network services
or anything using net/http.
Recommendation: Start with WASI for CUE evaluation (Phase 4 of the WASI proposal in userspace-binaries). If Go network services become a goal, invest in the native GOOS.
Relationship to Other Proposals
- Userspace binaries proposal — this extends Tier 4 (managed runtimes) with concrete Go implementation details. The POSIX layer (Part 4) is NOT sufficient for Go — Go doesn’t use libc on Linux, it makes raw syscalls. The GOOS approach bypasses POSIX entirely.
- Service architecture proposal — Go services participate in the capability graph like any other process. The Go net poller (Phase 3) uses TcpSocket/UdpSocket caps from the network stack.
- Storage and naming proposal — Go’s
os.Open()/os.Read()map to Namespace + Store caps via the GOOS file I/O implementation. Go doesn’t use POSIX for this — it has its ownruntime/os_capos.gowith direct cap invocations. - SMP proposal — Go’s
GOMAXPROCSuses multiple OS threads (Phase 2). Requires per-CPU scheduling from Stage 5/7.
Open Questions
-
Fork maintenance. A
GOOS=caposfork must track upstream Go releases. How much drift is acceptable? Could the capOS-specific code eventually be upstreamed (like Fuchsia’s was)? -
CGo support. Go’s FFI to C (
cgo) requires a C toolchain and dynamic linking. Should capOS support cgo, or is pure Go sufficient? CUE doesn’t use cgo, but some Go libraries do. -
GOROOT on capOS. Go programs expect
$GOROOT/libat runtime for some stdlib features. Where does this live on capOS? In the Store? Baked into the binary via static compilation? -
Go module proxy.
go getneeds HTTP access. On capOS, this would use aFetchcap. But cross-compilation on the host is more practical than building Go on capOS itself. -
Debugging. Go’s
runtime/debugandpprofexpect signals and/procaccess. What debugging capabilities should capOS expose? -
GC tuning. Go’s GC is tuned for Linux’s mmap semantics (decommit is cheap, virtual space is nearly free). capOS’s VirtualMemory cap needs to match these assumptions or the GC will need retuning.
Estimated Scope
| Phase | New kernel code | Go runtime changes | Dependencies |
|---|---|---|---|
| Phase 1: Minimal GOOS | ~200 (VirtualMemory cap) | ~2000-3000 | Stages 4-5 |
| Phase 2: Threading | ~500 (threads, futex) | ~500 | Stage 5, SMP |
| Phase 3: Net poller | ~100 (async notification) | ~300 | Networking, Stage 6 |
| Phase 4: CUE on capOS | 0 | 0 | Phase 1 (or WASI) |
| Total | ~800 | ~2800-3800 |
Plus ongoing maintenance to track Go upstream releases.