Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
756f75c
fix: unlink inline symlinks without following targets
Jun 5, 2026
99b5de8
fix: preserve dylink metadata during fork instrumentation
Jun 15, 2026
7a6aef7
fix: preserve instrumented wasm file permissions
Jun 15, 2026
17ef1dd
fix: preserve omitted host utimens timestamps
Jun 15, 2026
78a5cb6
fix: align host-backed mount path resolution
brandonpayton Jun 19, 2026
39e770e
fix: stabilize host-backed PHP test mounts
Jun 16, 2026
581ec3a
fix: tighten browser DNS and PHPT output ordering
Jun 16, 2026
2c6474b
fix: update sharedfs timestamps on content changes
Jun 17, 2026
13b0aad
fix: honor socket peeking in browser network paths
Jun 17, 2026
a2f3b3b
fix: preserve browser child output and exec errors
Jun 17, 2026
d50c868
fix: honor exclusive creates in sharedfs
Jun 17, 2026
23c6315
fix: keep virtual interface name APIs consistent
Jun 17, 2026
4f5b0c4
fix: keep memoryfs inode numbers representable
Jun 17, 2026
a65f854
fix: speed up large SharedFS directories
Jun 18, 2026
0b1b8b5
fix: align SharedFS directory reads and append offsets
Jun 18, 2026
0e98540
fix: update SharedFS directory mutation times
Jun 18, 2026
ce9aaa0
fix: tighten SharedFS rename semantics
Jun 18, 2026
45beda8
fix: reject trailing-slash unlink on files
Jun 18, 2026
439d699
fix: include service aliases in browser VFS
Jun 18, 2026
90281cf
fix: arm browser VM interrupt timers from kernel worker
Jun 18, 2026
aef48d5
Fix browser PHP PHPT runtime blockers
brandonpayton Jun 19, 2026
306a8e6
Preserve finite poll deadlines across retries
brandonpayton Jun 20, 2026
62a5771
Share main longjmp tag with side modules
brandonpayton Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion crates/fork-instrument/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ pub fn analyze(input: &[u8], opts: &Options) -> Result<Analysis> {
/// tool is invoked by build scripts across programs that may or may
/// not use `fork()`.
pub fn instrument(input: &[u8], opts: &Options) -> Result<Vec<u8>> {
let leading_dylink_section = leading_dylink_section(input);
let mut module = walrus::Module::from_buffer(input)
.context("failed to parse input wasm module")?;

Expand Down Expand Up @@ -133,6 +134,57 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result<Vec<u8>> {
// see `instrument_one_function_switch` / `instrument_one_function_nested_switch`
// for the actual transform.

let output = module.emit_wasm();
let mut output = module.emit_wasm();
if let Some(section) = leading_dylink_section {
// Walrus does not preserve arbitrary custom sections on re-emit.
// For side modules, dylink.0 must stay first so the dynamic linker can
// allocate memory/table requirements before instantiation.
output.splice(8..8, section.iter().copied());
}
Ok(output)
}

fn leading_dylink_section(input: &[u8]) -> Option<Vec<u8>> {
if input.len() < 8 || &input[0..4] != b"\0asm" {
return None;
}
let mut offset = 8usize;
let section_start = offset;
let section_id = *input.get(offset)?;
offset += 1;
if section_id != 0 {
return None;
}
let size = read_var_u32(input, &mut offset)? as usize;
let payload_start = offset;
let payload_end = payload_start.checked_add(size)?;
if payload_end > input.len() {
return None;
}
let name_len = read_var_u32(input, &mut offset)? as usize;
let name_end = offset.checked_add(name_len)?;
if name_end > payload_end {
return None;
}
if &input[offset..name_end] != b"dylink.0" {
return None;
}
Some(input[section_start..payload_end].to_vec())
}

fn read_var_u32(input: &[u8], offset: &mut usize) -> Option<u32> {
let mut result = 0u32;
let mut shift = 0u32;
loop {
let byte = *input.get(*offset)?;
*offset += 1;
result |= u32::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
return Some(result);
}
shift += 7;
if shift >= 35 {
return None;
}
}
}
25 changes: 24 additions & 1 deletion crates/fork-instrument/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
use anyhow::{Context, Result};
use clap::Parser;
use std::fs;
use std::path::PathBuf;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};

