Skip to content

feat(vm-runner): out-of-process wasm compilation daemon - #16067

Open
jakmeier wants to merge 12 commits into
near:masterfrom
jakmeier:wasmtime_sandboxing_pr0
Open

feat(vm-runner): out-of-process wasm compilation daemon#16067
jakmeier wants to merge 12 commits into
near:masterfrom
jakmeier:wasmtime_sandboxing_pr0

Conversation

@jakmeier

@jakmeier jakmeier commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Sandboxed, memory-limited compilation for Wasmtime on Linux.

This is meant to be an optional setup that lives side-by-side with in-process compilation as an experiment for now.

The implementation is not complete, to keep the initial PR reasonably sized.
(Hint: Review it commit-by-commit.)

Follow-ups are planned for:

  • Retry with a higher memory limit on a OOM crash
  • Allow configuration of constants
  • Metrics
  • Check version compatibility between child and parent binaries

Questions that need to be resolved and might also need follow-ups:

  • How to deal with WasmCompilationUnknownError? For now, it results in skipping chunk endorsement.
  • Should we have a wallclock timeout on child startup and request processing?
  • Should we avoid a second binary and run full neard as the daemon instead?
  • Should we use sccomp for additional sandboxing

darioush and others added 4 commits July 14, 2026 18:02
Sandboxed, memory-limited compilation for Wasmtime on Linux.

This commit only adds the daemon, actual usage is in the next.
Use the compiler daemon introduced in the previous commit.

This is meant to be an optional setup that lives side-by-side with in-process compilation as an experiment for now.
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.05166% with 276 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.40%. Comparing base (9a8358a) to head (fdb732f).

Files with missing lines Patch % Lines
...untime/near-vm-runner/src/compiler_daemon/child.rs 0.00% 81 Missing ⚠️
...ntime/near-vm-runner/src/compiler_daemon/parent.rs 76.58% 54 Missing and 16 partials ⚠️
...time/near-vm-runner/src/compiler_daemon/sandbox.rs 0.00% 63 Missing ⚠️
runtime/near-vm-runner/src/runner.rs 40.00% 11 Missing and 1 partial ⚠️
...ime/near-vm-runner/src/compiler_daemon/protocol.rs 54.54% 7 Missing and 3 partials ⚠️
runtime/near-vm-runner/src/wasmtime_runner/mod.rs 87.17% 8 Missing and 2 partials ⚠️
core/async/src/thread_pool.rs 11.11% 8 Missing ⚠️
...ime/near-vm-runner/src/compiler_daemon/watchdog.rs 91.56% 2 Missing and 5 partials ⚠️
nearcore/src/lib.rs 93.10% 4 Missing and 2 partials ⚠️
runtime/near-vm-runner/src/cache.rs 86.36% 3 Missing ⚠️
... and 4 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #16067      +/-   ##
==========================================
- Coverage   73.45%   73.40%   -0.06%     
==========================================
  Files         861      868       +7     
  Lines      190459   191204     +745     
  Branches   190459   191204     +745     
==========================================
+ Hits       139910   140353     +443     
- Misses      46100    46374     +274     
- Partials     4449     4477      +28     
Flag Coverage Δ
pytests-nightly 1.21% <0.14%> (-0.01%) ⬇️
unittests 70.06% <66.05%> (-0.03%) ⬇️
unittests-nightly 70.12% <66.05%> (-0.04%) ⬇️
unittests-spice 65.72% <66.05%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jakmeier

Copy link
Copy Markdown
Contributor Author

Regarding the chosen size limits, I ran experiments on jakmeier/experiments_on_wasmtime_memory_usage against >3000 mainnet contracts.

Results show that on average, more than 6 threads doesn't meaningfully reduce compilation time. Virtual memory usage (what is limited by RLIMIT_AS in the PR) does keep increasing with more allocated threads though. Hence I chose 6 threads per worker so we don't have to overallocate too much.

With 6 threads, maximum virtual memory usage is 596.46 MB for mainnet contracts. (Crafted contracts we identified as being memory hungry actually stay below that, now that we have limits in place and use Winch.)

Compilation time

Threads n mean median min max stdev
2 10386 74.90 ms 64.92 ms 0.25 ms 403.26 ms 47.01 ms
4 10386 43.77 ms 38.48 ms 0.31 ms 226.10 ms 26.52 ms
6 10386 33.69 ms 29.71 ms 0.33 ms 198.81 ms 20.14 ms
8 10386 28.73 ms 25.49 ms 0.41 ms 147.49 ms 16.86 ms
10 10386 25.31 ms 22.42 ms 0.47 ms 133.61 ms 14.76 ms

