eryx-runtime 0.4.9

Python WASM runtime component for the eryx sandbox
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
// Build scripts should panic on failure, so expect/unwrap are appropriate
#![allow(clippy::expect_used, clippy::unwrap_used)]

//! Build script for eryx-runtime.
//!
//! This builds:
//! 1. The eryx-wasm-runtime shared library (liberyx_runtime.so) that implements
//!    the wit-dylib interpreter interface for Python execution.
//! 2. The final WASM component (runtime.wasm) by linking all libraries together.
//!
//! The build process:
//! 1. Compile eryx-wasm-runtime staticlib with PIC for wasm32-wasip1
//! 2. Compile clock_stubs.c with WASI SDK clang
//! 3. Link into liberyx_runtime.so
//! 4. Generate wit-dylib bindings
//! 5. Link all libraries into final runtime.wasm component

use std::env;
use std::path::PathBuf;
use std::process::Command;

use wit_component::{StringEncoding, embed_component_metadata};

fn main() {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    let wasm_runtime_dir = manifest_dir.parent().unwrap().join("eryx-wasm-runtime");
    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set"));

    // Rerun if eryx-wasm-runtime sources change
    println!(
        "cargo::rerun-if-changed={}",
        wasm_runtime_dir.join("src").display()
    );
    println!(
        "cargo::rerun-if-changed={}",
        wasm_runtime_dir.join("clock_stubs.c").display()
    );
    println!(
        "cargo::rerun-if-changed={}",
        wasm_runtime_dir.join("Cargo.toml").display()
    );
    // Rerun if WIT changes
    println!(
        "cargo::rerun-if-changed={}",
        manifest_dir.join("wit").display()
    );
    // Rerun if the build flag changes
    println!("cargo::rerun-if-env-changed=BUILD_ERYX_RUNTIME");
    // Rerun if pre-built artifacts change
    println!(
        "cargo::rerun-if-changed={}",
        manifest_dir.join("prebuilt").display()
    );
    // Rerun if docs.rs env changes
    println!("cargo::rerun-if-env-changed=DOCS_RS");

    // docs.rs sandbox: no WASM build tools available, write empty placeholders
    if env::var("DOCS_RS").is_ok() {
        if env::var("CARGO_FEATURE_PREINIT").is_ok() {
            std::fs::write(out_dir.join("liberyx_runtime.so.zst"), b"")
                .expect("failed to write docs.rs placeholder runtime");
            std::fs::write(out_dir.join("liberyx_bindings.so.zst"), b"")
                .expect("failed to write docs.rs placeholder bindings");
        }
        return;
    }

    // Only build when explicitly requested via BUILD_ERYX_RUNTIME env var
    // Previously we also built in release mode, but that causes issues in CI where
    // we pre-build the WASM and don't want clippy/doc to try rebuilding it
    let build_requested = env::var("BUILD_ERYX_RUNTIME").is_ok();
    let preinit = env::var("CARGO_FEATURE_PREINIT").is_ok();

    // Check for pre-built late-linking artifacts (for CI)
    let prebuilt_dir = manifest_dir.join("prebuilt");
    let prebuilt_runtime = prebuilt_dir.join("liberyx_runtime.so.zst");
    let prebuilt_bindings = prebuilt_dir.join("liberyx_bindings.so.zst");
    let has_prebuilt = prebuilt_runtime.exists() && prebuilt_bindings.exists();

    // Check if late-linking artifacts already exist in OUT_DIR (from previous build)
    let out_runtime_zst = out_dir.join("liberyx_runtime.so.zst");
    let out_bindings_zst = out_dir.join("liberyx_bindings.so.zst");
    let has_out_artifacts = out_runtime_zst.exists() && out_bindings_zst.exists();

    if build_requested {
        // Full build requested - build everything from scratch
        let runtime_so = build_wasm_runtime(&wasm_runtime_dir);
        build_component(&manifest_dir, &runtime_so);
    } else if preinit {
        // preinit feature (and native-extensions which implies it) needs .so.zst files in OUT_DIR
        //
        // IMPORTANT: Always prefer prebuilt/ artifacts over cached OUT_DIR artifacts!
        // The Rust cache may contain stale OUT_DIR artifacts from a previous build
        // (e.g., from main branch) that don't match the current branch's WIT.
        // Since prebuilt/ comes from the same CI run's build-eryx-runtime job,
        // it's guaranteed to match the current branch.
        if has_prebuilt {
            // Use pre-built artifacts from prebuilt/ directory (CI)
            eprintln!("Using pre-built late-linking artifacts from prebuilt/");
            std::fs::copy(&prebuilt_runtime, &out_runtime_zst)
                .expect("failed to copy prebuilt runtime");
            std::fs::copy(&prebuilt_bindings, &out_bindings_zst)
                .expect("failed to copy prebuilt bindings");
        } else if has_out_artifacts {
            // No prebuilt available, use existing OUT_DIR artifacts (local dev)
            eprintln!("Using existing late-linking artifacts from OUT_DIR");
        } else {
            // No artifacts anywhere, need to build from scratch
            eprintln!("Building late-linking artifacts from scratch...");
            let runtime_so = build_wasm_runtime(&wasm_runtime_dir);
            build_component(&manifest_dir, &runtime_so);
        }
    }
}

