Skip to content

Instantly share code, notes, and snippets.

@TerrorBite
Created April 29, 2026 14:03
Show Gist options
  • Select an option

  • Save TerrorBite/bb2d7fb2bc56034c42398c1e8a5de001 to your computer and use it in GitHub Desktop.

Select an option

Save TerrorBite/bb2d7fb2bc56034c42398c1e8a5de001 to your computer and use it in GitHub Desktop.
LethargicOS WASM testing

Executables

Introduction

LethargicOS supports (will support) two types of executables. These are:

  • Native executables (currently supported)
  • WASM executables (not yet implemented)

Preamble

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.

The key words "MUST (BUT WE KNOW YOU WON'T)", "SHOULD CONSIDER", "REALLY SHOULD NOT", "OUGHT TO", "WOULD PROBABLY", "MAY WISH TO", "COULD", "POSSIBLE", and "MIGHT" in this document are to be interpreted as described in RFC 6919.

Native Executables

Native LethargicOS executables are implemented as AMD modules (Asynchronous Module Definition). For more information about AMD, see: https://requirejs.org/docs/whyamd.html

To be considered a valid executable, an AMD module MUST conform to the following convention:

  • The AMD module MUST use a named callback function called executable. This is equivalent to the magic number in the header of an ELF or PE binary. If the callback is anonymous or is not named correctly, then LethargicOS will raise ENOEXEC: Exec format error and will fail to run the executable.
  • The executable() callback MUST return an async function named main. This is used as an additional sanity check, and is equivalent to finding the entry point in an ELF/PE binary.
  • The main() function MUST return a Promise which resolves when the process exits. If the promise rejects, then the executable has crashed (and is also considered to have exited), and the OS will react accordingly. The simple and RECOMMENDED way to implement this is to declare main() as async. If main() doesn't return a Promise, then it will be considered to have exited immediately.

Additionally, executables SHOULD NOT do any of the following:

** TODO **

The following is RECOMMENDED, but not enforced:

  • All executables OUGHT TO make use of Strict Mode ("use strict").
  • The promise returned by main() SHOULD resolve to an 8-bit integer (0-255), which will be used as the exit code of the process, with 0 representing success. If the return value is undefined, null or otherwise not a number, the exit code will become 128. If the return value is a number but is not in the range 0-255, the exit code will be 255. This matches the well-known behaviour of bash as documented in the Advanced Bash-Scripting Guide, Appendix E, which defined 128 as "invalid argument to exit" and 255 as "exit status out of range".
  • Executable modules SHOULD omit the name component (first parameter) of the define() call. Implementers MAY WISH TO specify a name as the first parameter of "define", but in LethargicOS this has no effect on how the module is loaded: the OS uses the full filesystem path to identify modules, and any name provided by the module is ignored.
  • The main() function will be called with a single parameter, which is an array of strings containing the program's arguments. The function definition SHOULD therefore be function main(argv), but if access to argv is not required, then main() is allowed to take no parameters. There is no argc parameter, because Javascript arrays are self-aware about their length. argv.length can be used as a direct replacement for argc if needed.
  • All program code should take place inside main(), or other functions called from main(). The only code that should exist directly inside executable is static initialization of constants and/or globals, and class definitions. Calling any methods on the program context (this) during execution of executable() (before main is invoked) will result in an error.

The executable callback function will receive a this which is a process context object. This object provides access to POSIX methods, which when called are executed in the context of the calling process, and is the equivalent of the syscall interface in UNIX-like systems. To make this context easily accessible to all code in the program, it's recommended to assign this to a variable inside executable() (convention is to use a variable named $). Throughout this document, the program context will be referred to as this rather than $.

Libraries

Like any AMD module, an executable can depend on other modules (libraries). A library dependency can be specified in one of the following ways:

  • As a relative path without a leading dot, e.g. "somelib.js" or "foo/bar.js". The system will search the configured set of library search paths in order, loading the library from the first place where it's found. This is usually what you want.
  • As an absolute path, e.g. "/usr/lib/pathlib.js". The library will be loaded from that exact location.
  • As a relative path beginning with a dot, e.g. "./lib/mylib.js". The library will be loaded relative to the working directory of the executable.
  • As a path beginning with tilde-slash, e.g. "~/.local/lib/mylib.js". The tilde will be replaced with the absolute path to the user's home directory (i.e. /home/user).
  • As a path beginning with at-slash, e.g. "@/../lib/mylib.js". The at sign will be replaced with the absolute path to the executable, allowing libraries to be loaded relative to the executable's location.

Environment