use fork_instrument::{Options, analyze, instrument};

Expand Down Expand Up @@ -71,10 +73,31 @@ fn main() -> Result<()> {

fs::write(output_path, &output)
.with_context(|| format!("writing output: {}", output_path.display()))?;
preserve_input_permissions(&cli.input, output_path)?;

Ok(())
}

#[cfg(unix)]
fn preserve_input_permissions(input_path: &Path, output_path: &Path) -> Result<()> {
let input_mode = fs::metadata(input_path)
.with_context(|| format!("stat input for permissions: {}", input_path.display()))?
.permissions()
.mode();
let mut output_permissions = fs::metadata(output_path)
.with_context(|| format!("stat output for permissions: {}", output_path.display()))?
.permissions();
output_permissions.set_mode(input_mode);
fs::set_permissions(output_path, output_permissions)
.with_context(|| format!("setting output permissions: {}", output_path.display()))?;
Ok(())
}

#[cfg(not(unix))]
fn preserve_input_permissions(_input_path: &Path, _output_path: &Path) -> Result<()> {
Ok(())
}

fn print_analysis_json(analysis: &fork_instrument::Analysis) {
// Hand-rolled JSON to avoid a serde dependency for a tiny output.
// Format is one-entry-per-line array of `{name, is_import}` objects.
Expand Down
75 changes: 73 additions & 2 deletions crates/fork-instrument/tests/instrument.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
use std::collections::HashSet;

use fork_instrument::runtime::names as runtime_names;
use fork_instrument::{Options, instrument};
use fork_instrument::{instrument, Options};
use walrus::{
ExportItem, FunctionId, FunctionKind, LocalFunction, Module,
ir::{self, Instr, InstrSeqId},
ExportItem, FunctionId, FunctionKind, LocalFunction, Module,
};

// --- Helpers ----------------------------------------------------------
Expand All @@ -41,6 +41,49 @@ fn validate(bytes: &[u8]) {
validator.validate_all(bytes).expect("valid wasm");
}

fn insert_leading_dylink_section(mut wasm: Vec<u8>) -> Vec<u8> {
// Minimal dylink.0 section with WASM_DYLINK_MEM_INFO
// {memorySize=0, memoryAlign=0, tableSize=0, tableAlign=0}.
let dylink = [
0x00, // custom section
0x0f, // payload size
0x08, b'd', b'y', b'l', b'i', b'n', b'k', b'.', b'0', 0x01, // WASM_DYLINK_MEM_INFO
0x04, // subsection payload size
0x00, 0x00, 0x00, 0x00,
];
wasm.splice(8..8, dylink);
wasm
}

fn first_custom_section_name(bytes: &[u8]) -> Option<String> {
let mut offset = 8usize;
if bytes.get(offset).copied()? != 0 {
return None;
}
offset += 1;
let _section_size = read_var_u32(bytes, &mut offset)?;
let name_len = read_var_u32(bytes, &mut offset)? as usize;
let name = bytes.get(offset..offset + name_len)?;
Some(String::from_utf8_lossy(name).into_owned())
}

fn read_var_u32(bytes: &[u8], offset: &mut usize) -> Option<u32> {
let mut result = 0u32;
let mut shift = 0u32;
loop {
let byte = *bytes.get(*offset)?;
*offset += 1;
result |= u32::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
return Some(result);
}
shift += 7;
if shift >= 35 {
return None;
}
}
}

fn func_by_name(module: &Module, name: &str) -> FunctionId {
module
.funcs
Expand All @@ -57,6 +100,34 @@ fn local_func(module: &Module, id: FunctionId) -> &LocalFunction {
}
}

