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.
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 -
.requirewith automatic file resolution,.import/.exportfor cross-module calls - Hot code reloading -
.dynamicmodules 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:
MyApp/Utils.xasmmy_app/utils.xasmmy_app.utils.xasmMyApp/utils.xasmUtils.xasmutils.xasmMyApp.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
- Fork it (https://github.com/konjac-lang/x/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Giorgi Kavrelishvili - creator and maintainer