Peak memory usage (RSS)

Threads n mean median min max stdev
2 10386 39.09 MB 34.40 MB 4.78 MB 169.96 MB 20.15 MB
4 10386 39.30 MB 34.51 MB 4.80 MB 170.18 MB 20.13 MB
6 10386 39.72 MB 34.94 MB 4.87 MB 170.87 MB 20.17 MB
8 10386 40.10 MB 35.37 MB 4.91 MB 171.54 MB 20.23 MB
10 10386 40.63 MB 35.88 MB 4.96 MB 170.02 MB 20.33 MB

Peak virtual memory usage

Threads n mean median min max stdev
2 10386 212.76 MB 210.68 MB 144.27 MB 286.96 MB 15.74 MB
4 10386 315.89 MB 303.63 MB 212.28 MB 465.53 MB 27.90 MB
6 10386 448.83 MB 434.79 MB 408.34 MB 596.46 MB 30.64 MB
8 10386 580.26 MB 567.03 MB 540.37 MB 733.66 MB 28.88 MB
10 10386 724.99 MB 710.61 MB 672.40 MB 877.72 MB 42.81 MB

I also plotted compilation time with different thread numbers grouped by effective WASM size, to ensure the global average and mean don't hide a meaningful improvement on large contracts. Looks like 6 threads hits diminishing returns on all size buckets.

rayon_threads_vs_compile_duration_winch

@jakmeier
jakmeier marked this pull request as ready for review July 14, 2026 18:30
@jakmeier
jakmeier requested a review from a team as a code owner July 14, 2026 18:30
@ssavenko-near

Copy link
Copy Markdown
Contributor

How to deal with WasmCompilationUnknownError? Now I treat them the same as WasmUnknownError, potentially committing it to the chain if it fails compilation on most nodes and validators. I'm not so happy about that and it's opposite to what we discussed in the last meeting on the topic.

I recall we did not converge on this topic during the meeting. I actually favor the currently implemented option, but that's definitely something which is worth a deeper dive.

@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown

Greptile Summary

Adds an optional out-of-process Wasmtime compilation service.

  • Introduces parent/child IPC, worker pooling, prioritization, watchdog handling, and Linux sandboxing.
  • Wires daemon selection through node configuration and runtime compilation paths.
  • Adds daemon packaging, build targets, metrics, cache integration, and integration tests.

Confidence Score: 4/5

This PR is not safe to merge until daemon failures stop panicking contract execution and the outstanding worker-progress and memory-containment failures are resolved.

Production response reads can still retain workers indefinitely, failed RLIMIT_AS setup still permits unrestricted compilation despite the author's reply preferring to defer OOM work, and exhausting daemon retries now reaches an unconditional panic during function execution.

Files Needing Attention: runtime/near-vm-runner/src/compiler_daemon/parent.rs, runtime/near-vm-runner/src/compiler_daemon/child.rs, runtime/runtime/src/function_call.rs

Sequence Diagram

sequenceDiagram
    participant Runtime
    participant Parent as Daemon pool
    participant Worker as Compiler daemon
    participant Wasmtime
    Runtime->>Parent: compile(prepared Wasm, priority)
    Parent->>Parent: checkout worker lease
    Parent->>Worker: framed compile request
    Worker->>Wasmtime: precompile_module
    Wasmtime-->>Worker: compiled artifact/error
    Worker-->>Parent: framed response
    Parent->>Parent: check worker lease in
    Parent-->>Runtime: compilation result
    alt IPC failure on both attempts
        Parent-->>Runtime: WasmCompilationUnknownError
        Runtime->>Runtime: panic
    end
Loading

Fix All in Claude Code Fix All in Codex

Reviews (6): Last reviewed commit: "Merge branch 'master' into wasmtime_sand..." | Re-trigger Greptile

Comment thread runtime/near-vm-runner/src/compiler_daemon/parent.rs Outdated
Comment thread runtime/near-vm-runner/src/compiler_daemon/child.rs
jakmeier added 2 commits July 27, 2026 17:05
This is necessary to prevent a hanging compilation from
blocking workers and eventually neard as a whole.
@jakmeier

Copy link
Copy Markdown
Contributor Author

@darioush @ssavenko-near This is now updated, as discussed, to skip chunk endorsement rather than commit WasmCompilationUnknownError to the chain.

I've also added an IPC timeout to avoid potential hangs, as pointed out by the AI review.