The program's environment variables are accessible two ways:

  • POSIX: via the environ property of the program context (i.e. this.environ). This exists solely for POSIX compatibility, and is the direct equivalent of extern char **environ in a POSIX C program, returning the environment as an array of strings in NAME=value format. Adding, changing or removing values in this array will update the program's environment, although be aware that POSIX forbids editing environ directly and recommends calling getenv(), setenv(), unsetenv(), and/or putenv() instead.

  • JS: via the env property of the program context (i.e. this.env). This returns an object whose names are the names of the variables, and whose values are the value of that variable. This is the format in which LethargicOS internally stores the canonical env for a process, and in fact env gives direct access to this object, therefore adding/removing properties or changing the value of a property will change the environment of the process. Setting {"NAME": "value"} here will be reflected in environ as "NAME=value".

Through environ, it's possible to set environment variables without an equals sign. The same is true in real POSIX (the NAME=value format is merely "conventional", not enforced). When accessing $.environ, you should not assume that there will always be an equals sign present. If a variable definition that doesn't contain an equals sign is added to environ, it will appear in $.env with the full string as the key, and the value null. Examples: the string FOOBAR in environ would translate to {"FOOBAR": null} in $.env (while FOOBAR= becomes {"FOOBAR": ""}).

Termination

Normal termination

The normal way for a program to terminate is for main() to return (or technically speaking, for the Promise returned by main() to resolve). After this, the program is considered to be in the terminated state.

Returning a nonzero exit code from main() is still considered normal termination.

Abmornal termination

A program terminates abnormally when it is killed by a signal. A process may be sent a signal that kills it under the following circumstances:

  • Unhandled exception: The promise returned by main() rejects (i.e. an unhandled exception occurs in async code). The process will be sent a SIGABRT. If the process has a signal handler for SIGABRT, it will be called, but the process will still be terminated afterwards.
  • Killed by external signal: kill() is called on the process to send it a signal that defaults to termination (such as SIGINT, SIGTERM) and either the process has no signal handler for that signal, or the signal is not catchable (i.e. SIGKILL).

Process termination is achieved by abuse of async. Our execution environment (JavaScript in the browser) is single-threaded, so LethargicOS is running on the equivalent of a single-core CPU. In such an environment, user code cannot execute simultaneously with kernel code. Either the kernel is executing instructions, or a process is.

In a real OS, the illusion of concurrency would be obtained through preemptive scheduling, by periodically interrupting program execution to transfer control back to the OS. The OS then schedules the program to resume later. However, JS gives us no way to interrupt any long-running code, so we cannot do the same. Instead, we have to use cooperative multitasking through the use of coroutines (async functions). When a process is executed, control is handed to the process. When the process makes a syscall, it briefly transfers control to the kernel, which the kernel uses to create a Promise. Control (and the Promise) is then returned back to the process, which will typically then immediately await the Promise, expecting that it will resolve with the result of the syscall. By awaiting, the process indefinitely gives up control until the owner of the Promise (the kernel) resolves or rejects it.

This then gives us the only mechanism by which the kernel can terminate a process (without resorting to Web Workers and iframe sandboxes -- which might be the way to go further in development). Each Process not in a RUNNING state (noting that, in a single-threaded or "single-core" system, only one process may be RUNNING at any one time) must necessarily be associated with a Promise that the process is currently awaiting, which when settled (resolved/rejected), will cause the associated process to resume execution. If the kernel itself is also not actively running any code, then the system is idle, and the kernel is waiting for some event to occur (either user-initiated, a timeout, or completion of some I/O operation completing).

