package

github.com/konjac-lang/x

main / published Apr 27, 2026 / repository

A virtual machine for concurrent applications

X

A lightweight, Erlang-inspired virtual machine for concurrent, fault-tolerant applications in Crystal.

Crystal License: MIT Build Status

This project combines a clean stack-based bytecode interpreter with actor-model concurrency, lightweight processes, message passing, supervision trees, and built-in fault tolerance - all implemented in pure Crystal.

Inspired by Erlang's BEAM, but designed to be simple, hackable, and embeddable.

Features

  • Stack-based VM - 120+ instructions across stack, arithmetic, logic, control flow, process, and message operations
  • Actor-model concurrency - Lightweight processes with isolated stacks, mailboxes, and preemptive scheduling via reductions
  • Fault tolerance - Process linking, monitoring, exit trapping, and supervision trees (one-for-one, one-for-all, rest-for-one)
  • XASM assembler - Human-readable assembly language with modules, imports, exports, subroutines, and block-based control flow
  • Multi-module system - .require with automatic file resolution, .import/.export for cross-module calls
  • Hot code reloading - .dynamic modules can be reloaded at runtime without restarting processes
  • Elixir-style standard library - IO, String, Integer, Float, Array, Map, Type modules with 80+ built-in functions
  • Interactive debugger - Step, continue, breakpoints, stack/locals inspection, process filtering
  • Lambdas and closures - First-class functions with captured variables
  • Exception handling - try/catch/throw with stack unwinding
  • Pure Crystal - No external dependencies beyond standard library

Quick Start

Installation

git clone https://github.com/konjac-lang/x.git
cd x
shards install

Hello World

Create hello.xasm:

.module Hello

.import IO.printLine/1

.process main
  push "Hello, World!"
  call IO.printLine/1

  push :normal
  exit
.end

Run it:

shards run -- hello.xasm

Build the binary

shards build --release
./bin/x hello.xasm

The XASM Language

XASM is a human-readable assembly language for the X VM. Every line is a verb - there are no implicit operations.

Modules and Directives

Every .xasm file starts with a module declaration:

.module MyApp.Main

; Import functions from other modules or standard library
.import IO.printLine/1
.import String.concatenate/2

; Require other modules (auto-resolved from disk)
.require "MyApp.Utils"

; Export functions for other modules to use
.export myFunction/1

Processes

Processes are the unit of concurrency. Each process has its own stack, locals, and mailbox:

.process main
  push "I am a process"
  call IO.printLine/1

  push :normal
  exit
.end

Variables

Local variables are declared with .local and accessed with load/store:

.process main
  .local name
  .local counter

  push "alice"
  store name

  push 0
  store counter

  load name
  call IO.printLine/1

  push :normal
  exit
.end

Control Flow

Block-based control flow - no manual jump offsets:

; If/else
load x
push 10
gt
if
  push "x is greater than 10"
  call IO.printLine/1
else
  push "x is 10 or less"
  call IO.printLine/1
end

; Loops
push 0
store i
loop
  load i
  push 10
  gte
  break_if

  load i
  inc
  store i
end

; Try/catch
try
  push "something dangerous"
  throw
catch
  push "caught it"
  call IO.printLine/1
end

Subroutines

Reusable code blocks within a module:

.subroutine greet
  .local name
  store name
  push "Hello, "
  load name
  call String.concatenate/2
  return
.end

Message Passing

Processes communicate through messages:

; Send a message to a named process
push "worker"
push "do something"
send

; Receive a message (blocks until one arrives)
receive

; Wait for a process to register
await "worker"

Spawning Processes

; Spawn a new process
spawn
  self
  register "worker"
  loop
    receive
    call IO.printLine/1
  end
end

; Spawn with a link (crash propagation)
spawn_link
  push "linked child"
  call IO.printLine/1
  push :normal
  exit
end

Lambdas

; Create a lambda capturing a variable
lambda [multiplier]
  load multiplier
  mul
  return
end

; Invoke with arity
push 5
invoke 1

Hot Code Reloading

Mark a module as dynamic to enable live reloading:

.module MyApp.Config
.dynamic

.export version/1

.subroutine version
  push "1.0.0"
  return
.end

Reload at runtime from another process:

push "MyApp.Config"           ; module name to reload
push "MyApp.Config.v2"        ; file hint (resolved by module resolver)
reload

The new code is picked up by all processes on their next function call - no restart required.

Supervision Trees

Declarative supervisor configuration:

.supervisor pool :one_for_one max_restarts=3 window=5

  .child "logger" :permanent
    self
    register "logger"
    loop
      receive
      call IO.printLine/1
    end
  .end

  .child "worker" :transient
    push "working..."
    call IO.printLine/1
    push :normal
    exit
  .end

.end

CLI

Usage: x [options] <file.xasm> [file2.xasm ...]

Options:
    -v, --version                    Show version
    -h, --help                       Show this help
    -d, --debug                      Enable debug logging
    -q, --quiet                      Suppress info logging
    -I PATH, --include PATH          Add search path for module resolution
    --ast                            Print the AST and exit
    --instructions                   Print compiled instructions and exit
    --debugger                       Launch interactive debugger

Interactive Debugger

Launch with --debugger to step through execution:

-- Process <1> (room) @ instruction 0 --
  -> PROCESS_SELF
  Stack: (empty)
xdb>

Commands:

| Command | Short | Description | |---------|-------|-------------| | step | s | Execute one instruction | | next | n | Step over subroutines | | continue | c | Continue until next breakpoint | | run | r | Run without stopping | | kill | k | Kill current process | | stack | st | Show full stack | | locals | l | Show local variables | | mailbox | mb | Show process mailbox | | processes | ps | List all processes | | registry | reg | Show named processes | | instructions | is | Show nearby instructions | | callstack | cs | Show call frames | | break <addr> | b | Breakpoint at instruction | | break <name> | b | Breakpoint on named process | | break <name>:<addr> | b | Breakpoint on process at instruction | | filter <pid> | f | Only break on one process | | eval <expr> | e | Inspect values | | help | h | Show all commands | | quit | q | Exit |

Press Enter to repeat the last command.

Standard Library

IO

| Function | Description | |----------|-------------| | IO.puts/1 | Print string with newline, returns :okay | | IO.print/1 | Print string without newline, returns :okay | | IO.printLine/1 | Alias for IO.puts/1 | | IO.inspect/1 | Print inspect representation, returns the value | | IO.gets/0 | Read a line from stdin |

String

| Function | Description | |----------|-------------| | String.concatenate/2 | Concatenate two strings | | String.length/1 | String length | | String.reverse/1 | Reverse a string | | String.upcase/1 | Convert to uppercase | | String.downcase/1 | Convert to lowercase | | String.trim/1 | Strip whitespace from both ends | | String.trimLeading/1 | Strip leading whitespace | | String.trimTrailing/1 | Strip trailing whitespace | | String.split/2 | Split string by delimiter | | String.join/2 | Join array with separator | | String.contains/2 | Check if string contains substring | | String.startsWith/2 | Check prefix | | String.endsWith/2 | Check suffix | | String.replace/3 | Replace all occurrences | | String.slice/3 | Extract substring (string, start, length) | | String.at/2 | Character at index | | String.toInteger/1 | Parse to integer | | String.toFloat/1 | Parse to float | | String.toAtom/1 | Convert to atom/symbol | | String.duplicate/2 | Repeat string N times | | String.padLeading/3 | Pad start to width | | String.padTrailing/3 | Pad end to width | | String.toString/1 | Convert any value to string |

Integer

| Function | Description | |----------|-------------| | Integer.toString/1 | Convert to string | | Integer.toFloat/1 | Convert to float | | Integer.parse/1 | Parse string to integer | | Integer.isEven/1 | Check if even | | Integer.isOdd/1 | Check if odd | | Integer.digits/1 | Get array of digits | | Integer.gcd/2 | Greatest common divisor |

Float

| Function | Description | |----------|-------------| | Float.toString/1 | Convert to string | | Float.toInteger/1 | Truncate to integer | | Float.parse/1 | Parse string to float | | Float.round/2 | Round to N decimal places | | Float.ceil/1 | Ceiling | | Float.floor/1 | Floor | | Float.isNan/1 | Check for NaN | | Float.isInfinity/1 | Check for infinity |

Array