Everything stays hidden behind a CLI flag. I wouldn't recommend anybody using this until the follow-ups are done and even then I wouldn't rush to enable this by default.

@darioush Can you please review this when you can find the time? I would like to merge the basic sandboxing first and address follow-ups mentioned in the PR description with future PRs.

This is to preserve the current behavior as closely as possible for the first itertation.
Remove superfluous test `background_compilation_does_not_block_critical_pool`.
It didn't test anything meaningful.

Remove unnecessary 1 * GB.
@jakmeier

jakmeier commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

I've changed failure management again according to the latest discussions. Now it crashes the node when compilation doesn't work.

Please excuse the git history getting a bit messy. I didn't it clean up and force-pushed this time, since people already started reviewing.

The CI failure is unrelated to my changes and fixed in #16153 .

Comment on lines +347 to +349
Err(VMRunnerError::WasmCompilationUnknownError { debug_message }) => {
panic!("wasm compilation unknown error: {debug_message}");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Compiler failures panic the node

When both daemon compilation attempts fail because workers crash, time out, or return IPC errors, compile_in_subprocess returns WasmCompilationUnknownError, which this match arm handles with an unconditional panic. A repeatable worker failure during contract execution can therefore terminate the node instead of aborting or skipping only the affected state transition.

Fix in Claude Code Fix in Codex

@darioush darioush left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work :)

High level comments:

  • I lean towards 1 binary if possible. This means we don't have to change deploy strategy and there is no possibility of version mismatch between daemon process and neard parent. Also we don't have to worry about finding the binary. One issue here can be jemalloc, which could interfere with memory sandboxing or require some additional settings. Maybe like: .env("MALLOC_CONF", "narenas:1,background_thread:false,retain:false,dirty_decay_ms:0,muzzy_decay_ms:0")
  • I think this feature needs a config that can disable it
  • For seccomp I suggest leaving any further hardening to a separate PR.

Comment thread nearcore/src/lib.rs
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should check something like below?

fn is_usable_compiler_daemon_binary(path: &Path) -> bool {
    fs::metadata(path).is_ok_and(|m| m.is_file())
        && accessat(CWD, path, Access::EXEC_OK, AtFlags::EACCESS).is_ok()
}

/// Number of distinct priority classes; used to size per-class structures.
// Only read by the compiler-daemon pool, which is gated on `wasmtime_vm`.
#[cfg_attr(not(feature = "wasmtime_vm"), allow(dead_code))]
pub(crate) const COUNT: usize = 3;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe a UT can tie this to the enum

///
/// Errors from either compile are dropped.
///
/// Uses `CompilePriority::Critical` if the out-of-process compiler is enabled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Maybe worth clarifying that the next_config compilation uses the background priority

// reach these arms.
//
// TODO: Compilation must become asynchronous before this can
// work with SPICE, where validators endorse before execution.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// work with SPICE, where validators endorse before execution.
// work with SPICE, where chunks are determined before execution.

Comment on lines +4 to +6
//! [`near_vm_runner::compiler_daemon::daemon_main`], making it easy for neard
//! to include a command that directly calls it without the need for a second
//! binary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//! [`near_vm_runner::compiler_daemon::daemon_main`], making it easy for neard
//! to include a command that directly calls it without the need for a second
//! binary.
//! [`near_vm_runner::compiler_daemon::daemon_main`], which is public so neard
//! can call it without the need for an additional binary.

is this what is intended? a bit confused because seems there is a second binary.