When an event (exception, kill() invoked by another process, etc) causes a process to be sent a signal that would terminate it, the kernel can proceed to handle the signal (invoking the process's signal handler, if applicable). If the conditions for termination are still met (signal was unhandled or unblockable), then the kernel can change the process's status to TERMINATED.

At this point, the Promise that was returned back to the process when it made its current syscall is discarded. The Promise will now never be settled (it will never resolve OR reject), and this means that the process coroutine will never resume. As an additional safeguard, any further syscalls made through the context of the dead process can now throw an exception instead of returning a Promise. That is, the context (the only way for process code to access the system, via the POSIX API) is now invalidated.

This process is not foolproof. A process might, for example, call native setInterval() and register a callback that JavaScript will periodically invoke, allowing a process to outlive its lifetime. However, without a valid program context, such a callback should be unable to affect the running system in any meaningful way. (Processes should use kernel functionality to schedule timeouts/callbacks, instead of setInterval()/setTimeout())

Ultimately, it comes down to the word "cooperative": processes are expected to cooperate with the kernel to achieve a running system. Cooperative multitasking was used in such venerable systems as MS-DOS, and even the Linux kernel used to internally use cooperative multitasking (that is, the kernel could preempt process threads, but not its own threads) up until Linux 2.6.

Javascript provides scheduler.yield() as a way for long-running code to temporarily yield the execution thread back to the JavaScript engine, so that any pending events may be serviced, and the webpage can continue to update in response to events. In LethargicOS, this is exposed to processes through the POSIX function this.sched_yield(), which is treated like any other syscall.

Example native executable

"use strict";

define(["/usr/lib/some-library.js"], function executable(libsomelibrary) {
    const $ = this;

    class Foo {
        // ...example class definition
    }

    async function main(argv) {
        // Program code goes here.
        
        // Example POSIX write call
        const buf = new TextEncoder().encode("Hello world!\n").buffer;
        await $.write(1, buf);

        return 0;
    }

    return main;
});

WASM (todo)

LethargicOS may (but does not yet) support WASM executables. The idea is:

POSIX applications can be compiled to WASM externally, and then the resulting WASM file loaded into LethargicOS. The WASM executable is expected to make external calls to standard POSIX functions. LethargicOS will map these POSIX calls to its own POSIX API, theoretically offering seamless compatibility.

Implementation

LethargicOS will support POSIX C programs compiled for WebAssembly System Interface (WASI) Preview 1. This will be achieved by providing a custom WASI runtime that integrates with LethargicOS. To compile a C program with clang, add the flag --target wasm32-wasip1 (or --target wasm32-wasi for older LLVM). You will need wasi-libc installed.

This isn't a 1 to 1 POSIX mapping. WASI includes a libc layer which maps to the WASI system calls. However, the use of WASI means that there's a standard toolchain already available for compiling to WASM, and then WASM files can just be dropped into LethargicOS and work with no additional support. WASI is also POSIX-like enough that there's not too much work to be done to map back to LethargicOS.

Note that WASI is still under development. As of April 2026, LethargicOS uses WASI 0.1 (Preview 1), because it's already widely supported by toolchains (there's a few runtimes out there too). WASI 0.2 is also available, but is very new, with not much support yet. WASI 0.3 is under development and is still evolving, so cannot yet be used.

Example

Consider a basic Hello World program that looks like this:

#include <unistd.h>

int main(void) {

    write(1, "Hello, World!\n", 14);

    return 0;
}

This is then compiled with:

clang helloworld.c -target wasm32-wasi -o helloworld.wasm

The resulting wasm output (represented as WebAssembly Text) contains the following imports:

  (import "wasi_snapshot_preview1" "fd_write" (func $__imported_wasi_snapshot_preview1_fd_write (type 0)))
  (import "wasi_snapshot_preview1" "proc_exit" (func $__imported_wasi_snapshot_preview1_proc_exit (type 1)))

To successfully run this code, LethargicOS needs to export the following two functions to the WASM module:

const namespace = {
    wasi_snapshot_preview1: {
        // https://wasix.org/docs/api-reference/wasi/fd_write
        fd_write(fd, iovs, iovs_len, nwritten) {
            //...
        },
        // https://wasix.org/docs/api-reference/wasi/proc_exit
        proc_exit(code) {
            //...
        }
    }
}

And to launch the executable, LethargicOS needs to invoke the exported _start function in the WASM module.

The Async WASM Problem

WASM is synchronous and expects its external calls to be synchronous. To meet the asynchronous nature of JavaScript (and therefore LethargicOS), we need to be able to have WASM call JS functions in such a way that the JS function can then suspend the WASM code and resume it later.

There appear to be some systems that make this possible. Most notably there's something called Binaryen which is a WASM toolchain/optimiser. Notably, it can take an existing WASM file (such as that output by LLVM), run a pass called Asyncify over it, then produce an async-ready WASM file.

It appears Binaryen also has a Web port, e.g. https://www.npmjs.com/package/binaryen which opens several possibilities.

  • LethargicOS could load unmodified WASM (directly compiled by LLVM), and at runtime convert it to be async-compatible before caching and executing it.
  • Binaryen is a full toolchain, could we make a C-to-WASM compiler within LethargicOS itself?

The main downside of this solution is performance, because of the stack unwinding/rewinding every time an async external call is made, but there are mitigating factors:

  • For any call that doesn't need to be async, we can just do it synchronously and completely avoid stack unwinding.
  • Calls that need to be async are probably going to take a long time (in computer terms) anyway, so the performance impact of the stack unwind/rewind is not too noticeable in the cases where it MUST be used.
  • Exceptions are another example of stack unwinding, and those are used all the time, with not too much performance impact.

Solving the Async WASM Problem in LethargicOS

Initial plan