/// Build the eryx-wasm-runtime shared library.
/// Returns the path to the built liberyx_runtime.so.
fn build_wasm_runtime(wasm_runtime_dir: &PathBuf) -> PathBuf {
    // Use a separate target directory inside OUT_DIR to avoid cargo lock contention
    // This is the key insight from componentize-py - nested cargo must use different target
    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set"));
    let nested_target_dir = out_dir.join("wasm-runtime-target");

    std::fs::create_dir_all(&out_dir).expect("failed to create output directory");

    // Find WASI SDK
    let wasi_sdk = find_wasi_sdk().expect(
        "WASI SDK not found. Install with: mise install github:WebAssembly/wasi-sdk@wasi-sdk-27",
    );
    let clang = wasi_sdk.join("bin/clang");
    let sysroot = wasi_sdk.join("share/wasi-sysroot");

    eprintln!("Building eryx-wasm-runtime...");
    eprintln!("  WASI SDK: {}", wasi_sdk.display());

    // Build eryx-wasm-runtime staticlib with PIC for wasm32-wasip1
    // Strip RUST*/CARGO* env vars to avoid cargo lock contention
    let mut cmd = Command::new("rustup");
    cmd.current_dir(wasm_runtime_dir)
        .arg("run")
        .arg("nightly")
        .arg("cargo")
        .arg("build")
        .arg("-Z")
        .arg("build-std=panic_abort,std")
        .arg("--target")
        .arg("wasm32-wasip1")
        .arg("--release");

    // Strip inherited Rust/Cargo env vars to prevent lock contention
    for (key, _) in env::vars_os() {
        if let Some(key_str) = key.to_str()
            && (key_str.starts_with("RUST") || key_str.starts_with("CARGO"))
        {
            cmd.env_remove(&key);
        }
    }

    // Set the env vars we need
    // IMPORTANT: Use separate target dir to avoid cargo lock contention
    cmd.env("RUSTFLAGS", "-C relocation-model=pic");
    cmd.env("CARGO_TARGET_DIR", &nested_target_dir);

    let status = cmd
        .status()
        .expect("failed to run cargo build for eryx-wasm-runtime");
    if !status.success() {
        panic!("cargo build for eryx-wasm-runtime failed");
    }

    let staticlib = nested_target_dir.join("wasm32-wasip1/release/liberyx_wasm_runtime.a");
    if !staticlib.exists() {
        panic!("staticlib not found at {}", staticlib.display());
    }

    eprintln!("Compiling clock stubs...");

    // Compile clock_stubs.c
    let clock_stubs_o = out_dir.join("clock_stubs.o");
    let status = Command::new(&clang)
        .arg("--target=wasm32-wasip1")
        .arg(format!("--sysroot={}", sysroot.display()))
        .arg("-fPIC")
        .arg("-c")
        .arg(wasm_runtime_dir.join("clock_stubs.c"))
        .arg("-o")
        .arg(&clock_stubs_o)
        .status()
        .expect("failed to run clang");
    if !status.success() {
        panic!("clang failed to compile clock_stubs.c");
    }

    eprintln!("Linking shared library...");

    // Link shared library
    let runtime_so = out_dir.join("liberyx_runtime.so");
    let status = Command::new(&clang)
        .arg("--target=wasm32-wasip1")
        .arg(format!("--sysroot={}", sysroot.display()))
        .arg("-shared")
        .arg("-Wl,--allow-undefined")
        .arg("-o")
        .arg(&runtime_so)
        .arg("-Wl,--whole-archive")
        .arg(&staticlib)
        .arg("-Wl,--no-whole-archive")
        .arg(&clock_stubs_o)
        .status()
        .expect("failed to link shared library");
    if !status.success() {
        panic!("clang failed to link shared library");
    }

    // Also copy to a stable location in the wasm-runtime crate
    let stable_location = wasm_runtime_dir.join("target/liberyx_runtime.so");
    std::fs::create_dir_all(wasm_runtime_dir.join("target")).ok();
    std::fs::copy(&runtime_so, &stable_location).expect("failed to copy runtime.so");

    eprintln!("Built: {}", runtime_so.display());
    eprintln!("Copied to: {}", stable_location.display());

    // Export the path for downstream use
    println!("cargo::rustc-env=ERYX_RUNTIME_SO={}", runtime_so.display());

    // Also compress for late-linking support
    let runtime_bytes = std::fs::read(&runtime_so).expect("failed to read runtime.so");
    let compressed = zstd::encode_all(runtime_bytes.as_slice(), 19).expect("compress failed");
    let compressed_path = out_dir.join("liberyx_runtime.so.zst");
    std::fs::write(&compressed_path, &compressed).expect("failed to write compressed runtime");
    eprintln!(
        "Compressed runtime: {} bytes -> {} bytes",
        runtime_bytes.len(),
        compressed.len()
    );

    runtime_so
}