| Function | Description | |----------|-------------| | Array.new/0 | Create empty array | | Array.length/1 | Array length | | Array.size/1 | Alias for length | | Array.first/1 | First element | | Array.last/1 | Last element | | Array.at/2 | Element at index | | Array.get/2 | Alias for at | | Array.set/3 | Set element at index | | Array.append/2 | Add to end | | Array.prepend/2 | Add to start | | Array.push/2 | Alias for append | | Array.pop/1 | Remove last, returns [array, element] | | Array.concat/2 | Concatenate two arrays | | Array.reverse/1 | Reverse array | | Array.sort/1 | Sort array | | Array.uniq/1 | Remove duplicates | | Array.flatten/1 | Flatten one level | | Array.contains/2 | Check membership | | Array.indexOf/2 | Find index of element | | Array.slice/3 | Extract sub-array | | Array.take/2 | Take first N elements | | Array.drop/2 | Drop first N elements | | Array.zip/2 | Zip two arrays into pairs | | Array.isEmpty/1 | Check if empty | | Array.sum/1 | Sum of elements | | Array.product/1 | Product of elements | | Array.min/1 | Minimum element | | Array.max/1 | Maximum element | | Array.join/2 | Join elements with separator |

Map

| Function | Description | |----------|-------------| | Map.new/0 | Create empty map | | Map.put/3 | Set key-value pair | | Map.get/2 | Get value by key | | Map.getWithDefault/3 | Get with fallback | | Map.delete/2 | Remove key | | Map.hasKey/2 | Check if key exists | | Map.keys/1 | Get all keys | | Map.values/1 | Get all values | | Map.size/1 | Number of entries | | Map.merge/2 | Merge two maps | | Map.toArray/1 | Convert to array of [key, value] pairs | | Map.isEmpty/1 | Check if empty |

Type

| Function | Description | |----------|-------------| | Type.of/1 | Get type name as string | | Type.inspect/1 | Get inspect representation | | Type.toString/1 | Convert any value to string | | Type.isNull/1 | Check if null | | Type.isInteger/1 | Check if integer | | Type.isFloat/1 | Check if float | | Type.isString/1 | Check if string | | Type.isBoolean/1 | Check if boolean | | Type.isArray/1 | Check if array | | Type.isMap/1 | Check if map | | Type.isSymbol/1 | Check if symbol | | Type.isLambda/1 | Check if lambda | | Type.isNumeric/1 | Check if integer or float |

TCP

| Function | Description | |----------|-------------| | TCP.listen/1 | Create a TCP server on a port (binds to 0.0.0.0) | | TCP.listenOn/2 | Create a TCP server on a specific address and port | | TCP.listenWithBacklog/3 | Create a TCP server with custom backlog | | TCP.accept/1 | Accept a client connection (async, blocks process) | | TCP.acceptTimeout/2 | Accept with timeout in milliseconds | | TCP.connect/2 | Connect to a host and port (async) | | TCP.connectTimeout/3 | Connect with timeout in milliseconds | | TCP.send/2 | Send string data over a socket (async) | | TCP.sendBinary/2 | Send binary data over a socket (async) | | TCP.receive/2 | Receive up to N bytes as string (async) | | TCP.receiveBinary/2 | Receive up to N bytes as binary (async) | | TCP.receiveTimeout/3 | Receive with timeout in milliseconds | | TCP.receiveLine/2 | Read a line up to max bytes (async) | | TCP.receiveExact/2 | Read exactly N bytes (async) | | TCP.close/1 | Close a socket or server | | TCP.isClosed/1 | Check if a socket is closed | | TCP.setNodelay/2 | Enable/disable TCP_NODELAY | | TCP.setKeepalive/2 | Enable/disable keepalive | | TCP.setReceiveBufferSize/2 | Set receive buffer size | | TCP.setSendBufferSize/2 | Set send buffer size | | TCP.setReadTimeout/2 | Set read timeout in milliseconds | | TCP.setWriteTimeout/2 | Set write timeout in milliseconds | | TCP.clearReadTimeout/1 | Remove read timeout | | TCP.clearWriteTimeout/1 | Remove write timeout | | TCP.setReuseAddress/2 | Enable/disable SO_REUSEADDR | | TCP.setReusePort/2 | Enable/disable SO_REUSEPORT | | TCP.setLingerOption/3 | Set linger option (socket, enabled, timeout) | | TCP.localAddress/1 | Get local [address, port] | | TCP.remoteAddress/1 | Get remote [address, port] | | TCP.shutdown/2 | Shutdown socket direction ("read", "write", or "both") |

UDP

| Function | Description | |----------|-------------| | UDP.open/1 | Open a UDP socket bound to a port | | UDP.openOn/2 | Open a UDP socket bound to address and port | | UDP.openUnbound/0 | Open an unbound UDP socket | | UDP.connect/3 | Connect a UDP socket to a remote host and port | | UDP.send/2 | Send string data on a connected socket (async) | | UDP.sendTo/4 | Send string data to a specific host and port (async) | | UDP.sendToBinary/4 | Send binary data to a specific host and port (async) | | UDP.receive/2 | Receive up to N bytes as string (async) | | UDP.receiveFrom/2 | Receive data with sender [data, address, port] (async) | | UDP.receiveFromBinary/2 | Receive binary data with sender info (async) | | UDP.receiveFromTimeout/3 | Receive with sender info and timeout (async) | | UDP.close/1 | Close a UDP socket | | UDP.isClosed/1 | Check if socket is closed | | UDP.setBroadcast/2 | Enable/disable broadcast | | UDP.setReceiveBufferSize/2 | Set receive buffer size | | UDP.setSendBufferSize/2 | Set send buffer size | | UDP.joinMulticastGroup/2 | Join a multicast group | | UDP.leaveMulticastGroup/2 | Leave a multicast group | | UDP.setMulticastLoopback/2 | Enable/disable multicast loopback | | UDP.setMulticastHops/2 | Set multicast TTL/hops | | UDP.localAddress/1 | Get local [address, port] |

Unix

| Function | Description | |----------|-------------| | Unix.listen/1 | Create a Unix domain socket server at a path | | Unix.listenWithBacklog/2 | Create server with custom backlog | | Unix.accept/1 | Accept a client connection (async) | | Unix.acceptTimeout/2 | Accept with timeout in milliseconds | | Unix.connect/1 | Connect to a Unix socket path (async) | | Unix.send/2 | Send string data (async) | | Unix.sendBinary/2 | Send binary data (async) | | Unix.receive/2 | Receive up to N bytes as string (async) | | Unix.receiveBinary/2 | Receive up to N bytes as binary (async) | | Unix.receiveTimeout/3 | Receive with timeout in milliseconds | | Unix.receiveLine/2 | Read a line up to max bytes (async) | | Unix.close/1 | Close a socket or server | | Unix.isClosed/1 | Check if closed | | Unix.unlink/1 | Delete the socket file from disk | | Unix.setReadTimeout/2 | Set read timeout in milliseconds | | Unix.setWriteTimeout/2 | Set write timeout in milliseconds | | Unix.clearReadTimeout/1 | Remove read timeout | | Unix.clearWriteTimeout/1 | Remove write timeout | | Unix.path/1 | Get the socket file path |

Socket

| Function | Description | |----------|-------------| | Socket.resolve/1 | Resolve hostname to list of IP addresses (async) | | Socket.resolveAll/3 | Resolve with service and family filter ("ipv4", "ipv6", "any") (async) | | Socket.parseIpAddress/2 | Parse an IP address string and port into [address, port, family] | | Socket.isValidIp/1 | Check if a string is a valid IP address |

Instruction Set Reference

Stack Operations

| XASM | Description | |------|-------------| | push <value> | Push literal (string, integer, float, symbol, true, false, null) | | pop | Discard top of stack | | dup | Duplicate top | | over | Copy second element to top | | swap | Swap top two | | rot | Rotate top three up | | -rot | Rotate top three down | | nip | Remove second element | | tuck | Copy top below second | | depth | Push stack depth | | pick | Copy Nth element to top | | roll | Move Nth element to top |

Arithmetic

| XASM | Description | |------|-------------| | add | a + b | | sub | a - b | | mul | a * b | | div | a / b | | mod | a % b | | neg | Negate | | abs | Absolute value | | inc | Increment by 1 | | dec | Decrement by 1 | | pow | Power | | floor | Floor | | ceil | Ceiling | | round | Round | | min | Minimum of two | | max | Maximum of two |

Bitwise

| XASM | Description | |------|-------------| | band | Bitwise AND | | bor | Bitwise OR | | bxor | Bitwise XOR | | bnot | Bitwise NOT | | shl | Shift left | | shr | Shift right | | ushr | Unsigned shift right |

Comparison

| XASM | Description | |------|-------------| | eq | Equal | | neq | Not equal | | ideq | Identical (strict) | | nideq | Not identical | | lt | Less than | | lte | Less than or equal | | gt | Greater than | | gte | Greater than or equal | | is_null | Check null | | is_not_null | Check not null |

Logic

| XASM | Description | |------|-------------| | and | Logical AND | | or | Logical OR | | not | Logical NOT | | xor | Logical XOR |

Variables

| XASM | Description | |------|-------------| | load <name> | Push local variable onto stack | | store <name> | Pop stack into local variable | | gload <name> | Load global variable | | gstore <name> | Store global variable |

Control Flow

| XASM | Description | |------|-------------| | if...else...end | Conditional block | | loop...end | Loop block | | break | Exit loop | | break_if | Exit loop if top is true | | break_unless | Exit loop if top is false | | continue | Jump to loop start | | call Module.func/arity | Call built-in or module function | | return | Return from subroutine | | nop | No operation | | halt | Halt execution |

Process Operations

| XASM | Description | |------|-------------| | self | Push current process ID | | register <name> | Register process with a name | | unregister <name> | Remove name registration | | whereis <name> | Look up process by name | | spawn...end | Spawn a new process | | spawn_link...end | Spawn with bidirectional link | | spawn_monitor...end | Spawn with monitor | | exit | Exit current process (reason on stack) | | kill | Kill a process | | sleep | Sleep for duration | | yield | Yield to scheduler | | link | Link to another process | | unlink | Unlink from a process | | monitor | Monitor a process | | demonitor | Stop monitoring | | trap_on | Enable exit signal trapping | | trap_off | Disable exit signal trapping | | alive? | Check if process is alive | | await <name> | Wait for a process to register |

Messages

| XASM | Description | |------|-------------| | send | Send message (name and value on stack) | | send_after | Send message with delay | | receive | Receive next message (blocks) | | receive_timeout | Receive with timeout | | peek | Peek at next message without consuming | | mailbox_size | Push mailbox size |

Exceptions

| XASM | Description | |------|-------------| | try...catch...end | Exception handling block | | throw | Throw an exception | | rethrow | Re-throw current exception |

Hot Reload

| XASM | Description | |------|-------------| | reload | Reload a dynamic module (file hint and module name on stack) |

Module Resolution

When you .require "MyApp.Utils", the resolver searches for matching files in this order:

  1. MyApp/Utils.xasm
  2. my_app/utils.xasm
  3. my_app.utils.xasm
  4. MyApp/utils.xasm
  5. Utils.xasm
  6. utils.xasm
  7. MyApp.Utils.xasm

Search roots include the directory of the entry file and any paths added with -I.

If no file matches by name, the resolver scans .xasm files for a matching .module declaration.

Examples

The examples/ directory contains working demos:

Run any example:

shards run -- examples/single_module/Stick.Messaging.xasm
shards run -- examples/chat/Chat.Main.xasm
shards run -- examples/dynamic/Dynamic.Main.xasm

Architecture

                    XASM Source
                   (.xasm files)
                        |
                   +----v----+
                   |  Lexer  |  Tokenization
                   +----+----+
                   +----v----+
                   |  Parser |  AST generation
                   +----+----+
                   +----v---------+
                   | Code Generator|  Bytecode compilation
                   +----+---------+
                   +----v----+
                   |  Loader |  Module resolution, wiring
                   +----+----+
                        |
    +-------------------v---------------------+
    |            X VM Engine                   |
    |                                          |
    | +--------+  +--------+  +--------+       |
    | |Proc <1>|  |Proc <2>|  |Proc <N>|       |
    | | Stack  |  | Stack  |  | Stack  |       |
    | | Locals |  | Locals |  | Locals |       |
    | |Mailbox |  |Mailbox |  |Mailbox |       |
    | +--------+  +--------+  +--------+       |
    |                                          |
    | +--------------------------------------+ |
    | |           Scheduler                  | |
    | | Reduction-based preemptive scheduling| |
    | +--------------------------------------+ |
    |                                          |
    | +-------------+  +-------------------+   |
    | |Fault Handler|  |Supervisor Registry|   |
    | |Links/Monitors| |Restart strategies |   |
    | +-------------+  +-------------------+   |
    |                                          |
    | +--------------------------------------+ |
    | |    Built-in Function Registry        | |
    | | IO String Array Map Type Integer ... | |
    | +--------------------------------------+ |
    +------------------------------------------+

Why X?

  • Learning - Understand how actor-model VMs work from the inside
  • Embedding - Drop a concurrent runtime into your Crystal application
  • Experimentation - Build DSLs, game scripting engines, or workflow systems
  • Compiler target - XASM is a clean compilation target for higher-level languages

Contributing

  1. Fork it (https://github.com/konjac-lang/x/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors

API

  • String

    A String represents an immutable sequence of UTF-8 characters.

  • Symbol

    A symbol is a constant that is identified by a name without you having to give it a numeric value.

  • X