#[test]
fn preserves_leading_dylink_section_for_side_modules() {
let input = insert_leading_dylink_section(parse_wat(
r#"
(module
(import "env" "fork" (func $fork (result i32)))
(memory (import "env" "memory") 1)
(func $call_fork (export "call_fork") (result i32)
(call $fork)))
"#,
));

let output = instrument(
&input,
&Options {
entry_import: "env.fork".into(),
},
)
.expect("instrument side module");

validate(&output);
assert_eq!(
first_custom_section_name(&output).as_deref(),
Some("dylink.0"),
"dynamic-linking side modules must keep dylink.0 as the first section",
);
}

fn entry_instr_kinds(module: &Module, id: FunctionId) -> Vec<InstrKind> {
let f = local_func(module, id);
f.block(f.entry_block())
Expand Down
13 changes: 6 additions & 7 deletions crates/kernel/src/syscalls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5486,12 +5486,11 @@ pub fn sys_nanosleep(
pub fn sys_clock_getres(_proc: &Process, clock_id: u32) -> Result<WasmTimespec, Errno> {
use wasm_posix_shared::clock::*;
match clock_id {
CLOCK_REALTIME | CLOCK_MONOTONIC | CLOCK_PROCESS_CPUTIME_ID | CLOCK_THREAD_CPUTIME_ID => {
Ok(WasmTimespec {
tv_sec: 0,
tv_nsec: 1_000_000,
}) // 1ms
}
CLOCK_REALTIME | CLOCK_MONOTONIC | CLOCK_PROCESS_CPUTIME_ID | CLOCK_THREAD_CPUTIME_ID
| CLOCK_BOOTTIME => Ok(WasmTimespec {
tv_sec: 0,
tv_nsec: 1_000_000,
}), // 1ms
id if (id & 7) == 2 => {
// Per-process CPU clock: clock_getcpuclockid encodes as (-pid-1)*8 + 2
Ok(WasmTimespec {
Expand All @@ -5515,7 +5514,7 @@ pub fn sys_clock_nanosleep(
) -> Result<(), Errno> {
use wasm_posix_shared::clock::*;
// Validate clock_id
if clock_id != CLOCK_REALTIME && clock_id != CLOCK_MONOTONIC {
if clock_id != CLOCK_REALTIME && clock_id != CLOCK_MONOTONIC && clock_id != CLOCK_BOOTTIME {
return Err(Errno::EINVAL);
}
// Validate timespec
Expand Down
62 changes: 55 additions & 7 deletions crates/kernel/src/wasm_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9468,11 +9468,51 @@ pub extern "C" fn kernel_getitimer(which: u32, curr_ptr: *mut u8) -> i32 {
// POSIX timers (timer_create / timer_settime / timer_gettime / etc.)
// ---------------------------------------------------------------------------

/// SIGEV_SIGNAL = 0, SIGEV_NONE = 1.
/// SIGEV_SIGNAL = 0, SIGEV_NONE = 1, SIGEV_THREAD_ID = 4.
const SIGEV_SIGNAL: u32 = 0;
const SIGEV_NONE: u32 = 1;
const SIGEV_THREAD_ID: u32 = 4;
/// TIMER_ABSTIME flag for timer_settime.
const TIMER_ABSTIME: i32 = 1;

fn timer_clock_to_host_clock(clock_id: u32) -> Option<u32> {
use wasm_posix_shared::clock::*;
match clock_id {
CLOCK_REALTIME | CLOCK_MONOTONIC => Some(clock_id),
CLOCK_BOOTTIME => Some(CLOCK_MONOTONIC),
_ => None,
}
}

fn timer_notify_supported(sigev_notify: u32) -> bool {
matches!(sigev_notify, SIGEV_SIGNAL | SIGEV_NONE | SIGEV_THREAD_ID)
}

#[cfg(test)]
mod posix_timer_tests {
use super::*;
use wasm_posix_shared::clock::*;

#[test]
fn boottime_timers_use_monotonic_host_clock() {
assert_eq!(timer_clock_to_host_clock(CLOCK_BOOTTIME), Some(CLOCK_MONOTONIC));
}

#[test]
fn timer_create_rejects_unsupported_clock_ids() {
assert_eq!(timer_clock_to_host_clock(CLOCK_THREAD_CPUTIME_ID), None);
assert_eq!(timer_clock_to_host_clock(99), None);
}

#[test]
fn timer_create_accepts_thread_id_notification() {
assert!(timer_notify_supported(SIGEV_SIGNAL));
assert!(timer_notify_supported(SIGEV_NONE));
assert!(timer_notify_supported(SIGEV_THREAD_ID));
assert!(!timer_notify_supported(2));
}
}

/// timer_create(clock_id, sigevent_ptr, timerid_ptr)
/// musl sends ksigevent = {sigev_value(i32), sigev_signo(i32), sigev_notify(i32), sigev_tid(i32)} = 16 bytes.
/// Returns 0 on success, negative errno.
Expand All @@ -9486,19 +9526,27 @@ pub extern "C" fn kernel_timer_create(

let (_gkl, proc) = unsafe { get_process() };

let host_clock_id = match timer_clock_to_host_clock(clock_id) {
Some(id) => id,
None => return -(Errno::EINVAL as i32),
};

// Parse sigevent (default: SIGEV_SIGNAL with SIGALRM)
let (sigev_signo, sigev_value, sigev_notify) = if sevp_ptr.is_null() {
(14u32, 0i32, SIGEV_SIGNAL) // default: SIGALRM
let (sigev_signo, sigev_value, sigev_notify, sigev_tid) = if sevp_ptr.is_null() {
(14u32, 0i32, SIGEV_SIGNAL, 0u32) // default: SIGALRM
} else {
let buf = unsafe { slice::from_raw_parts(sevp_ptr, 16) };
let value = i32::from_le_bytes(buf[0..4].try_into().unwrap());
let signo = i32::from_le_bytes(buf[4..8].try_into().unwrap()) as u32;
let notify = i32::from_le_bytes(buf[8..12].try_into().unwrap()) as u32;
(signo, value, notify)
let tid = i32::from_le_bytes(buf[12..16].try_into().unwrap()) as u32;
(signo, value, notify, tid)
};

// Only SIGEV_SIGNAL and SIGEV_NONE are supported
if sigev_notify != SIGEV_SIGNAL && sigev_notify != 1 {
if !timer_notify_supported(sigev_notify) {
return -(Errno::EINVAL as i32);
}
if sigev_notify == SIGEV_THREAD_ID && !proc.is_main_thread(sigev_tid) {
return -(Errno::EINVAL as i32);
}

Expand All @@ -9522,7 +9570,7 @@ pub extern "C" fn kernel_timer_create(
};

proc.posix_timers[timer_id] = Some(PosixTimerState {
clock_id,
clock_id: host_clock_id,
sigev_signo,
sigev_value,
interval_sec: 0,
Expand Down
1 change: 1 addition & 0 deletions crates/shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,7 @@ pub mod clock {
pub const CLOCK_MONOTONIC: u32 = 1;
pub const CLOCK_PROCESS_CPUTIME_ID: u32 = 2;
pub const CLOCK_THREAD_CPUTIME_ID: u32 = 3;
pub const CLOCK_BOOTTIME: u32 = 7;
}

/// Timespec structure for the Wasm POSIX interface.
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@ Each process has a WebAssembly linear memory (shared, up to 1GB by default). The
```
Address Region
0x00000000 Wasm data segment (globals, static data)
0x00110000 Global base (--global-base=1114112)
0x00110000 Default global base (--global-base=1114112; raised by
the SDK when larger linker stack reservations require it)
__heap_base First linker-free byte exported by the program
control_base Host-owned low control slab
- main page 0: fork-save/scratch
Expand Down
4 changes: 2 additions & 2 deletions docs/posix-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ shortcuts.
|----------|--------|-------|
| `time()` | Full | Wrapper around clock_gettime(CLOCK_REALTIME). Returns seconds since epoch. |
| `gettimeofday()` | Full | Wrapper around clock_gettime(CLOCK_REALTIME). Returns (sec, usec) pair. |
| `clock_gettime()` | Full | Host-delegated. CLOCK_REALTIME and CLOCK_MONOTONIC supported. Node.js uses Date.now() and process.hrtime.bigint(). |
| `clock_gettime()` | Full | Host-delegated. CLOCK_REALTIME, CLOCK_MONOTONIC, CLOCK_PROCESS_CPUTIME_ID, CLOCK_THREAD_CPUTIME_ID, and CLOCK_BOOTTIME supported. CLOCK_BOOTTIME is monotonic-equivalent because Kandelo hosts cannot observe suspend time. Node.js uses Date.now() and process.hrtime.bigint(); browsers use Date.now() and performance.now(). |
| `nanosleep()` | Partial | Host-delegated. Node.js uses Atomics.wait with timeout. Browser support requires a worker context that can block with Atomics.wait. Validates tv_sec >= 0 and tv_nsec < 1e9. |
| `usleep()` | Full | Converts microseconds to sec+nsec, delegates to host_nanosleep. |
| `clock_settime()` | Stub | Returns EPERM. Cannot set system clock from Wasm. |
Expand Down Expand Up @@ -287,7 +287,7 @@ shortcuts.
| `inotify_init()` / `inotify_init1()` | Stub | Returns ENOSYS. |
| `inotify_add_watch()` / `inotify_rm_watch()` | Stub | Returns EBADF. |
| `fanotify_init()` / `fanotify_mark()` | Stub | Returns ENOSYS. |
| `timer_create()` | Full | CLOCK_REALTIME and CLOCK_MONOTONIC. SIGEV_SIGNAL delivery with si_value. Per-process timer table (max 32). |
| `timer_create()` | Partial | CLOCK_REALTIME, CLOCK_MONOTONIC, and CLOCK_BOOTTIME. CLOCK_BOOTTIME is monotonic-equivalent. SIGEV_SIGNAL, SIGEV_NONE, and current-main-thread SIGEV_THREAD_ID are supported; SIGEV_THREAD is not supported. Per-process timer table (max 32). |
| `timer_settime()` / `timer_gettime()` | Full | Absolute (TIMER_ABSTIME) and relative time. Interval timers with automatic rearming. Host setTimeout-based delivery. |
| `timer_getoverrun()` | Full | Tracks overrun count when signal is still pending at next interval fire. Reset on successful signal delivery. |
| `timer_delete()` | Full | Cancels timer and removes from per-process table. |
Expand Down
3 changes: 2 additions & 1 deletion docs/sdk-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ wasm32posix-cc -shared -fPIC plugin.c -o plugin.so
-Wl,--import-memory # Memory provided by host
-Wl,--shared-memory # Enable SharedArrayBuffer
-Wl,--max-memory=1073741824 # 1GB max memory
-Wl,--global-base=1114112 # Data segment start
-Wl,--global-base=<base> # Data segment start; defaults to 1114112
# and is raised for larger -z stack-size
-Wl,--allow-undefined # Host imports are resolved at load time
-Wl,--export-table # Export function table (for dlopen)
-Wl,--export=__stack_pointer # Required for fork/thread support
Expand Down
5 changes: 5 additions & 0 deletions host/src/browser-kernel-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ export interface BrowserKernelOptions {
syscallLogPtrWidth?: 4 | 8;
/** Forwarded to TlsNetworkBackendOptions.dnsAliases. */
dnsAliases?: Record<string, string>;
/** Forwarded to TlsNetworkBackendOptions.corsProxyUrl. Browser pages that
* are not controlled by Kandelo's service worker can use this to route
* guest outbound HTTP(S) through a same-origin proxy. */
corsProxyUrl?: string;
}

/** Options for {@link BrowserKernel.boot}. */
Expand Down Expand Up @@ -395,6 +399,7 @@ export class BrowserKernel {
enableSyscallLog: this.options.enableSyscallLog,
syscallLogPtrWidth: this.options.syscallLogPtrWidth,
dnsAliases: this.options.dnsAliases,
corsProxyUrl: this.options.corsProxyUrl,
},
};
this.kernelWorkerHandle.postMessage(initMsg, [transferBuf]);
Expand Down
Loading
Loading