/// Decompress .zst files in libs/ to libs/decompressed/
fn decompress_libs(manifest_dir: &std::path::Path) {
    let libs_dir = manifest_dir.join("libs");
    let decompressed_dir = libs_dir.join("decompressed");

    std::fs::create_dir_all(&decompressed_dir).expect("failed to create decompressed dir");

    // List of files to decompress
    let files = [
        "libc.so",
        "libc++.so",
        "libc++abi.so",
        "libpython3.14.so",
        "libwasi-emulated-process-clocks.so",
        "libwasi-emulated-signal.so",
        "libwasi-emulated-mman.so",
        "libwasi-emulated-getpid.so",
        "wasi_snapshot_preview1.reactor.wasm",
    ];

    for file in &files {
        let compressed_path = libs_dir.join(format!("{}.zst", file));
        let decompressed_path = decompressed_dir.join(file);

        // Skip if already decompressed and newer than compressed
        if decompressed_path.exists() {
            let compressed_meta = std::fs::metadata(&compressed_path).ok();
            let decompressed_meta = std::fs::metadata(&decompressed_path).ok();

            if let (Some(c), Some(d)) = (compressed_meta, decompressed_meta)
                && let (Ok(c_time), Ok(d_time)) = (c.modified(), d.modified())
                && d_time >= c_time
            {
                continue;
            }
        }

        eprintln!("Decompressing {}...", file);
        let compressed = std::fs::read(&compressed_path)
            .unwrap_or_else(|e| panic!("failed to read {}: {}", compressed_path.display(), e));
        let decompressed = zstd::decode_all(compressed.as_slice())
            .unwrap_or_else(|e| panic!("failed to decompress {}: {}", file, e));
        std::fs::write(&decompressed_path, &decompressed)
            .unwrap_or_else(|e| panic!("failed to write {}: {}", decompressed_path.display(), e));
    }
}

/// Build the WASM component by linking all libraries together.
fn build_component(manifest_dir: &std::path::Path, runtime_so: &std::path::Path) {
    eprintln!("Building WASM component...");

    // Ensure libs are decompressed
    decompress_libs(manifest_dir);

    let libs_dir = manifest_dir.join("libs/decompressed");

    // Load required shared libraries
    let libc = std::fs::read(libs_dir.join("libc.so")).expect("failed to read libc.so");
    let libcxx = std::fs::read(libs_dir.join("libc++.so")).expect("failed to read libc++.so");
    let libcxxabi =
        std::fs::read(libs_dir.join("libc++abi.so")).expect("failed to read libc++abi.so");
    let wasi_clocks = std::fs::read(libs_dir.join("libwasi-emulated-process-clocks.so"))
        .expect("failed to read libwasi-emulated-process-clocks.so");
    let wasi_signal = std::fs::read(libs_dir.join("libwasi-emulated-signal.so"))
        .expect("failed to read libwasi-emulated-signal.so");
    let wasi_mman = std::fs::read(libs_dir.join("libwasi-emulated-mman.so"))
        .expect("failed to read libwasi-emulated-mman.so");
    let wasi_getpid = std::fs::read(libs_dir.join("libwasi-emulated-getpid.so"))
        .expect("failed to read libwasi-emulated-getpid.so");
    let libpython =
        std::fs::read(libs_dir.join("libpython3.14.so")).expect("failed to read libpython3.14.so");
    let adapter = std::fs::read(libs_dir.join("wasi_snapshot_preview1.reactor.wasm"))
        .expect("failed to read wasi_snapshot_preview1.reactor.wasm");

    // Load our runtime
    let runtime = std::fs::read(runtime_so).expect("failed to read liberyx_runtime.so");

    // Parse WIT directory (includes deps/)
    let wit_dir = manifest_dir.join("wit");
    let mut resolve = wit_parser::Resolve::default();
    let (pkg_id, _) = resolve
        .push_dir(&wit_dir)
        .expect("failed to parse WIT directory");
    // Select the sandbox world from the eryx:sandbox package
    let world_id = resolve
        .select_world(&[pkg_id], Some("sandbox"))
        .expect("failed to select world");

    // Generate bindings pointing to our runtime
    let mut opts = wit_dylib::DylibOpts {
        interpreter: Some("liberyx_runtime.so".to_string()),
        async_: wit_dylib::AsyncFilterSet::default(),
    };

    let mut bindings = wit_dylib::create(&resolve, world_id, Some(&mut opts));
    embed_component_metadata(&mut bindings, &resolve, world_id, StringEncoding::UTF8)
        .expect("failed to embed component metadata");

    // Also compress bindings for late-linking support
    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set"));
    let bindings_compressed = zstd::encode_all(bindings.as_slice(), 19).expect("compress failed");
    let bindings_path = out_dir.join("liberyx_bindings.so.zst");
    std::fs::write(&bindings_path, &bindings_compressed).expect("failed to write bindings");
    eprintln!(
        "Compressed bindings: {} bytes -> {} bytes",
        bindings.len(),
        bindings_compressed.len()
    );

    // Link all libraries together
    // Order matters! Dependencies must come before dependents
    let linker = wit_component::Linker::default()
        .validate(true)
        .use_built_in_libdl(true)
        // WASI emulation libs
        .library("libwasi-emulated-process-clocks.so", &wasi_clocks, false)
        .expect("failed to add wasi-clocks")
        .library("libwasi-emulated-signal.so", &wasi_signal, false)
        .expect("failed to add wasi-signal")
        .library("libwasi-emulated-mman.so", &wasi_mman, false)
        .expect("failed to add wasi-mman")
        .library("libwasi-emulated-getpid.so", &wasi_getpid, false)
        .expect("failed to add wasi-getpid")
        // C/C++ runtime
        .library("libc.so", &libc, false)
        .expect("failed to add libc")
        .library("libc++abi.so", &libcxxabi, false)
        .expect("failed to add libc++abi")
        .library("libc++.so", &libcxx, false)
        .expect("failed to add libc++")
        // Python
        .library("libpython3.14.so", &libpython, false)
        .expect("failed to add libpython")
        // Our runtime and bindings
        .library("liberyx_runtime.so", &runtime, false)
        .expect("failed to add eryx runtime")
        .library("liberyx_bindings.so", &bindings, false)
        .expect("failed to add bindings")
        // WASI adapter
        .adapter("wasi_snapshot_preview1", &adapter)
        .expect("failed to add WASI adapter");

    let component = linker.encode().expect("failed to encode component");

    // Write the component to runtime.wasm in the crate directory
    let component_path = manifest_dir.join("runtime.wasm");
    std::fs::write(&component_path, &component).expect("failed to write runtime.wasm");

    eprintln!(
        "Built component: {} ({} bytes)",
        component_path.display(),
        component.len()
    );

    // Export the path for downstream use
    println!(
        "cargo::rustc-env=ERYX_RUNTIME_WASM={}",
        component_path.display()
    );
}

/// Find WASI SDK in order of preference:
/// 1. WASI_SDK_PATH environment variable
/// 2. mise-managed installation
/// 3. Local project installation
fn find_wasi_sdk() -> Option<PathBuf> {
    // Check explicit env var
    if let Ok(path) = env::var("WASI_SDK_PATH") {
        let path = PathBuf::from(path);
        if path.join("bin/clang").exists() {
            return Some(path);
        }
    }

    // Try mise-managed WASI SDK
    if let Ok(output) = Command::new("mise")
        .args(["where", "github:WebAssembly/wasi-sdk"])
        .output()
        && output.status.success()
    {
        let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
        if path.join("bin/clang").exists() {
            return Some(path);
        }
    }

    // Try local project installation
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").ok()?);
    let workspace_root = manifest_dir.parent()?.parent()?;
    let local_path = workspace_root.join(".wasi-sdk/wasi-sdk-27.0-x86_64-linux");
    if local_path.join("bin/clang").exists() {
        return Some(local_path);
    }

    None
}