If an async call is made, we can't return the result immediately as we need to await it. We first call the async function synchronously to obtain a Promise, then register a callback for when the promise is fulfilled. Finally, we set a flag and call the WASM's asyncify_start_unwind(), so that returning from this function will pause the WASM execution, and return. When the original call into _start() returns, the code there needs to check that flag and call asyncify_stop_unwind() if it's set, as we have unwound the stack fully.

When we return, because the stack is unwound, we actually end up returning from the initial call into _start() For the callback to resume the WASM execution, the unwound stack must be rewound. We initiate this by first calling asyncify_start_rewind(). Once this is done, calling back into _start() initiates a return to the same point where the stack was originally unwound at, and from there the WASM will continue to execute until the next async call is made.

The callback must save the return result of the Promise to a place where another call of the export function can access it, along with setting a flag to tell the export function that a rewind is in process. This is because as the stack rewinds, it will call back up the stack until it finally ends up calling back into our export function (which is where it was when we unwound), returning control to us. We check if the rewind flag is set, and if it is we call asyncify_stop_rewind() to return the WASM to normal behaviour, and finally we retrieve and return the awaited result.

Evolved plan

Testing in Node.JS revealed that the following system works:

  • Set up three flags. terminateNow, isUnwinding, isRewinding.
  • Set aside a variable to hold a promise and another to hold the result of a promise.
#include <unistd.h>
int main(void) {
if(write(1, "Hello, World!\n", 14) != 14) return 1;
//sleep(1);
//write(1, "Goodbye, World!\n", 16);
return 0;
}
#!/usr/bin/env node
"use strict";
import binaryen from "binaryen";
import { ArgumentParser } from "./argparse.js";
import * as fs from "node:fs";
async function main() {
const parser = new ArgumentParser();
parser.addArgument("wasmfile", {description: "The WASM file to test on"});
const args = parser.parseArgs(process.argv.slice(2));
if(!args.wasmfile.length) throw new Error("A file is required");
const wasmContent = fs.readFileSync(args.wasmfile, { flag: "r" });
const ir = new binaryen.readBinary(wasmContent);
binaryen.setOptimizeLevel(1);
ir.runPasses(['asyncify']);
const newBinary = ir.emitBinary();
//fs.writeFileSync(args.wasmfile.replace(/\.wasm$/, ".async.wasm"), newBinary);
// Set up for and execute the wasm
const compiled = new WebAssembly.Module(newBinary);
await runAsyncWasmInstance(compiled);
return 0;
}
async function runAsyncWasmInstance(module) {
let exitCode = 0;
const instance = new WebAssembly.Instance(module, {
wasi_snapshot_preview1: {
// https://wasix.org/docs/api-reference/wasi/fd_write
fd_write(fd, iovs, iovs_len, nwritten) {
//console.log("fd_write() was called with fd:", fd, "iovs:", iovs, "iovs_len:", iovs_len, "nwritten:", nwritten);
if(fd === 1) {
let written = 0;
const conv = new TextDecoder();
let ptr = (iovs>>2);
for(let i=0; i<iovs_len; i++) {
const base = mem[ptr++], len = mem[ptr++];
const buf = memBytes.subarray(base, base+len);
console.log(conv.decode(buf));
written += buf.byteLength;
}
// Return back to the process the number of bytes written
mem[nwritten>>2] = written;
}
},
// https://wasix.org/docs/api-reference/wasi/proc_exit
proc_exit(code) {
// Only called if main() returns nonzero!
// proc_exit() does NOT return; therefore we need to unwind the stack.
//console.trace("proc_exit() was called, beginning stack unwind");
exitCode = code; // Save the exit code
terminateNow = true;
exp.asyncify_start_unwind(DATA_ADDR);
},
poll_oneoff(in_, out_, nsubscriptions, nevents) {
}
}
});
const exp = instance.exports;
const mem = new Int32Array(exp.memory.buffer);
const memBytes = new Uint8Array(exp.memory.buffer);
const DATA_ADDR = 16; // Where the unwind/rewind data lives
// Flags
let terminateNow = false, isUnwinding = false, isRewinding = false;
/** @type {?Promise<any>} */
let promise;
let promiseResult;
// Run.
for(;;) {
// Invoke the _start method.
exp._start();
if(terminateNow) break;
if(!isUnwinding) {
console.log("WASM _start() returned, normal process exit.");
break;
}
console.log("Stack is unwinding");
exp.asyncify_stop_unwind();
if(promise) promiseResult = await promise;
}
if(exitCode === null) {
console.warn("WASM process abnormal termination!");
}
console.info("WASM process exited with code", exitCode);
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment