Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f09b1b0
fix: distinguish exit codes from signal termination
brandonpayton Jul 11, 2026
a4607d5
fix: honor mmap placement and unmap geometry
brandonpayton Jul 11, 2026
cb2dacc
fix: avoid kernel-owned pid collisions during fork
brandonpayton Jul 11, 2026
81a658f
fix: grow exec state before destructive cleanup
brandonpayton Jul 11, 2026
8128b83
fix: share AF_UNIX accept queues across fork
brandonpayton Jul 11, 2026
fd8d876
fix: preserve kernel-backed state across exec
brandonpayton Jul 11, 2026
172ea88
fix: preserve shared memory across process transitions
brandonpayton Jul 11, 2026
b0d0927
fix: share in-kernel descriptor backings across processes
brandonpayton Jul 11, 2026
4b9167f
fix: coordinate direct fork from instrumented side modules
brandonpayton Jul 11, 2026
fdb62c5
fix: keep file-backed shared mappings coherent
brandonpayton Jul 11, 2026
de5ad77
fix: preserve state at process lifecycle boundaries
brandonpayton Jul 11, 2026
32f750f
test: exercise anonymous shared mappings across fork
brandonpayton Jul 11, 2026
a3e1841
docs: define process sharing boundaries
brandonpayton Jul 11, 2026
10bb454
fix: keep exit-signal queries optional on ABI 16
brandonpayton Jul 11, 2026
057c731
fix: skip inactive shared-memory coordination
brandonpayton Jul 11, 2026
5d1d3c8
fix: keep concurrent thread sleeps independent
brandonpayton Jul 11, 2026
4987bae
fix: declare the ABI 16 exit-signal probe optional
brandonpayton Jul 11, 2026
139a5f8
test: initialize sleep state in thread teardown fixture
brandonpayton Jul 11, 2026
953d8af
fix: preserve signal termination across host boundaries
brandonpayton Jul 11, 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
41 changes: 41 additions & 0 deletions abi/snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@
}
],
"optional_kernel_exports": [
"kernel_get_process_exit_signal",
"kernel_reserve_host_region",
"kernel_reserve_host_region_at",
"kernel_set_cwd",
Expand Down Expand Up @@ -335,6 +336,11 @@
"name": "kernel_clear_fork_exec",
"signature": "() -> (i32)"
},
{
"kind": "func",
"name": "kernel_clear_process_metadata",
"signature": "(i32,i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_clock_getres",
Expand Down Expand Up @@ -455,11 +461,21 @@
"name": "kernel_eventfd2",
"signature": "(i32,i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_exec_prepare",
"signature": "(i32,i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_exec_setup",
"signature": "(i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_exec_setup_for_thread",
"signature": "(i32,i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_execve",
Expand Down Expand Up @@ -515,11 +531,26 @@
"name": "kernel_fcntl_lock",
"signature": "(i32,i32,i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_fd_is_open",
"signature": "(i32,i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_fd_supports_mmap_writeback",
"signature": "(i32,i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_fdatasync",
"signature": "(i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_find_listener_fd_by_accept_wake",
"signature": "(i32,i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_flock",
Expand Down Expand Up @@ -655,6 +686,11 @@
"name": "kernel_get_pipe_ofds",
"signature": "(i32,i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_get_process_exit_signal",
"signature": "(i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_get_process_exit_status",
Expand Down Expand Up @@ -1135,6 +1171,11 @@
"name": "kernel_push_argv",
"signature": "(i32,i32) -> ()"
},
{
"kind": "func",
"name": "kernel_push_process_metadata_entry",
"signature": "(i32,i32,i32,i32) -> (i32)"
},
{
"kind": "func",
"name": "kernel_pwrite",
Expand Down
37 changes: 37 additions & 0 deletions crates/fork-instrument/src/call_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,23 @@ fn types_match(module: &Module, a: TypeId, b: TypeId) -> bool {

const MAX_INDIRECT_DEPTH: u8 = 2;

/// Whether this module can resolve and invoke functions installed by Kandelo's
/// dynamic linker after static call-graph analysis has completed.
///
/// This predicate is also used when emitting the versioned fork capability
/// marker. Keep it as the single source of truth for both the conservative
/// closure below and the artifact claim consumed by the host runtime.
pub fn has_dynamic_linker_imports(module: &Module) -> bool {
module.imports.iter().any(|import| {
import.module == "env"
&& matches!(import.kind, ImportKind::Function(_))
&& matches!(
import.name.as_str(),
"__wasm_dlopen" | "__wasm_dlsym" | "__wasm_dlclose" | "__wasm_dlerror"
)
})
}

/// Compute the transitive closure of functions that reach `seed` via
/// direct calls, plus a bounded number of table/function-pointer dispatches.
///
Expand All @@ -589,6 +606,12 @@ const MAX_INDIRECT_DEPTH: u8 = 2;
pub fn reaching_closure(module: &Module, seed: FunctionId) -> HashSet<FunctionId> {
let profiles = profile_functions(module);
let table_targets = table_targets(module, &profiles);
// A dlsym result can be installed into the main module's table only after
// static analysis. Every call_indirect in a dlopen-capable main module is
// therefore a possible boundary above a fork-capable side-module frame.
// Keep this opt-in to the dynamic-linker imports so ordinary programs
// retain the precise table-target closure below.
let has_dynamic_linker_imports = has_dynamic_linker_imports(module);

// Reverse direct-call graph: `callee -> set of callers`.
let mut reverse_direct: HashMap<FunctionId, HashSet<FunctionId>> = HashMap::new();
Expand Down Expand Up @@ -652,6 +675,20 @@ pub fn reaching_closure(module: &Module, seed: FunctionId) -> HashSet<FunctionId
}
}

if has_dynamic_linker_imports {
for (&caller, profile) in &profiles {
if !profile.indirect.is_empty() {
enqueue(
caller,
1,
&mut best_indirect_depth,
&mut result,
&mut worklist,
);
}
}
}

while let Some((g, indirect_depth)) = worklist.pop_front() {
// (2) Direct-reverse: who calls g directly?
if let Some(callers) = reverse_direct.get(&g) {
Expand Down
42 changes: 41 additions & 1 deletion crates/fork-instrument/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,20 @@
//! - Phase 7: production rollout

use anyhow::{bail, ensure, Context, Result};
use walrus::RawCustomSection;
use wasmparser::{Parser, Payload};

pub mod call_graph;
pub mod instrument;
pub mod runtime;

/// Versioned artifact claim emitted by `wasm-fork-instrument` and consumed by
/// the host before it enables cross-module fork coordination.
pub const FORK_CAPABILITIES_SECTION: &str = "kandelo.wpk_fork.capabilities";
pub const FORK_CAPABILITIES_VERSION: u8 = 1;
pub const FORK_CAP_SIDE_ENTRY: u8 = 1 << 0;
pub const FORK_CAP_DYLINK_MAIN: u8 = 1 << 1;

/// Options controlling instrumentation. Fields will grow as phases
/// land; a `Default` implementation keeps call sites stable.
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -92,11 +100,29 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result<Vec<u8>> {
// the runtime's own injected functions are not mistaken for
// fork-path callers. (They can't reach the seed anyway, but the
// earlier-is-simpler ordering keeps the invariant trivially.)
let fork_path = match call_graph::find_import_func(&module, &opts.entry_import) {
let entry = call_graph::find_import_func(&module, &opts.entry_import);
let fork_path = match entry {
Some(seed) => call_graph::reaching_closure(&module, seed),
None => Default::default(),
};

// The five wpk_fork_* exports prove only that some instrumentation runtime
// was injected. They do not prove which import seeded the transformed call
// graph or whether a dlopen-capable main used the conservative dynamic
// call_indirect boundary. Emit a separate, versioned claim for exactly the
// transformations performed in this invocation so the host can reject
// stale or generically instrumented artifacts instead of mis-resuming.
let mut fork_capabilities = 0;
if entry.is_some() && opts.entry_import == "env.fork" {
fork_capabilities |= FORK_CAP_SIDE_ENTRY;
}
if entry.is_some()
&& opts.entry_import == "kernel.kernel_fork"
&& call_graph::has_dynamic_linker_imports(&module)
{
fork_capabilities |= FORK_CAP_DYLINK_MAIN;
}

// Phase 4a: runtime scaffolding. Always injected so the module's
// exported ABI is stable regardless of whether any caller was
// actually rewritten.
Expand Down Expand Up @@ -128,6 +154,20 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result<Vec<u8>> {
// No-op when `fork_path` is empty (module doesn't use fork).
instrument::instrument_functions(&mut module, &runtime, &fork_path, &b1_plan);

loop {
let existing = module
.customs
.iter()
.find(|(_, section)| section.name() == FORK_CAPABILITIES_SECTION)
.map(|(id, _)| id);
let Some(existing) = existing else { break };
module.customs.delete(existing);
}
module.customs.add(RawCustomSection {
name: FORK_CAPABILITIES_SECTION.into(),
data: vec![FORK_CAPABILITIES_VERSION, fork_capabilities],
});

// Historical phase list (Phase 4b/4c/4d/4e/4f/5/6) was an artefact
// of guard-dispatch's body-rewriting approach. Post-commit-4 those
// phases are folded into `instrument::instrument_functions` itself;
Expand Down
29 changes: 29 additions & 0 deletions crates/fork-instrument/tests/call_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,35 @@ fn passive_element_with_table_init_is_followed() {
);
}

#[test]
fn dynamic_linker_indirect_call_is_conservative_fork_boundary() {
// Side-module functions inserted after instrumentation are absent from
// every static element segment. A dlopen-capable main must still preserve
// the call_indirect frame and its direct callers when that side function
// later reaches fork().
let wat = r#"
(module
(import "kernel" "kernel_fork" (func $fork (result i32)))
(import "env" "__wasm_dlsym" (func $dlsym (param i32 i32 i32) (result i32)))
(type $side_fn_ty (func (result i32)))
(table $t 1 funcref)
(func $dispatch_side_callback (export "dispatch_side_callback") (result i32)
i32.const 0
call_indirect $t (type $side_fn_ty))
(func $parent_frame (export "parent_frame") (result i32)
call $dispatch_side_callback)
(func $ordinary (export "ordinary") (result i32)
i32.const 7))
"#;
let found = discover(wat);
assert!(found.iter().any(|n| n == "dispatch_side_callback"));
assert!(found.iter().any(|n| n == "parent_frame"));
assert!(
!found.iter().any(|n| n == "ordinary"),
"unrelated functions must stay out of the dynamic fork closure: {found:?}"
);
}

#[test]
fn constant_slot_pointing_to_safe_target_excludes_indirect_caller() {
// Both functions have the same signature and inhabit the same table.
Expand Down
71 changes: 70 additions & 1 deletion crates/fork-instrument/tests/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@
//! - Independently validating via wasmparser that the emitted module
//! is well-formed.

use fork_instrument::{Options, instrument};
use fork_instrument::runtime::names;
use fork_instrument::{
FORK_CAP_DYLINK_MAIN, FORK_CAP_SIDE_ENTRY, FORK_CAPABILITIES_SECTION,
FORK_CAPABILITIES_VERSION, Options, instrument,
};
use walrus::{ExportItem, Module, ValType};
use wasmparser::{Parser, Payload};

fn instrument_wat(wat_src: &str) -> Vec<u8> {
let bytes = wat::parse_str(wat_src).expect("wat parse");
Expand All @@ -27,6 +31,18 @@ fn validate(bytes: &[u8]) {
validator.validate_all(bytes).expect("valid wasm");
}

fn fork_capabilities(bytes: &[u8]) -> Vec<Vec<u8>> {
Parser::new(0)
.parse_all(bytes)
.filter_map(|payload| match payload.expect("parse payload") {
Payload::CustomSection(section) if section.name() == FORK_CAPABILITIES_SECTION => {
Some(section.data().to_vec())
}
_ => None,
})
.collect()
}

fn export_function_id(module: &Module, name: &str) -> walrus::FunctionId {
let export = module
.exports
Expand Down Expand Up @@ -57,6 +73,59 @@ fn instrumented_module_validates() {
validate(&bytes);
}

#[test]
fn marks_dlopen_main_indirect_boundary_separately() {
let wat = r#"
(module
(import "kernel" "kernel_fork" (func $fork (result i32)))
(import "env" "__wasm_dlsym" (func $dlsym (param i32 i32 i32) (result i32)))
(type $callback (func (result i32)))
(table 1 funcref)
(memory 1)
(func (export "dispatch") (result i32)
i32.const 0
call_indirect (type $callback)))
"#;
let output = instrument_wat(wat);
assert_eq!(
fork_capabilities(&output),
vec![vec![FORK_CAPABILITIES_VERSION, FORK_CAP_DYLINK_MAIN]],
);
}

#[test]
fn marks_env_fork_side_entry_separately() {
let input = wat::parse_str(
r#"
(module
(import "env" "fork" (func $fork (result i32)))
(memory 1)
(func (export "side_fork") (result i32) call $fork))
"#,
)
.expect("wat parse");
let output = instrument(
&input,
&Options {
entry_import: "env.fork".into(),
},
)
.expect("instrument side");
assert_eq!(
fork_capabilities(&output),
vec![vec![FORK_CAPABILITIES_VERSION, FORK_CAP_SIDE_ENTRY]],
);
}

#[test]
fn generic_runtime_exports_do_not_claim_side_or_dylink_coverage() {
let output = instrument_wat(EMPTY_MODULE_WITH_FORK);
assert_eq!(
fork_capabilities(&output),
vec![vec![FORK_CAPABILITIES_VERSION, 0]],
);
}

#[test]
fn injects_state_global_mutable_i32_init_zero() {
let bytes = instrument_wat(EMPTY_MODULE_WITH_FORK);
Expand Down
10 changes: 5 additions & 5 deletions crates/kernel/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
//! Like `/dev/fb0` and `/dev/input/mice`, `/dev/dsp` is single-open. A
//! second `open` from a different pid is `EBUSY`. Re-opens by the
//! current owner are accepted (matches the typical OSS exclusive-grab
//! model). Owner is released when the process closes its last `/dev/dsp`
//! fd, or exits, or `execve`s — at which point the ring is also
//! cleared so a successor open starts from silence.
//! model). A non-CLOEXEC fd retains ownership and queued samples across
//! `execve`; last close or process exit releases ownership and clears the
//! ring so a successor open starts from silence.
//!
//! ## Backpressure
//!
Expand Down Expand Up @@ -127,8 +127,8 @@ pub fn pending_bytes() -> usize {
ring().len()
}

/// Drop all buffered samples. Called on process exit / exec by the
/// owner, and by `SNDCTL_DSP_RESET`.
/// Drop all buffered samples. Called when the owner exits or closes its last
/// fd, and by `SNDCTL_DSP_RESET`.
pub fn reset() {
ring().clear();
}
Expand Down
Loading