Files
logos-tutorial/EXTISM_MODULES.md
2026-04-06 13:29:23 -04:00

34 KiB
Raw Permalink Blame History

WebAssembly Modules in Logos

This document describes how WebAssembly (WASM) modules work in the Logos platform — how to build one, how the runtime loads and executes it, and what changes were made across the codebase to enable WASM support.

Table of Contents


Overview

A traditional Logos module is a C++/Qt plugin — a .so or .dylib compiled with CMake, linked against Qt 6, and loaded via QPluginLoader. This works well but requires C++ and Qt knowledge.

WASM modules are a simpler alternative. You write pure computation in Rust (or any language that compiles to wasm32-unknown-unknown), export plain C functions, and the Logos runtime loads the .wasm binary directly via Wasmtime. No C++, no Qt, no CMake, no code generation.

Note on naming: Despite the document title, Logos does not use Extism. The runtime uses Wasmtime directly with raw C ABI exports (extern "C" + #[no_mangle]). Extism's PDK and host SDK are not involved. Functions take and return scalar numeric types — not Extism-style input/output buffers.

What you write:

File Purpose
wasm-guest/src/lib.rs Your module logic in Rust
wasm-guest/Cargo.toml Rust build config
metadata.json Module name, version, type
manifest.json Package manifest for module discovery
flake.nix Nix build orchestration

What you don't write: No C++ source, no Qt headers, no CMakeLists.txt, no C header, no generated code.

Comparison with native Qt modules:

Native Qt Module WASM Module
Language C++ (or any language via C FFI + code generator) Rust (or any language targeting wasm32)
Build system CMake + Nix + logos-module-builder Cargo + Nix (no CMake)
Output .so / .dylib Qt plugin .wasm binary
Host process logos_host logos_host_wasm
Runtime QPluginLoader + Qt MOC Wasmtime
IPC Qt Remote Objects Qt Remote Objects (same)
Types Full Qt type system (QString, QVariantMap, etc.) Scalars only (i32, i64, f32, f64)
Inter-module calls Yes, via LogosAPI Yes, via LogosAPI (same)
File size ~100KB1MB+ ~15KB for a calculator

How It Works (Architecture)

Process Model

Every module — native or WASM — runs in its own process for isolation. The core process (logoscore) spawns a host process for each module:

logoscore (main process)
├── logos_host "capability_module" "/path/to/capability_module_plugin.so"
├── logos_host "chat_module" "/path/to/chat_module_plugin.so"
└── logos_host_wasm "wasm_calc_module" "/path/to/wasm_calc_module.wasm"
                       ↑                     ↑
                   --name arg            --path arg

When logoscore detects a .wasm file (by extension), it spawns logos_host_wasm instead of logos_host. The WASM host process:

  1. Receives an auth token via a local Unix socket (logos_token_<module_name>)
  2. Loads the .wasm binary using the Wasmtime C API — compiles it to native code, creates an engine/store/instance
  3. Discovers exported functions by iterating module exports and caching their type signatures
  4. Registers on the IPC bus via LogosAPI::getProvider()->registerObject() — making it callable by any other module
  5. Enters the Qt event loop to serve remote method calls

IPC: Transparent to Callers

From the caller's perspective, WASM modules are indistinguishable from native modules. The same Qt Remote Objects transport layer carries method calls between processes. A native C++ module calling a WASM module looks exactly like calling any other module:

// This works identically whether "wasm_calc_module" is a .so or a .wasm
LogosAPIClient* client = logosAPI->getClient("wasm_calc_module");
QVariant result = client->invokeRemoteMethod("wasm_calc_module", "add", 3, 5);
// result == QVariant(8)

The call path is:

Caller module (any process)
    → Qt Remote Objects (IPC)
        → logos_host_wasm process
            → ModuleProxy::callRemoteMethod()
                → WasmProviderObject::callMethod()
                    → QVariant args → wasmtime_val_t conversion
                        → wasmtime_func_call()
                            → WASM function executes
                        → wasmtime_val_t result → QVariant conversion
                    ← returns QVariant
                ← returns to ModuleProxy
            ← serialized back over IPC
        ← received by caller
    ← QVariant result

Type System

WASM modules support four scalar types. Each maps bidirectionally between WASM, Rust, Qt, and logoscore -c argument auto-detection:

WASM Type Rust Type Qt/QVariant Type logoscore -c auto-detect
i32 i32 int Integer literals (small)
i64 i64 qlonglong Integer literals
f32 f32 float (stored as double) Decimal literals
f64 f64 double Decimal literals
(void) () QVariant(true)

Conversion rules in WasmProviderObject::callMethod():

  • Incoming QVariant args are converted using toInt(), toLongLong(), toFloat(), or toDouble() based on the WASM function's declared parameter types
  • Return values are converted back: i32QVariant(int), i64QVariant(qlonglong), f32/f64QVariant(double)
  • Void functions (no return value) return QVariant(true)

Export Discovery

When a .wasm module is loaded, WasmProviderObject::discoverExports() iterates all module exports and registers every exported function — except internal/WASI bookkeeping symbols:

Skipped exports:

  • memory, _start, _initialize
  • __data_end, __heap_base, __indirect_function_table
  • Any name starting with __ (double underscore)

Everything else becomes a callable method. The parameter count and types are read from the function's Wasmtime type signature and cached in a QHash<QString, WasmFunc>.

Authentication

WASM modules use the same token-based authentication as native modules:

  1. logoscore spawns logos_host_wasm with --name and --path args
  2. logos_host_wasm creates a QLocalServer on socket logos_token_<name>
  3. logoscore connects and sends a UUID auth token
  4. The WASM host saves the token for "core" and "capability_module"
  5. All subsequent IPC calls include the token, validated by ModuleProxy

Tutorial: Building a WASM Calculator Module

This walks through building the logos-wasm-calc-module — a calculator with add, subtract, multiply, divide, factorial, and fibonacci functions.

Prerequisites

  • A working logos-workspace checkout with Nix installed
  • The ws CLI on your PATH: export PATH="/path/to/workspace/scripts:$PATH"
  • Rust and Cargo (provided automatically by Nix during build)

Step 1: Create the Project Structure

mkdir -p logos-wasm-calc-module/wasm-guest/src
cd logos-wasm-calc-module

Your final directory structure:

logos-wasm-calc-module/
├── metadata.json          # Module metadata
├── manifest.json          # Package manifest for discovery
├── flake.nix              # Nix build
├── flake.lock             # (auto-generated by nix)
├── .gitignore
└── wasm-guest/            # Rust source
    ├── Cargo.toml
    ├── Cargo.lock
    └── src/
        └── lib.rs         # Module logic

Create .gitignore:

result
result-*
wasm-guest/target/

Step 2: Write the Rust Guest Code

2.1 Configure Cargo

Create wasm-guest/Cargo.toml:

[package]
name = "wasm_calc"
version = "1.0.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[profile.release]
opt-level = "s"
lto = true

Key settings:

  • crate-type = ["cdylib"] — produces a C-compatible dynamic library. For wasm32-unknown-unknown, this means a .wasm binary with exported functions.
  • opt-level = "s" — optimize for size. WASM binaries are small; this keeps them minimal (~16KB for a calculator).
  • lto = true — link-time optimization strips unused code, critical for WASM where every byte counts.

No external dependencies. This example has zero crate dependencies, so cargo build --offline works in the Nix sandbox. If you need crates, you'll need to use rustPlatform.buildRustPackage instead of calling cargo directly.

2.2 Write the Module Logic

Create wasm-guest/src/lib.rs:

// Each exported function becomes a callable method in the Logos module.
// Functions use the C ABI and are exported with #[no_mangle] so Wasmtime
// can discover them by name.

#[no_mangle]
pub extern "C" fn add(a: i64, b: i64) -> i64 {
    a + b
}

#[no_mangle]
pub extern "C" fn subtract(a: i64, b: i64) -> i64 {
    a - b
}

#[no_mangle]
pub extern "C" fn multiply(a: i64, b: i64) -> i64 {
    a * b
}

#[no_mangle]
pub extern "C" fn divide(a: i64, b: i64) -> i64 {
    if b == 0 { -1 } else { a / b }
}

#[no_mangle]
pub extern "C" fn factorial(n: i64) -> i64 {
    if n < 0 { return -1; }
    let mut result: i64 = 1;
    for i in 1..=n {
        result = result.checked_mul(i).unwrap_or(-1);
        if result < 0 { return -1; }
    }
    result
}

#[no_mangle]
pub extern "C" fn fibonacci(n: i64) -> i64 {
    if n < 0 { return -1; }
    if n <= 1 { return n; }
    let (mut a, mut b) = (0i64, 1i64);
    for _ in 2..=n {
        let next = a.checked_add(b).unwrap_or(-1);
        if next < 0 { return -1; }
        a = b;
        b = next;
    }
    b
}

Rules for exported functions:

  1. #[no_mangle] — prevents Rust name mangling so Wasmtime finds the function by its plain name
  2. pub extern "C" — uses the C calling convention (standard for WASM exports)
  3. Scalar types only — parameters and return values must be i32, i64, f32, or f64
  4. No panics across FFI — use checked_* operations and return error values instead of panicking. A panic in WASM becomes a trap, which the host logs as an error and returns QVariant() (empty).

Step 3: Configure the Module (metadata.json)

Create metadata.json:

{
  "name": "wasm_calc_module",
  "version": "1.0.0",
  "description": "Calculator module implemented as WebAssembly",
  "author": "Logos Core Team",
  "type": "core",
  "category": "general",
  "main": "wasm_calc_module_plugin",
  "dependencies": []
}

This file follows the same schema as native modules. The type must be "core" for the package manager to discover it as a module (vs. a UI plugin).

Step 4: Create the Package Manifest (manifest.json)

Create manifest.json:

{
  "name": "wasm_calc_module",
  "version": "1.0.0",
  "type": "core",
  "category": "general",
  "description": "Calculator module implemented as WebAssembly",
  "author": "Logos Core Team",
  "main": "wasm_calc_module.wasm",
  "manifestVersion": "0.1.0",
  "dependencies": []
}

The main field tells the package manager which file to load. For WASM modules, this is the .wasm filename. It can be either:

  • A string (simplest) — the .wasm filename, used on all platforms
  • An object mapping platform variants to filenames (useful if you have different builds per platform, though WASM is typically architecture-independent):
"main": {
  "linux-x86_64": "wasm_calc_module.wasm",
  "linux-arm64": "wasm_calc_module.wasm",
  "darwin-arm64": "wasm_calc_module.wasm",
  "darwin-x86_64": "wasm_calc_module.wasm"
}

The string form is preferred for WASM since the same binary runs everywhere.

Step 5: Write the Nix Build (flake.nix)

Create flake.nix:

{
  description = "Calculator module as WebAssembly — loadable by logoscore via logos_host_wasm";

  inputs = {
    logos-nix.url = "github:logos-co/logos-nix";
    nixpkgs.follows = "logos-nix/nixpkgs";
    nix-bundle-lgx.url = "github:logos-co/nix-bundle-lgx";
    logos-package.url = "github:logos-co/logos-package";
    nix-bundle-dir.url = "github:logos-co/nix-bundle-dir";
  };

  outputs = { self, nixpkgs, logos-nix, nix-bundle-lgx, logos-package, nix-bundle-dir }:
    let
      systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
      forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
    in {
      packages = forAllSystems (system:
        let
          pkgs = import nixpkgs { inherit system; };

          # Stage 1: Compile Rust to .wasm
          wasmLib = pkgs.stdenv.mkDerivation {
            pname = "wasm-calc";
            version = "1.0.0";
            src = ./wasm-guest;

            nativeBuildInputs = [ pkgs.cargo pkgs.rustc pkgs.lld ];

            buildPhase = ''
              runHook preBuild
              export HOME=$TMPDIR
              export CARGO_HOME=$TMPDIR/cargo
              mkdir -p $CARGO_HOME
              cargo build --target wasm32-unknown-unknown --release --offline 2>&1
              runHook postBuild
            '';

            installPhase = ''
              runHook preInstall
              mkdir -p $out/lib
              cp target/wasm32-unknown-unknown/release/wasm_calc.wasm $out/lib/
              runHook postInstall
            '';
          };

          # Stage 2: Create module directory with canonical naming
          moduleLib = (pkgs.runCommand "logos-wasm_calc_module-module-lib-1.0.0" {} ''
            mkdir -p $out/lib
            cp ${wasmLib}/lib/wasm_calc.wasm $out/lib/wasm_calc_module.wasm
          '') // { src = ./.; version = "1.0.0"; };

          bundleLgx = nix-bundle-lgx.bundlers.${system}.default;
        in {
          default = moduleLib;
          lib = moduleLib;
          wasm = wasmLib;
          lgx = bundleLgx moduleLib;
        }
      );
    };
}

Build pipeline:

┌──────────────────────────────────────────────────────────────────────┐
│                                                                      │
│  Stage 1: Compile Rust → WASM                                        │
│  ────────────────────────────                                        │
│  wasm-guest/src/lib.rs  ──→  cargo build --target wasm32-unknown-    │
│                               unknown --release                      │
│                          ──→  wasm_calc.wasm (~16KB)                 │
│                                                                      │
│  Stage 2: Package as module                                          │
│  ───────────────────────────                                         │
│  wasm_calc.wasm  ──→  renamed to wasm_calc_module.wasm               │
│                  ──→  placed in lib/ directory                        │
│                                                                      │
│  Stage 3 (optional): Bundle as .lgx                                  │
│  ──────────────────────────────────                                  │
│  module lib/  ──→  nix-bundle-lgx  ──→  wasm_calc_module.lgx        │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘

Key points:

  • The Rust target wasm32-unknown-unknown ships with every Rust compiler — no additional sysroot or WASI SDK needed
  • --offline works because there are no external crate dependencies
  • lld is included as a linker (Cargo uses it for WASM targets)
  • The module name in the output (wasm_calc_module.wasm) must match the main field in manifest.json

Step 6: Build

# Initialize git (nix flakes require tracked files)
git init
git add -A

# Build
nix build

# Check the output
ls -la result/lib/
# -r-xr-xr-x 1 ... 15945 ... wasm_calc_module.wasm

file result/lib/wasm_calc_module.wasm
# WebAssembly (wasm) binary module version 0x1 (MVP)

Step 7: Test with logoscore

To test, you need two things:

  1. A logoscore build that includes logos_host_wasm (the WASM host binary)
  2. The module installed in the directory layout the package manager expects

7.1 Build logoscore with WASM support

The logos_host_wasm binary is built by logos-liblogos when Wasmtime is available (it's a build dependency). Build logoscore with the local logos-liblogos:

nix build path:./repos/logos-logoscore-cli#cli \
  --override-input logos-liblogos path:./repos/logos-liblogos \
  -o /tmp/logoscore-result

Verify logos_host_wasm is in the closure:

WASM_HOST=$(nix-store -qR /tmp/logoscore-result \
  | xargs -I{} sh -c 'test -f {}/bin/logos_host_wasm && echo {}/bin/logos_host_wasm' 2>/dev/null \
  | head -1)
echo "WASM host: $WASM_HOST"

7.2 Set up the module directory

The package manager expects each module in a subdirectory with a manifest.json:

mkdir -p /tmp/test-modules/wasm_calc_module
cp result/lib/wasm_calc_module.wasm /tmp/test-modules/wasm_calc_module/
cp manifest.json /tmp/test-modules/wasm_calc_module/

Resulting structure:

/tmp/test-modules/
└── wasm_calc_module/
    ├── manifest.json
    └── wasm_calc_module.wasm

7.3 Run with logoscore

LOGOS_HOST_WASM_PATH="$WASM_HOST" /tmp/logoscore-result/bin/logoscore \
  -m /tmp/test-modules \
  -l wasm_calc_module \
  -c "wasm_calc_module.add(3, 5)" \
  -c "wasm_calc_module.multiply(6, 7)" \
  -c "wasm_calc_module.factorial(10)" \
  -c "wasm_calc_module.fibonacci(20)" \
  --quit-on-finish

Expected output:

Method call successful. Result: 8
Method call successful. Result: 42
Method call successful. Result: 3628800
Method call successful. Result: 6765

Why LOGOS_HOST_WASM_PATH? The logoscore-cli flake.nix currently sets LOGOS_HOST_PATH for native modules but does not yet set LOGOS_HOST_WASM_PATH. The plugin launcher looks for the WASM host binary in three places: (1) the LOGOS_HOST_WASM_PATH env var, (2) next to the logoscore binary, (3) ../bin/ relative to the modules directory. Until the flake is fixed, the env var is the simplest workaround.

Use -v for verbose output to debug loading issues:

LOGOS_HOST_WASM_PATH="$WASM_HOST" /tmp/logoscore-result/bin/logoscore -v \
  -m /tmp/test-modules -l wasm_calc_module \
  -c "wasm_calc_module.add(3, 5)" --quit-on-finish

Step 8: Package with lgx

lgx create wasm_calc_module
lgx add wasm_calc_module.lgx -v linux-x86_64 -f result/lib/wasm_calc_module.wasm
lgx add wasm_calc_module.lgx -v darwin-arm64 -f result/lib/wasm_calc_module.wasm
lgx verify wasm_calc_module.lgx

Or use the Nix-based bundler (builds the .lgx automatically):

nix build .#lgx

Calling a WASM Module from Another Module

WASM modules participate in the same IPC system as native modules. From any Qt module with a LogosAPI* pointer:

// Raw call (works from any module — native or WASM)
LogosAPIClient* client = logosAPI->getClient("wasm_calc_module");
QVariant result = client->invokeRemoteMethod("wasm_calc_module", "add", 10, 20);
qDebug() << result; // QVariant(qlonglong, 30)

// Check for errors
if (!result.isValid()) {
    qWarning() << "Call failed (unknown method, timeout, or WASM trap)";
}

From logoscore -c (command line):

logoscore -m /path/to/modules \
  -l wasm_calc_module,my_other_module \
  -c "wasm_calc_module.add(10, 20)"

The caller does not need to know whether the target module is a .so plugin or a .wasm binary. The IPC transport and authentication are identical.


Cross-Repo Changes That Enable WASM Support

WASM module support required changes across five repositories. This section describes each change in detail.

logos-liblogos

The bulk of the implementation. Three new files and two modified files.

New: src/logos_host_wasm/logos_host_wasm.cpp

The WASM host process entry point — analogous to logos_host (which loads native Qt plugins). The main() function:

  1. Parses arguments via CLI11: --name <module_name> --path <wasm_file>
  2. Receives an auth token by creating a QLocalServer on socket logos_token_<name> and waiting up to 10 seconds for the core process to connect and send a UUID
  3. Instantiates WasmProviderObject with the .wasm path
  4. Registers on IPC via LogosAPI::getProvider()->registerObject(name, wasmProvider), which wraps the provider in a ModuleProxy and publishes it on the Qt Remote Objects bus
  5. Saves auth tokens for "core" and "capability_module" in TokenManager
  6. Enters the Qt event loop to serve incoming remote method calls indefinitely

New: src/logos_host_wasm/wasm_provider_object.h

Declares WasmProviderObject, which inherits LogosProviderObject (from logos-cpp-sdk). Key members:

class WasmProviderObject : public LogosProviderObject {
    // Each discovered WASM function
    struct WasmFunc {
        wasmtime_func_t func;       // function handle
        wasm_functype_t* type;      // parameter/return type info
        int paramCount;
        int resultCount;
    };

    wasm_engine_t* m_engine;            // Wasmtime engine (manages compilation)
    wasmtime_store_t* m_store;          // Wasmtime store (execution state)
    wasmtime_module_t* m_module;        // compiled module
    wasmtime_instance_t m_instance;     // instantiated module
    QHash<QString, WasmFunc> m_functions;  // exported functions cache
};

New: src/logos_host_wasm/wasm_provider_object.cpp

The implementation, with four key sections:

loadModule() — Reads the .wasm binary, creates a Wasmtime engine, compiles the module to native code, creates a store (no WASI — pure computation), and instantiates the module via a linker. Error handling covers both compilation errors and instantiation traps.

discoverExports() — Iterates all module exports via wasmtime_instance_export_nth(). For each exported function (skipping internal symbols like memory, _start, __heap_base, etc.), it reads the function's type signature, counts parameters and results, and caches the handle in m_functions.

callMethod() — Looks up the function by name, converts QVariant arguments to wasmtime_val_t based on declared parameter types (i32/i64/f32/f64), calls the function via wasmtime_func_call(), and converts the result back to QVariant. Handles both errors and runtime traps gracefully.

getMethods() — Returns a QJsonArray describing all exported functions with their names, parameter types, return types, and signatures. This is what lm and other introspection tools use (though lm currently only works with Qt plugins, not .wasm files).

Modified: src/logos_core/plugin_launcher.cpp

Added WASM detection and host binary resolution:

// In PluginLauncher::launch():
bool isWasm = pluginPath.endsWith(".wasm", Qt::CaseInsensitive);
QString hostPath = isWasm
    ? resolveLogosHostWasmPath(pluginsDirs)
    : resolveLogosHostPath(pluginsDirs);

Added resolveLogosHostWasmPath() which uses resolveHostBinary("logos_host_wasm", "LOGOS_HOST_WASM_PATH", pluginsDirs) — checking the env var, then the application directory, then ../bin/ relative to the plugins directory.

Modified: src/logos_core/plugin_registry.cpp

Added special handling for .wasm files during module discovery. When the package manager returns a module whose mainFilePath ends in .wasm, the registry skips QPluginLoader introspection (which would fail on a WASM binary) and registers it directly from manifest metadata:

if (mainFilePath.endsWith(".wasm", Qt::CaseInsensitive)) {
    PluginInfo info;
    info.path = mainFilePath;
    // Parse dependencies from manifest JSON
    if (mod.contains("dependencies") && mod["dependencies"].is_array()) {
        for (const auto& dep : mod["dependencies"])
            info.dependencies.append(QString::fromStdString(dep.get<std::string>()));
    }
    m_plugins.insert(name, info);
    continue;  // skip Qt plugin processing
}

Modified: src/CMakeLists.txt

Conditional build of logos_host_wasm when Wasmtime is available:

find_library(WASMTIME_LIBRARY NAMES wasmtime)
find_path(WASMTIME_INCLUDE_DIR NAMES wasmtime.h)

if(WASMTIME_LIBRARY AND WASMTIME_INCLUDE_DIR)
    add_executable(logos_host_wasm
        logos_host_wasm/logos_host_wasm.cpp
        logos_host_wasm/wasm_provider_object.h
        logos_host_wasm/wasm_provider_object.cpp
    )
    target_link_libraries(logos_host_wasm PRIVATE
        Qt6::Core Qt6::RemoteObjects Qt6::Network
        logos_sdk CLI11::CLI11 ${WASMTIME_LIBRARY}
    )
endif()

The logos_host_wasm target is also conditionally added to the install targets in the top-level CMakeLists.txt.

Modified: nix/default.nix

Added pkgs.wasmtime to buildInputs so the Wasmtime C library and headers are available during CMake configuration.

logos-cpp-sdk

cpp/logos_provider_object.h

This file defines LogosProviderObject — the abstract base class that WasmProviderObject extends. It was not modified for WASM support; the existing interface was sufficient:

class LogosProviderObject {
public:
    virtual QVariant callMethod(const QString& methodName, const QVariantList& args) = 0;
    virtual bool informModuleToken(const QString& moduleName, const QString& token) = 0;
    virtual QJsonArray getMethods() = 0;
    virtual void setEventListener(EventCallback callback) = 0;
    virtual void init(void* apiInstance) = 0;
    virtual QString providerName() const = 0;
    virtual QString providerVersion() const = 0;
};

The key design decision was that LogosProviderObject is not Qt-specific — it uses QVariant for data but doesn't require Q_OBJECT, MOC, or QPluginLoader. This made it possible to implement a Wasmtime-backed provider without any changes to the SDK.

logos-package-manager

src/package_manager_lib.cpp

No WASM-specific changes were needed. The scanInstalledByTypes() function already works generically:

  1. Scans module directories for subdirectories containing manifest.json
  2. Reads the main field and resolves it to a file path (trying platform variants)
  3. Returns the resolved mainFilePath — the registry then checks the extension

The .wasm extension handling happens entirely in logos-liblogos (plugin_registry.cpp). The package manager is extension-agnostic.

logos-logoscore-cli

flake.nix

The logoscore-cli flake wraps the binary with environment variables via qtWrapperArgs:

qtWrapperArgs = [
  "--set LOGOS_HOST_PATH ${liblogos}/bin/logos_host"
];

Missing: A corresponding --set LOGOS_HOST_WASM_PATH ${liblogos}/bin/logos_host_wasm entry. This is why the LOGOS_HOST_WASM_PATH environment variable must be set manually when testing. This is a known gap that should be fixed.


Reference

Type Mappings

WASM Rust C Qt (QVariant) getMethods() string
i32 i32 int32_t int "int"
i64 i64 int64_t qlonglong "qlonglong"
f32 f32 float double (via float) "float"
f64 f64 double double "double"
(none) () void QVariant(true) "void"

Function Signature Requirements

For a function to be discoverable by the Logos WASM host:

Requirement Example
#[no_mangle] attribute Prevents Rust name mangling
pub extern "C" C calling convention
Scalar parameter types i32, i64, f32, f64 only
Scalar or void return type Same four types, or no return
No panics across FFI Use checked_* or return error values

Valid:

#[no_mangle] pub extern "C" fn add(a: i64, b: i64) -> i64 { a + b }
#[no_mangle] pub extern "C" fn pi() -> f64 { std::f64::consts::PI }
#[no_mangle] pub extern "C" fn noop() { }

Invalid (will not work):

// String parameters — not supported
pub extern "C" fn greet(name: *const c_char) -> *const c_char { ... }

// Struct return — not a scalar type
pub extern "C" fn get_point() -> Point { ... }

Filtered Exports

These export names are automatically skipped during discovery:

Export Reason
memory WASM linear memory (not a function)
_start WASI entry point
_initialize WASI initialization
__data_end WASM data segment boundary
__heap_base WASM heap start
__indirect_function_table WASM function table
__* (any double-underscore prefix) Internal/compiler-generated symbols

Platform Variant Names

The package manager tries these variant names in order when resolving manifest.json main entries:

Platform Primary Alias Dev variant
Linux x86_64 linux-x86_64 linux-amd64 linux-x86_64-dev
Linux ARM64 linux-aarch64 linux-arm64 linux-aarch64-dev
macOS ARM64 darwin-arm64 aarch64-darwin darwin-arm64-dev
macOS x86_64 darwin-x86_64 x86_64-darwin darwin-x86_64-dev

For WASM modules, the binary is architecture-independent, so a simple string main field is preferred over per-variant entries.


Limitations and Future Work

Limitation Details
Scalars only No string, map, list, or complex type support. Functions can only take and return i32, i64, f32, f64.
No WASI Modules target wasm32-unknown-unknown (pure computation). No filesystem, network, or clock access from WASM.
No lm support The lm module inspector uses QPluginLoader and cannot introspect .wasm files. Use logoscore -v to see discovered methods.
No module-builder template logos-module-builder has templates for native C++ modules but no WASM template yet. WASM modules must be structured manually.
LOGOS_HOST_WASM_PATH workaround The logoscore-cli flake.nix doesn't set this env var yet. Must be set manually or the binary placed next to logoscore.
No events WasmProviderObject has an EventCallback but WASM functions are synchronous and cannot emit events.
Single return value WASM functions can return at most one value. Multi-return WASM is not yet supported.

Troubleshooting

"Module not found in known plugins: wasm_calc_module"

The package manager didn't discover the module. Check:

  1. The module directory structure is correct: <modules-dir>/<module_name>/manifest.json + .wasm file
  2. manifest.json has "type": "core" (not "ui" or other types)
  3. The main field in manifest.json matches the actual .wasm filename
  4. The .wasm file actually exists at the resolved path

"logos_host_wasm not found"

The WASM host binary isn't where the plugin launcher expects it. Fix:

# Find it in the nix store
WASM_HOST=$(nix-store -qR /path/to/logoscore-result \
  | xargs -I{} sh -c 'test -f {}/bin/logos_host_wasm && echo {}/bin/logos_host_wasm' 2>/dev/null \
  | head -1)

# Set the env var
export LOGOS_HOST_WASM_PATH="$WASM_HOST"

Or build logos-liblogos directly and point to it:

cd repos/logos-liblogos && nix build .#logos-liblogos
export LOGOS_HOST_WASM_PATH="$(pwd)/result/bin/logos_host_wasm"

"WasmProviderObject: compile error"

The .wasm binary is invalid or uses unsupported features. Check:

  • Built with --target wasm32-unknown-unknown (not wasm32-wasi)
  • The crate-type is ["cdylib"], not ["rlib"] or ["lib"]
  • No unsupported WASM proposals are used (the default Wasmtime config is used)

"WasmProviderObject: call trap"

A runtime trap during function execution. Common causes:

  • Integer overflow — use checked_mul(), checked_add(), etc.
  • Division by zero — WASM traps on i32.div_s / i64.div_s with zero divisor
  • Unreachable — Rust panic!() compiles to unreachable in WASM, which traps
  • Stack overflow — deep recursion in WASM hits the stack limit

"Method call returned invalid result"

The method call failed — the module isn't loaded, the method doesn't exist, or IPC timed out. Run with -v to see the full debug log.

Build fails with "cargo build --offline" error

Your Rust code has external crate dependencies. Either:

  1. Remove the dependencies (if possible)
  2. Use rustPlatform.buildRustPackage in your flake.nix with a cargoHash instead of calling cargo directly