Comment thread nearcore/src/lib.rs
) -> Option<PathBuf> {
if let Some(configured_path) = configured_path {
let configured_path = if configured_path.is_relative() {
home_dir.join(configured_path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This join can produce a relative path, since --home is taken verbatim with no canonicalize (neard/src/cli.rs:203-205). Two ways that ends badly, both in the same place.

The checked file is not the executed file. is_usable_compiler_daemon_binary resolves a relative path against the process cwd through fs::metadata, but the spawn sets .current_dir("/") (parent.rs:80), and on Unix the chdir happens before the exec. std calls out exactly this case as platform specific and unstable, and recommends canonicalizing instead (library/std/src/process.rs:936-940).

So neard --home ./localnet/node0 with compiler_daemon_binary_path: "bin/daemon" passes the usability check on ./localnet/node0/bin/daemon and then execs /localnet/node0/bin/daemon. Since the check passed, set_daemon_binary has already run, so is_daemon_configured() is true and every compile routes to a daemon that can never start: two failed spawn attempts, WasmCompilationUnknownError, and a panic under panic = 'abort'. A relative --home would kill the node on the first contract call.

An empty home turns the path into a $PATH lookup. get_default_home() returns an empty path when both NEAR_HOME and HOME are unset (lib.rs:83-94), which happens in containers and bare systemd units. Path::new("").join("near-vm-compiler-daemon") is a bare name with no separator, so exec resolves it through PATH rather than from the file that was checked. env_clear() makes that resolution less predictable, not more.

Both go away by canonicalizing here and refusing a result that is not absolute.

🤖 -- Claude on behalf of Darioush

Comment on lines +285 to +295
while let Some(worker) = inner.idle.pop() {
if worker.is_alive() {
inner.waiters[idx] -= 1;
if !inner.idle.is_empty() {
self.wake_one(&inner);
}
return Ok(worker);
}
// Dead idle worker: release its permit and let it drop (reaped).
inner.live -= 1;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This drain loop frees a permit per dead worker (inner.live -= 1) but never calls wake_one. The caller then consumes exactly one of those permits at step 2. So if N dead workers are drained, N-1 permits become available with no waiter notified.

This looks reachable by design rather than by accident: raise_oom_score_adj in child.rs:203-209 marks workers as the kernel's preferred OOM victims, so several idle workers dying together is the expected behavior under memory pressure. That is exactly when the freed permits matter most.

It is not a permanent stall since the next check_in/discard calls wake_one again. But production requests have no deadline (compilation_request_timeout returns None, parent.rs:185-190), so the waiters stay blocked for as long as the draining caller's compile takes.

Would something like this work?

let mut freed_permits = false;
while let Some(worker) = inner.idle.pop() {
    if worker.is_alive() {
        inner.waiters[idx] -= 1;
        if !inner.idle.is_empty() {
            self.wake_one(&inner);
        }
        return Ok(worker);
    }
    inner.live -= 1;
    freed_permits = true;
}
if freed_permits {
    self.wake_one(&inner);
}

Separately, the dead worker drops at the end of each iteration while inner is still locked. DaemonProcess::drop joins the watchdog thread, kills and wait()s the child, and joins the stderr thread, all under the pool mutex. Moving the dead workers into a local Vec and dropping them after the lock is released would match what Lease::discard already does.

🤖 -- Claude on behalf of Darioush

Comment on lines +67 to +68
/// Per-request retry budget on IPC failure (for example, a worker crash).
const MAX_SPAWN_ATTEMPTS: u32 = 2;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the loop at parent.rs:452 counts request attempts, not spawns. An IPC error on a live worker consumes the budget too. MAX_REQUEST_ATTEMPTS?

🤖 -- Claude on behalf of Darioush

Comment on lines +3 to +5
//! A pool of worker subprocesses serves compilations in parallel, so
//! independent compilations shards run concurrently with independent memory
//! limits . The pool spawns workers lazily up to a configured maximum and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "compilations shards" looks like a word is missing, and there is a stray space before the period on limits .

🤖 -- Claude on behalf of Darioush

Comment on lines +347 to +349
Err(VMRunnerError::WasmCompilationUnknownError { debug_message }) => {
panic!("wasm compilation unknown error: {debug_message}");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The panic reads right for chunk application: stopping beats committing a state transition that other nodes would compute differently.

The concern is the other caller. state_viewer/mod.rs:511 runs this same function for RPC view calls with view_config: Some(..). There is no state transition to protect there and no endorsement to withhold, just a query answer. So an unauthenticated call_function against a contract that is not in the compiled cache reaches this arm, and panic = 'abort' in [profile.release] means the process dies rather than unwinds. A kernel without Landlock gets here on every compile too, since sandbox::apply fails and the child exits (child.rs:31).

Could we keep the panic for apply only?

Err(VMRunnerError::WasmCompilationUnknownError { debug_message })
    if context.view_config.is_none() =>
{
    panic!("wasm compilation unknown error: {debug_message}");
}
Err(VMRunnerError::WasmCompilationUnknownError { debug_message }) => {
    return Ok(VMOutcome::nop_outcome(FunctionCallError::WasmUnknownError {
        msg: debug_message,
    }));
}

Two details if you take this. view_config is moved into context at line 279, so the guard needs context.view_config, the way line 353 does it. And the guard makes the match non-exhaustive, hence the second arm. The view caller already turns outcome.aborted into CallFunctionError::VMError (state_viewer/mod.rs:530-537), so nothing new is needed on that side. The error variant is your call, WasmUnknownError is just the closest one that exists today.

🤖 -- Claude on behalf of Darioush

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants