ADR-005: Single WASM Binary Architecture
Status
Accepted
Context
Traditional operating systems separate kernel and userspace: - Kernel: Runs in privileged mode - Userspace: Runs in unprivileged mode, uses syscalls
In WASM, there is no hardware privilege separation. We must decide: 1. Simulate separation (separate modules, message passing) 2. Embrace single binary (everything in one module)
Decision
We will compile everything into a single WASM binary. The kernel is a Rust struct, and "syscalls" are method calls.
// Kernel is just a struct
pub struct Kernel {
// ... all state
}
// "Syscalls" are method calls
impl Kernel {
pub fn sys_read(&mut self, fd: Fd, buf: &mut [u8]) -> Result<usize> {
// ...
}
}
// Shell calls kernel methods directly
let n = kernel.sys_read(fd, &mut buf)?;
Consequences
Positive
- Simplicity: No IPC for syscalls, just function calls
- Performance: No serialization overhead
- Debugging: Single stack trace, standard tools
- Type safety: Rust compiler checks kernel/shell interface
- Deployment: One file to serve
- Tractability: One codebase to understand
Negative
- No true isolation: Shell bug could corrupt kernel state
- Not realistic: Real OS uses hardware protection
- Testing: Harder to test kernel in isolation
- Extension: Adding new programs means recompiling
Mitigated
- Isolation: Rust's ownership model provides some protection
- Realism: We're teaching concepts, not building production OS
- Testing: Unit tests work fine, integration tests test the whole
- Extension: Could add WASM module loading later
Alternatives Considered
1. Separate WASM modules + message passing
- Pro: More realistic separation
- Con: Complex IPC, serialization overhead, harder to debug
2. Web Workers for kernel
- Pro: True isolation, parallel execution
- Con: Async-only communication, complex state sharing
3. Microkernel in main thread, services in Workers
- Pro: Best of both worlds
- Con: Significant complexity, overkill for demo
4. Interpret bytecode for userspace
- Pro: True process isolation
- Con: Need to design bytecode, much more work
Implementation Notes
The "separation" is conceptual:
src/
├── kernel/ # Kernel code
│ ├── syscall.rs
│ ├── process.rs
│ └── ...
├── shell/ # "Userspace" code
│ ├── executor.rs
│ └── programs/
└── lib.rs # Entry point
Even though it's one binary, the code is organized as if they were separate: - Kernel has private state - Shell uses public kernel API - Programs can't access kernel internals
WASM Module Loading
We do have WASM module loading for extensibility (from src/kernel/wasm/loader.rs):
pub struct Loader {
module: Option<Vec<u8>>,
}
impl Loader {
pub fn load(&mut self, bytes: &[u8]) -> WasmResult<()> {
ModuleValidator::validate(bytes)?;
self.module = Some(bytes.to_vec());
Ok(())
}
pub fn execute(&self, args: &[&str]) -> WasmResult<CommandResult> {
// ...
}
}
But this is for user-provided modules, not kernel/userspace split.
Lessons Learned
- Start simple, add complexity only when needed
- Conceptual separation + good code organization is enough for education
- Rust's module system provides natural boundaries
- A tractable system is more valuable than a realistic one