beamlet, the Elixir VM
beamlet is a BEAM interpreter in safe Rust: it runs Erlang and Elixir code compiled by the
standard compiler, OTP 28 with Elixir 1.20. Every session and every agent on Redoubt is one
beamlet VM. The VM gets from its embedder, through one Rust trait (Platform), exactly the
services it is granted (a clock, a console, random bytes, code, files, programs) and nothing
else, and it treats every .beam file, literal and message as hostile input. beamlet runs on
the host and boots the shell on Redoubt with verified modules from the userland disk, and its
files are 9P files in its namespace. Native launching on Redoubt remains planned.
Purpose
A session needs a language, a standard library and a prompt, and it needs them without C, without
a POSIX kernel underneath, and small enough to audit. The BEAM gives Elixir and Erlang, OTP’s
libraries, the compilers, supervision and message passing; beamlet gives the BEAM in a form
Redoubt can trust: its own crates hold no unsafe, a loader checks everything before code runs,
limits fail closed, and one narrow boundary is where the operating system comes in. The code is in
userland/otp.
How to use it
On the host, beamlet is a command:
$ cargo run -p beamlet -- -pa ebin my_module start
$ cargo run -p beamlet -- --root ./sandbox -pa ebin my_module start # expose one directory
$ cargo run -p beamlet -- --mount /data=./data:ro --root ./sandbox ... # add a read-only mount
$ cargo run -p beamlet -- --exec --schedulers 4 ... # grant programs; 4 threads
Without --root, file calls fail with enotsup; without --exec, opening a port to a program
fails with eacces. The VM’s environment starts empty, with HOME set to / when there is a
--root (--env NAME[=VALUE] adds to it), so the host’s is not visible. Tests: cargo test in
userland/otp runs the unit and hostile-input tests; the differential suites (tools/difftest,
tools/elixir-tests) need the pinned OTP and Elixir toolchains installed.
On Redoubt there is no command to run: the steward starts a session’s VM when a person logs in
(sessions), and a launcher starts an agent’s (agents). What the code
in the VM sees is ordinary Elixir: File.read!/1, IO.puts/1, :gen_tcp.connect/3.
What it can and cannot do
Loading hostile code
Status: built · partly tested: hostile loader tests run on the host; checked modules boot on Redoubt, but the refusal of other OTP versions' opcodes and atom tables is not attacked by a named test · tested (12)
- host:beamlet-vm::fixtures_load
- host:beamlet-vm::every_truncation_is_rejected
- host:beamlet-vm::wrong_formats_are_named
- host:beamlet-vm::mutants_never_panic
- host:beamlet-vm::empty_frames_count_against_the_stack
- host:beamlet-vm::rejects_hostile_input
- host:beamlet-vm::safe_mode_creates_no_atoms
- host:beamlet-vm::safe_mode_refuses_an_export_fun_of_code_not_exported
- host:beamlet-vm::safe_mode_decodes_no_local_fun
- host:beamlet-vm::nesting_is_bounded
- host:beamlet-vm::deep_terms_are_handled_iteratively
- bench:userland-boot
The VM crate (beamlet-vm) is #![forbid(unsafe_code)], and so are beamlet-re and
beamlet-crypto (userland/otp/vm/src/lib.rs).
- One compiler. The loader accepts bytecode from OTP 28 only. It refuses deprecated or unknown opcodes, old atom tables and anything else another version would need compatibility code for.
- Checked before it runs. The loader checks every table index, label, register number and
literal before any code runs (
userland/otp/vm/src/loader.rs). A malformation that survives loading isbad_codeat run time: the process that ran it dies, uncatchably. Nothing in the VM panics on bad input. Every truncation of real.beamfiles is refused, and 20,000 mutated modules per run load or fail without a panic or a hang. - Deep terms do not recurse. Copying, comparing, printing and collecting use work lists, never Rust recursion, so a million-level nested term is fine. The external term format limits nesting to 256.
- Safe decoding names no code.
binary_to_term(Bin, [safe])creates no atoms, decodes an export fun only of a function exported now (a loaded module’s export, or a BIF), as BEAM does, and decodes no local fun at all, where BEAM decodes one whose atoms exist: a fun is code to run, with free variables the sender chose, and safe mode is for data from outside (userland/otp/vm/src/etf.rs).
Limits inside one VM
Status: built · partly tested: in a boot, only the process heap limit and the budget's backstop are attacked · tested (42)
- host:beamlet-vm::full_mailbox_kills_the_receiver
- host:beamlet-vm::a_term_equal_to_itself_is_not_scanned
- host:beamlet-vm::full_own_mailbox_kills_the_sender
- host:beamlet-vm::a_roomy_mailbox_is_not_a_limit
- host:beamlet-vm::the_vm_heap_limit_kills
- host:beamlet-vm::a_process_can_lower_its_own_limit
- host:beamlet-vm::spawn_opt_sets_a_limit
- host:beamlet-vm::under_the_limit_nothing_happens
- host:beamlet-vm::ets_inserts_past_the_limit_raise
- host:beamlet-vm::memory_is_reported
- host:beamlet-vm::jump_loops_are_preempted
- host:beamlet-vm::garbage_is_collected_and_live_data_survives
- host:beamlet-vm::unreferenced_binaries_are_freed
- host:beamlet-vm::an_atomics_array_within_the_limit_is_held
- host:beamlet-vm::an_atomics_array_past_a_processs_own_heap_limit_ends_it
- host:beamlet-vm::counters_arrays_past_the_limit_together_end_their_holder
- host:beamlet-vm::a_zlib_stream_within_the_limit_is_held
- host:beamlet-vm::a_zlib_streams_queue_past_the_limit_ends_its_holder
- host:beamlet-vm::zlib_codecs_past_the_limit_together_end_their_holder
- host:beamlet-vm::a_zlib_streams_stash_past_the_limit_ends_its_holder
- host:beamlet-vm::a_deflaters_boxed_state_is_the_constant
- host:beamlet-vm::an_inflater_holds_nothing_beyond_its_own_box
- bench:beamlet-heap-flood
- bench:beamlet-budget-flood
- bench:beamlet-compile-footprint
- bench:beamlet-compile-flood
- host:beamlet-vm::a_deflaters_inline_state_is_a_few_hundred_bytes
- host:beamlet-vm::the_footprint_is_reported_at_the_first_wait_for_input
- host:beamlet-vm::held_bytes_follow_the_runtime_heap
- bench:beamlet-footprint
- host:beamlet-vm::a_128_mb_atomics_array_in_persistent_term_past_the_limit_is_refused
- host:beamlet-vm::a_replaced_persistent_value_still_counts
- host:beamlet-vm::a_zlib_stream_grown_in_persistent_term_past_the_limit_refuses_the_next_put
- host:beamlet-vm::a_zlib_stream_grown_in_ets_past_the_limit_refuses_the_next_insert
- host:beamlet-vm::deleting_the_table_holding_a_grown_stream_makes_room
- host:beamlet-vm::an_update_element_past_the_ets_limit_is_refused
- host:beamlet-vm::heir_data_past_the_ets_limit_is_refused
- host:beamlet-vm::heir_data_counts_toward_the_ets_limit
- host:beamlet-crypto::mod_exp_refuses_an_oversized_operand_before_it_runs
- host:beamlet-crypto::dh_generate_key_refuses_oversized_parameters_before_it_runs
- host:beamlet-crypto::dh_compute_key_refuses_an_oversized_private_key_before_it_runs
- host:beamlet-crypto::an_oversized_rsa_private_key_is_refused_before_it_is_built
One VM is one trust domain, but a buggy or hostile Erlang process must not take the rest of the
VM down. Every limit fails closed: the offender ends, and nothing is lost silently
(userland/otp/vm/src/vm.rs).
- Mailbox (
max_mailbox, 2^20 messages): a message that would overflow a mailbox kills the receiver with{system_limit, message_queue}, untrappably. Dropping it would break protocols silently, like a TCP stream with a hole in it. - Process memory (
max_heap_words, 2^27 words, andmax_heap_size, which a process can only lower): checked at the end of each slice; a process over it is collected first and killed only if what is live is still over. A resource whose native declares its size counts that size as the memory of each process whose heap holds it, towardmax_heap_sizeas heap words do: a screen buffer; anatomicsorcountersarray, its cells at 8 bytes each, up to 2^24 of them; and azlibstream, its queued input (at most 256 MB), the output it has not handed out, its codec’s state (a deflater’s includes the tables its library keeps behind boxes of its own, measured against the library by a test) and the term stashed in it, declared afresh by every native that changes them. A process past its limit through any of them is ended as for heap growth. Only a process’s heap counts one toward its limit, and a holder that is not the one resizing it counts the new size from its next collection. A resource in ETS orpersistent_termcounts toward their limits, below. One in a message not yet received counts toward nobody’s: the screen buffer’s limit of four a process and the mailbox’s limit bound a buffer there, but an atomics array or a zlib stream sent and dropped by its sender is bounded only by the budget’s page limit, below. - ETS (
max_ets_words, 2^27 words for all tables together): an insert past it raisessystem_limit, as does anupdate_counterorupdate_elementthat would take it there. A table’s heir data ({heir, Pid, Data}) is kept with the table and counts as an object does: anets:neworets:setoptsthat would take ETS past the limit with it raisessystem_limit. A resource an object holds counts at its live size, once for each object holding it: a zlib stream that grows after its insert counts its growth at once, so the next insert past the limit is refused (the growth itself is bounded by the grower’s own heap limit). ETS keeps its total as objects enter and leave and resources resize, never by scanning: an insert, delete or replacement costs one step for each resource on the objects it adds or removes, a resize one step, and deleting a table one step for each resource its objects hold. persistent_term(max_persistent_words, 2^27 words): its keys, and every value ever put, each resource at its live size as in ETS. A put past it raisessystem_limit, as does a put of a new key past 2^16 keys. A value replaced or erased is never freed: it stays in its literal chunk, where BEAM frees it once no process refers to it, so its words count for good. An erased key’s own words are released. A value’s parts that are already literals (another persistent value, a module’s constant) are not copied, and count only where they were first put.persistent_term:info()reports the count as itsmemory.- CPU: reductions preempt every process, including a loop of plain jumps with no calls. A
native is not preempted, so what one may compute is bounded before it runs:
crypto:mod_powand finite-field Diffie-Hellman hold the modulus (P), the base and the exponent each to 10,000 bits, OpenSSL’sOPENSSL_DH_MAX_MODULUS_BITS, and an RSA key to 8,192 bits, itsDand primes toN’s width; within the bound every call answers as BEAM’s does, and a wider operand is refused withbadargbefore any arithmetic, where the unbounded call on a megabyte’s would hold its scheduler thread, and every process on it, for days. - Fixed limits, each
system_limit: 2^20 atoms of at most 255 characters, 2^16 processes, a stack of 2^24 slots, bignums of 2^24 bits, binaries of 2^30 bits. - A term is equal to itself without being read. Comparing a term with itself (
==,=:=, a map key, a sort) answers before a byte of it is touched, as BEAM’seqdoes: sound for every Erlang term, since the language has no NaN and so no term that differs from itself. It matters at the prompt:erl_evalcompares every binding with itself on each call of an interpreted fun, so a loop typed at the prompt over a 256 KiB binary once cost a scan of it a step (a_term_equal_to_itself_is_not_scanned).
These limits are measurements, not an allocator: one native that allocates a lot at once is caught
afterwards. The hard backstop is the embedder’s allocator, and on Redoubt the session budget’s page
limit (R6 (charging)). The image also caps beamlet’s heap
(init), but at the budget’s edge, where the budget binds
first: that cap is there for the bench’s measurement. On Redoubt the platform lowers
max_heap_words, max_ets_words and max_persistent_words to a sixteenth of the VM’s budget
each, which it reads from its startup block’s budget_pages
(init): the launcher that made the budget fills it, init
from the manifest’s budget.pages, the steward with the VM’s share of its session’s budget, so
no argument has to be kept equal to the budget by hand, and a manifest that names one is refused
by the bench (host:testbench::no_manifest_names_budget_pages). Each counts the VM’s own 8-byte words, as the VM
counts a process (two for a 16-byte term), so a limit is the same bytes on rv32 as on rv64. A flooding process peaks at about four times its
heap limit, the old heap, the collector’s copy and its growth, so the budget must be at least twice
what the VM uses with no Erlang process running; then one flooding process, the tables or
persistent_term meets its limit while the VM still has pages. A session’s sixteenth is 696
pages, 2,850,816 bytes; the shell at its prompt holds 3,696 bytes in ETS and 36,112 counted in
persistent_term, about a thousandth and a hundredth of it. Several flooding at once, or a native’s single large allocation,
reach the backstop instead, which ends the VM, and init restarts it. It is a server like any other
under init’s restart rule: a VM that cannot stay up (a start module that fails every time, a
startup block that tells it no budget, exit code 4) is restarted until the limit, and then the machine reboots
(init). CPU between VMs is the kernel’s to share, by
budget weight (scheduling).
What the VM holds at its prompt
Started with the argument report_memory, which the image never passes, the VM prints what it
holds the first time it waits for console input with nothing else to run, for the shell at its
prompt, one console line a row (memory.rs). It is the
VM’s own count: each allocation as the runtime’s heap holds it (a power-of-two block up to 2 KiB,
whole pages above), every waiting process collected first; B-tree nodes are not counted.
beamlet adds its runtime heap’s pages held at that moment and its record’s peak, and the bench’s
scan of that record (the memory budget) is the independent
total, read once the case has typed one command. In pages, each row rounded to whole pages on its
own:
| What | rv64 | rv32 |
|---|---|---|
| Decoded code: instructions | 535 | 535 |
| Decoded code: operands | 2,169 | 2,169 |
| Literals: each module’s | 37 | 37 |
| Literals: the shared table | 719 | 717 |
| Module tables | 235 | 205 |
| Atoms | 97 | 68 |
| Processes (22): heaps, collected | 57 | 57 |
| Processes: the rest | 118 | 112 |
| ETS and binaries | 1 | 1 |
| Accounted | 3,968 | 3,901 |
| Runtime heap at the prompt: held, peak | 4,196, 4,601 | 4,023, 4,431 |
| Not accounted (held less accounted) | 228 | 122 |
| The scan’s peak, after one command | 5,542 | 5,362 |
A module’s instructions are 8-byte entries over one array of its operands, 16 bytes each on either
width, a list operand’s items in the same array, so decoded code is the same size on both widths
and still about two thirds of the count. The five largest modules are unicode_util, erl_parse,
Elixir.Enum, Elixir.Kernel and string. What is not accounted is free small blocks, B-tree
nodes and the platform’s buffers; the peak above what is held is the boot pack, which is held
until the prompt, and a load’s transient, and the scan’s peak is higher than the prompt’s because
the first command loads more modules.
- Loaded code keeps no spare room. The loader shrinks a module’s instructions and its operand array to their counts once decoded, where the decoding’s doubling left up to twice as many.
- A literal chunk keeps no spare room. A module’s constants are shrunk to their count before they join the shared table (about 350 pages on either width).
- Loading stays eager, but for the shell’s commands. The shell’s start loads what its prompt needs. The largest modules are the ones evaluating any line needs, so loading them on first call would move their pages to the first command, not save them. A session calls few of its commands, so a command’s module is loaded when the command is first called (the shell), and the boot pack holds none of them.
The image budgets the VM twice the largest peak the scan finds across its memory cases, in
beamlet-footprint, the one case that scans a shell’s VM now that the steward starts the others’
shells, plus what the VM holds outside its heap, rounded up to 128 pages: 11,264 pages from the
largest peak, 5,556 on rv64 at one hart, which its heap cap of 11,220 holds twice
with 108 to spare, and with which the single VM boots in 512 MiB
(budgets). Outside the heap are 44 pages:
its two schedulers’ 18-page stacks (the second’s above a guard page of address space alone, which
costs no page; its peak at the prompt is 21,496 to 25,200 bytes on rv64 over three runs, stack beamlet thread 3 in beamlet-footprint’s scan) and thread pages, up to five pages of endpoints its locks
make, and its process’s own page. The share moves up a step of 128 when the peak passes what
the cap holds twice: the rv64 peak may reach 5,610 pages before it moves again
(budgets). The shell’s machine mode is
19 of the pages at the prompt, the driver’s and the loop’s code. The line editor under the shell’s driver is loaded at
the prompt: OTP’s group, edlin, edlin_key, group_history, prim_tty, shell,
gen_statem, sys and kernel, with Redoubt.Term and the driver. The shell’s protocols are not
consolidated, but nothing at the prompt, nor a plain line, dispatches a protocol on a struct, so
Elixir’s Protocol and Enumerable load only for a line that does.
That rule sizes the idle VM, and a compile is a workload within it: beamlet-compile-footprint
types a three-line defmodule at the prompt under the same manifest, calls the module, and
judges that the compile fits the session’s cap with at least an eighth of it to spare
(heap_fits, the memory budget). The compiler’s modules
stay loaded, so its heap peak is 9,135 pages on rv64 and 8,798 on rv32 of the 11,220 cap, 2,085
and 2,422 pages under it (an eighth is 1,402). It runs one scheduler, so every process is on the
first thread, the one the scan paints: with two, the compile runs on whichever scheduler holds the
shell’s evaluator, and the second thread’s stack is not measured. Its first thread’s stack peaks at
34,280 bytes on rv64 and 25,832 on rv32, under beamlet-footprint’s idle 36,136, so the 18-page
declaration stands. It was 88,888 and 81,056 while term_to_binary(T, [compressed]), which the
compiler calls for a chunk, kept miniz_oxide’s whole compressor, 65,712 bytes of it a code buffer,
as a local of its own function: one native frame larger than the stack left under the
interpreter’s, which defmodule at the prompt ended with a store page fault below the first
scheduler’s stack on rv64 and rv32 alike, and zlib:deflateInit/2 built the same temporary
on the stack before boxing it (on rv32 most times, not every time). Redoubt’s patch to the crate
boxes the code buffer under with-alloc, as the crate does on wasm32
(patched crates), so the state is a few hundred bytes wherever
it is built and the frame is gone.
Residual: a session that has compiled has spent its flood headroom on the compiler’s code. The
sixteenth limits are static, so one flooding process after a compile reaches the budget’s
backstop before its own limit, an allocation fails, the VM ends and init restarts it: a logout,
within the session’s own budget and no other principal’s, where before the compile the process
would have been killed alone (beamlet-compile-flood, both widths: the allocation that fails is
about 1.2 MiB on rv64 and 2.4 MiB on rv32, from one run of each). The follow-up is the VM deriving its limits from
the pages not held by loaded code, recomputed after a load, which keeps the guarantee at any
footprint without resizing a session.
Residual: QEMU’s default 256 MiB is out of reach. Its system budget leaves the VM about 2,700
pages beside the other servers, and the VM holds more than that at its prompt (the table above),
most of it decoded code and the shared literal table. Fitting needs code kept in its on-disk form or
loaded a function at a time.
The Platform boundary
Status: built · partly tested: file and program grants run on the host only; Redoubt's verified lookup is tested below · tested (9)
- host:beamlet-vm::programs_need_the_platform_to_grant_them
- host:beamlet-vm::the_bundle_wins_over_a_front_directory
- host:beamlet-vm::a_name_the_bundle_lacks_is_found_on_the_path
- host:beamlet-vm::a_refused_system_module_never_touches_the_code_path
- host:beamlet-vm::app_spec_uses_one_source_attempt_and_keeps_its_erlang_result
- host:beamlet-vm::names_resolve_inside_the_root
- host:beamlet::symlinks_cannot_leave_the_root
- host:beamlet::mounts_are_separate_and_may_be_read_only
- host:beamlet::files_round_trip
Everything the VM gets from outside comes through the Platform trait
(userland/otp/vm/src/platform.rs):
| Method | What it gives | Default |
|---|---|---|
monotonic_us, idle | a clock that never goes backwards; sleeping until a deadline or an event | required |
system_time_us | wall-clock time | required; may answer None |
console_write, console_read, console_size, prompt_drawn | the user I/O device; input never blocks; the shell’s driver says when its first prompt is drawn | no input; size unknown; nothing |
console_listening | told whether a process reads the console; with none, input held is no reason for idle to return | holds nothing |
console_resized | the console’s new size since the last call, the newest only; the VM sends it to the console’s reader as {beamlet_console_resize, {Cols, Rows}} ahead of input | never changes |
random | random bytes from a cryptographic source; on failure the VM raises rather than use a weaker source | required |
load_module, load_app, module_file | a system .beam or .app lookup answers found bytes, an absent name or a refused object; module_file names a loaded module | applications absent |
files | a file system, as prim_file sees it | none: file calls fail with enotsup |
programs | starting programs behind ports | none: open_port fails with eacces |
system | the system’s own calls: the namespace, calls and serving, budgets, labels, launching (natives) | none: each redoubt native answers {error, not_supported} |
load_moduleis a lookup, not a gate. It asks the platform for a module before examining the code path.Founduses those bytes, so no directory shadows a system module (packages);Absentsearches the code path’s directories in order, including those added withcode:add_patha/1;Refusedstops without touching the code path (userland/otp/vm/src/vm.rs,locate_module). On Redoubt a name the userland volume’serofsdanswersnot_foundto is absent, while any other refusal at the open or on the read (corrupt, a short or long file, a device error) is refused, with one console line naming the file and the error’s name (R75 (verified userland)).load_appcarries the same three outcomes:beamlet:app_spec/1makes one platform attempt and returns the bytes onFound, orerroronAbsentandRefused. Residual: the bundle’s protocols are not consolidated when it is built, so a protocol consolidated in a session’s own directory is not used and protocol dispatch stays the slower, unconsolidated kind; behaviour is the same. Consolidating them when the bundle is built is not done, at the cost of that speed and of Elixir’sProtocolmodule once a line dispatches on a struct: adefimpltyped at the prompt for a protocol already consolidated would be ignored. Code in the VM can also load any bytes it holds withcode:load_binary/3(userland/otp/vm/src/bif/info.rs), through the same loader checks. So what confines loaded code is not how it arrived but what the VM holds: every module, however loaded, reaches only what thePlatformgrants. Loading one’s own bytecode acts within one’s own authority.- Files are the platform’s. OTP’s own
file,file_serverandfile_io_serverrun unchanged over aFilestrait, path-based and POSIX-shaped:open,read,pread,pwrite,seek,info,list_dir,rename,deleteand the rest; a 9P client implementing it is Redoubt’s, below. Names resolve inside the VM, relative to its own working directory, with.and..resolved lexically, so no name climbs above/. What/is, is the platform’s choice, and the platform must still refuse what the VM cannot see, such as a symbolic link out of a mount. An open file belongs to the Erlang process that opened it and closes when it exits; a VM has at most 1024 open files. - Programs are a large grant. A program is outside the VM altogether, so
programsdefaults to none. The host CLI grants it only with--exec, and the programs it then starts are host processes with the user’s own rights, not sandboxed. - The host embedding exposes one directory with
--rootthroughcap-std, and more with--mount, read-only if asked; a symbolic link resolves within the VM’s own name space, and a link that would leave a mount is refused.
What runs on it
Status: built · partly tested: beamlet boots the shell on Redoubt; the differential suites against the real BEAM need OTP 28 and Elixir installed and are not run by the bench, and linear-time matching, crypto's refusal without randomness, the cofactored Ed25519 check and the bound on a zlib stream are not attacked by a named test · tested (16)
- host:beamlet-vm::decodes_otp_output
- host:beamlet-vm::encodes_like_otp
- host:beamlet-vm::printing_matches_otp
- host:beamlet-vm::matches_otp
- host:beamlet-vm::block_hash_handles_every_tail_length
- host:beamlet-re::pcre_spellings
- host:beamlet-re::braces_are_quantifiers_only_when_counted
- host:beamlet-crypto::certificates_round_trip
- host:beamlet-crypto::nesting_is_bounded
- host:beamlet-crypto::mutants_never_panic
- host:beamlet-crypto::ed25519_is_rfc_8032
- host:beamlet-crypto::the_all_zero_seed_is_refused_not_a_panic
- host:beamlet-crypto::x25519_refuses_a_low_order_point
- host:beamlet-vm::compressed_terms_round_trip
- bench:userland-boot
- bench:userland-read-only
Where beamlet implements something, it behaves as the real BEAM does, and the differential suite checks it: each test runs on BEAM and on beamlet and the printed results must be identical.
- OTP and Elixir unchanged. OTP’s
stdlib,logger,file,ssl(TLS 1.2 and 1.3) andsshrun unmodified, and so do Elixir’s standard library, its compiler, OTP’s Erlang compiler and IEx, whose transcript matches BEAM’s. Every live OTP 28 opcode is implemented excepton_load. - erts’s Erlang where it is code. Of BEAM’s preloaded modules, those whose Erlang is
useful load from erts like any other:
erlang,erts_internal,persistent_term,atomicsandcounters, their NIF stubs answered by natives, andprim_eval, whosereceiveis BEAM assembly: it runs areceivethaterl_evalevaluates, such as one typed at the shell’s prompt. The rest stand on BEAM’s C runtime (the boot process, ports, sockets, tracing) and never load; a call to one isundefunless a native answers. - A fun’s identity is its module’s checksum. A fun read from the external format with the
loaded module’s checksum is that code’s fun; one with any other checksum keeps the identity it
came with, is written back byte for byte, and a call to it is
badfun, as on BEAM. Three artifacts of BEAM’s fun table are not reproduced: the order of two funs that differ only in their checksum (here the loaded code’s sorts first, then by checksum); theOldIndexa second decode of one unknown checksum is written back with (BEAM keeps the first decode’s; here each fun keeps its own); and a fun decoded before its code is loaded, which stays apart from that code once it loads with the fun’s checksum (BEAM’s table then makes the two equal; here they compare unequal). - Processes as on BEAM. Links, monitors, aliases, exit signals, registered names, timers and ETS, on one or more scheduler threads with per-process heaps and copying garbage collection.
- Regular expressions (
beamlet-re) run in linear time for every pattern, so a hostile pattern cannot backtrack for ever. A pattern either means what it means in PCRE or fails to compile; backreferences and general lookaround do not compile. - Crypto (
beamlet-crypto) implements OTP’scryptonatives in pure Rust, socrypto.erl,public_key,sslandsshrun on it. It takes randomness only fromPlatform::random, and a failure to get it fails the operation. Itsrsacrate has a known timing side channel in decryption; side channels are a stated wall, not one the design closes. - One X25519 and Ed25519 for the whole box. They are
ed25519-compact’s, the crate the loader,keydandsshduse, built from the same vendored bytes, and the RustCrypto primitives beamlet shares with the servers are built from them too (vendored dependencies). Where it departs from OpenSSL, and so from the real BEAM: an all-zero Ed25519 seed is refused as a bad key, where the crate would panic; and a signature is checked cofactored, as the loader’s andsshd’s are, so one whose R differs by a point of small order verifies. That forges nothing without the key, but signatures are malleable, and code that takes a signature as an identifier must not. - Compression is OTP’s
zlib, whose natives run onminiz_oxide: deflate and inflate in raw, zlib and gzip formats, so:zlib,:zipand compressed external terms work unchanged. Each stream bounds what it holds queued, so a hostile archive cannot make one call allocate without limit, and what it holds counts toward its holder’s heap limit (limits inside one VM). - A compiled pattern is a compiled matcher.
binary:compile_pattern/1builds a matcher the VM keeps behind the reference it returns ({bm, Ref}for one pattern,{ac, Ref}for several, as on BEAM): the patterns indexed by their first byte, the longest first, sobinary:match,matchesandsplitcompare a byte of the haystack only against the patterns that start with it, and a search costs the haystack’s length whatever the pattern count, the leftmost and then longest match still winning. An uncompiled pattern is compiled for the call, as on BEAM; the haystack is read in place; what a compiled pattern holds counts toward its holders’ heap limit. On the host, 88 patterns over 64 KiB cost what one does, 0.44 ns a byte, where comparing every pattern at every byte cost 11 ns with 8 patterns and 127 ns with 88 (the_search_is_flat_in_the_pattern_count). - Not supported: NIFs and port drivers (foreign code runs as a separate program), distribution, hot code upgrade, and any OTP version but the pinned one.
The console on a host
Status: built · partly tested: host only; a change of the terminal's size is not delivered on a host · tested (9)
- host:beamlet::the_terminal_is_raw_while_the_vm_reads_it_and_restored_at_a_normal_end
- host:beamlet::the_terminal_is_restored_when_the_run_ends_in_an_exception
- host:beamlet::the_terminal_is_restored_when_the_vm_halts
- host:beamlet::the_terminal_is_restored_when_a_signal_ends_beamlet
- host:beamlet::a_panic_restores_the_terminal
- host:beamlet-vm::a_second_console_subscription_is_refused_and_the_first_reader_keeps_the_console
- host:beamlet-vm::the_console_is_free_once_its_reader_has_exited
- host:beamlet-vm::the_console_size_is_the_platforms_answer_asked_at_each_call
- host:beamlet-vm::a_change_of_the_console_s_size_reaches_its_reader_once
On a host, beamlet’s command line puts the terminal in raw mode from the moment a process of the
VM first reads the console, so that every byte typed reaches the VM as it is (Ctrl+C included),
and restores it on every exit: a result, an exception, a halt, a signal, a panic included
(userland/otp/cli/src/tty.rs). Output processing stays
on, so a line written past the shell’s encoder still lands where a line does on a host; the
encoder ends its lines with CR LF itself, as it must on Redoubt. console_size is the
terminal’s size, read afresh at each call. A change of the terminal’s size is not delivered on a
host (console_resized answers nothing there); the VM hands a platform’s change to the console’s
reader, as on Redoubt, and the shell reads the size at each prompt
(the shell).
Console input goes to one Erlang process, the shell’s driver, which takes it with
beamlet:console_subscribe/0; a second subscription is refused, so no code run at the prompt
can take the keyboard, or the interrupt key with it, from the driver.
The console, the clock and randomness
Status: built · partly tested: its tests run on the host, on the fake kernel, against a console server that keeps `consoled`'s protocol with a host terminal for its device; it runs in a boot in bench:beamlet-boot and bench:beamlet-console · tested (14)
- host:beamlet-redoubt::writes_reach_the_screen
- host:beamlet-redoubt::a_long_write_reaches_the_screen_whole_and_in_order
- host:beamlet-redoubt::a_write_answered_busy_goes_again_after_the_retry_interval
- host:beamlet-redoubt::the_console_is_one_hub_connection_with_one_waiter
- host:beamlet-redoubt::typing_reaches_the_vm_then_its_end
- host:beamlet-redoubt::a_read_waits_for_typing_without_holding_the_vm
- host:beamlet-redoubt::an_end_of_input_already_waiting_ends_the_idle_that_takes_it
- host:beamlet-redoubt::the_console_s_size_is_its_server_s
- host:beamlet-redoubt::a_change_of_the_console_s_size_reaches_the_vm_once_reading_has_begun
- host:beamlet-redoubt::idling_with_a_deadline_returns_by_it
- host:beamlet-redoubt::after_the_console_ends_idling_still_waits_for_its_deadline
- host:beamlet-redoubt::there_is_no_wall_clock
- host:beamlet-redoubt::input_nobody_reads_holds_no_idle_and_waits_for_the_next_reader
- host:beamlet-vm::the_platform_hears_the_console_reader_come_and_go
The first part of beamlet’s platform on Redoubt, beamlet-redoubt
(userland/otp/redoubt), serves the console, the clock
and randomness over the client library and the runtime’s calls. /dev/cons is opened once, if
the namespace holds it (a lease’s does not: below), and its reads and writes go through the VM’s hub
(asynchronous underneath): one read is out at a
time, held by the server until there is typing, and one write, a page at most, with what the VM
wrote after it waiting in order behind it, since the console’s share is a page a badge. So
neither a read that waits nor a slow console holds the VM’s thread, until 64 KiB wait unwritten,
when a write waits in place as one to a slow terminal does. When the VM ends, what it wrote
reaches the console before its last line, waiting at most 2 s. After the console’s end, idle
still sleeps until its deadline. When the process reading the console exits, the VM says so
(console_listening) and no read goes out until another process reads it: typing waits at the
console, what came in before is held for the next reader, and idle does not return for it, so a
VM with nobody at the console sleeps rather than spins. The console’s size is asked of its server
at each call, and from the first read one thread keeps a resize out there and hands each change
to the VM (console_resized), whose idle returns for it. There is no wall clock, so
system_time_us is None.
./shell --fake runs the shell on it.
- The size is still the VM’s own call. It is a typed call, which no hub carries, so a console that stops answering a size query stops the VM.
- Randomness is the kernel’s. On the fake kernel it is seeded from the host for a person’s run, and fixed for a test’s, so a test repeats. On the machine it is the kernel’s own.
beamlet on Redoubt
Status: built · partly tested: programs are not built · tested: bench:session-tcp-echo, bench:session-tcp-loopback, host:beamlet-redoubt::a_connect_is_clone_ctl_and_data_and_the_peers_end_is_closed, host:beamlet-redoubt::active_mode_delivers_what_arrives_and_the_end, host:beamlet-redoubt::refusals_are_named_and_a_failed_socket_is_given_back, host:beamlet-redoubt::a_listener_accepts_through_ctl, host:beamlet-redoubt::a_read_the_server_times_out_is_asked_again, host:beamlet-redoubt::without_a_net_the_in_vm_loopback_still_serves, bench:beamlet-natives, bench:beamlet-reduction-rate, bench:beamlet-schedulers-mttcg, bench:beamlet-schedulers-one-hart, bench:shell-compile-module, bench:shell-compile, host:beamlet-vm::two_schedulers_lose_nothing_across_the_system_lock, host:beamlet-vm::schedulers_go_offline_and_online_again, host:beamlet-redoubt::two_schedulers_lose_nothing_across_the_system_lock, host:beamlet-redoubt::four_schedulers_lose_nothing_either, host:beamlet-redoubt::without_the_argument_the_vm_has_one_scheduler, host:beamlet-redoubt::the_count_is_one_decimal_argument_capped_and_one_otherwise, host:beamlet-redoubt::the_io_report_counts_the_schedulers, host:beamlet-redoubt::without_a_console_writes_are_dropped_reads_end_and_the_size_is_unknown, host:beamlet-redoubt::a_vm_without_a_console_runs_its_module_to_exit, bench:beamlet-boot, bench:beamlet-console, bench:beamlet-files, bench:boot-profile, bench:boot-profile-unverified, bench:pack-outside-module, bench:pack-bad-truncated, bench:pack-bad-wrong-length, bench:pack-bad-wrong-name, bench:beamlet-heap-flood, bench:beamlet-budget-flood, bench:userland-boot, bench:userland-bad-start, bench:lease-bad-start, bench:userland-read-only, bench:verity-flipped-tree, bench:verity-wrong-root, host:beamlet-redoubt::a_module_is_its_file_and_a_failed_read_is_refused, host:beamlet-redoubt::not_found_at_the_open_is_absent_and_every_other_error_is_refused_by_name, host:beamlet-redoubt::verified_module_lookup_propagates_found_absent_and_refused, host:beamlet-redoubt::verified_application_lookup_propagates_found_absent_and_refused, host:beamlet-redoubt::a_packed_module_comes_from_the_pack_and_any_other_from_the_volume, host:beamlet-redoubt::a_pack_with_a_bad_entry_is_refused_whole, host:testbench::the_boot_pack_is_deterministic_sorted_and_only_of_the_objects, host:beamlet-vm::the_search_is_flat_in_the_pattern_count, host:beamlet-vm::the_matcher_finds_what_every_pattern_at_every_byte_finds
On Redoubt, beamlet is a native program. Its built Platform adapter uses the client library
(native programs) for the console, files, verified code lookup
and the system natives, and the kernel’s calls for the clock and randomness. The program
adapter remains planned.
| Method | On Redoubt |
|---|---|
monotonic_us, idle | the kernel’s time_now (microseconds since boot); idle is a receive with a timeout (timer) |
system_time_us | None until wall-clock time and time sync exist, in M6 (persist, install, share); the VM then counts system time from the Unix epoch at boot, so the logger and anything else that stamps a time works and a date says 1970. A check that a date has begun (a certificate’s notBefore) then fails, a check only that one has not passed (a token’s expiry) passes, and times from two boots cannot be ordered |
console_write, console_read | the client library’s console: writes and reads on the /dev/cons connection; a read with nothing to read is parked by the server, so input arrives as a completion and Eof means the connection ended (consoled). /dev/cons is optional: a lease’s namespace has none (agents), and the platform then serves the console as one that has gone: a write is dropped, a read answers Eof at once, console_size is None and nothing resizes, so an agent’s output has nowhere to go but its files |
console_size | a fresh consol size call on every query, never cached; a server that does not serve it, or a consoled its manifest does not size, refuses the call and the answer is None |
console_resized | one thread, started by the first console read, keeps a consol resize call out at the console’s server and calls again for each change; it stores the newest size and wakes the VM, whose idle returns for it. On sshd’s channels a change of the window’s size arrives so, until the channel ends: sshd answers a waiting call then and refuses the next, and the thread ends; consoled refuses the call, since a multiplexed session’s completion call fills a connection’s one parked call there, and its UART never resizes anyway: the thread ends, and the console never changes size |
prompt_drawn | nothing, but in a boot-stats build the line beamlet: first prompt drawn [t=N], the end of the span the boot profile times |
random | the kernel’s random call |
load_module, load_app | takes the requested file from the boot pack, if the pack holds it (below); otherwise reads the requested file (Elixir.Enum.beam, elixir.app) whole from the root of the verified userland volume, through its erofsd (erofsd:system), which reads it through its verityd; a reader of the volume trusts that erofsd and verityd (R76 (verified volumes)) in place of checking each object itself. A name erofsd answers not_found to at the open is Absent; any other refusal at the open or on the read is Refused, with one console diagnostic naming the file and the error’s name and no other source tried; the bytes read whole are Found (R75 (verified userland)). From M6 (persist, install, share), the principal’s profile joins the lookup (packages), never the session’s writable namespace. This decides which module a name finds, not what code may run |
files | 9P on the namespace’s connections through the VM’s hub: walk, open, create, read, write, stat, remove and clunk, and littlefsd’s rename (files) |
programs | the client library’s launch: native programs in carved budgets (native programs); planned, as ports. Launching is the launch/1 native’s |
system | the namespace’s files and named handles, typed calls on a pool of threads, served endpoints on a thread each, budgets from the named handle budget, the kernel’s label stamp, and launch (natives) |
TCP is Plan 9’s /net, served by ipd (ipd): gen_tcp
works unchanged over a backend in Erlang, beamlet_tcp, embedded in the VM
(vm/lib/beamlet_tcp.erl), and the Rust side only
moves bytes. When the namespace holds a /net, a socket is a process that reads
/net/tcp/clone for its number, writes connect(addr, port) to its ctl in the wire encoding
(the generated codec, wire), reads ctl until the state is
established, and moves bytes through data: each a file operation through the VM’s hub like
any other (files), a reader process per socket for what arrives,
writes from the socket process. Framing, packet modes and the active-mode mailbox stay in
Erlang; the hub’s etimedout on a wait is asked again, and 0 bytes is the peer’s end
(tcp_closed, or {error, closed} to a recv). There is no backpressure: the reader drains
the socket as bytes arrive and a passive socket’s unread bytes accumulate in the socket process,
bounded only by the VM’s budget. close is ctl close, and a socket process
that dies has its socket aborted by its reader, so nothing of ipd’s outlives it.
- Every socket is
ipd’s then, loopback addresses included. The loopback inside the VM is not consulted: a connect to127.0.0.1or to the box’s own address isipd’s refusal (R59 (never the box’s own addresses)), and a refusal keeps the nameipdgave it, by the error table’s last column (wire):eaccesfor the box’s own addresses, for a labelled caller (R60 (a sink refuses labels)) and for an address or port outside the scope; a connect the stack could not complete (nobody answering, a reset) reads back as the closed state,econnrefused.session-tcp-loopbackconnects to the box’s ownsshdat its loopback and its listed address and the capture shows no SYN to either;session-tcp-echoechoes through the bench’s peer in each packet mode. - A session’s sockets are its share of
ipd’s admission (sizing): aStateunit each, as its/netconnection is one, within its account’s bucket divided among the account’s sessions, and a socket closed by the session keeps its unit through TCP’s TIME-WAIT (10 s). Past the share a connect is{error, emfile},ipd’stoo_many. The image’sipdgives alice’s account 32 sockets, 4 + 32 = 36 units, so her session with her console session beside it has 36 / 3 = 12, its connection and 11 sockets: eleven connections opened and closed in quick succession all succeed, and the twelfth while they linger isemfile(session-tcp-churn). A principal without an override has the defaults, 12 / 3 = 4 beside a console session, so 3 sockets, and a client that opens and closes connections in quick succession reuses one instead, assession-tcp-echodoes. - Addresses are IPv4 tuples. A name is
{error, nxdomain}until the resolver (name-scoped connections); an IPv6 tuple iseafnosupport. listenisipd’s listen rule (scopes): a scope without one, as a session’s in the image, answers{error, eacces}; one with it listens, at most 8 in the backlog, andacceptis a read of the listener’sctlthatipdholds until a connection arrives, the new socket’sremoteits peer name.- Without a
/net(a lease, a VM on the fake kernel) a loopback network inside the one VM stands in:listenregisters a port,connectto a loopback address finds it, and any other address is{error, enetunreach}, since the VM has no network (agents). One VM is one trust domain, so the stand-in adds no authority: it reaches only the VM’s own processes. - Not inet.
socknameof a socket ofipd’s is{error, enotsup}:ipddoes not tell a socket its own address and port, and nothing is invented; a listener’s is{0,0,0,0}and its port.
The waits fit together because a server parks a request strictly shorter than its clients wait
for it: ipd holds a data or ctl read 8 s against the hub’s 10 s
(ipd, files), so a read the hub
gives up has already been answered timeout, the client asks again, and bytes arriving between
wait in the socket’s buffer for the next read; the host test
a_read_the_server_times_out_is_asked_again runs the backend against a /net that keeps the
rule and feeds the bytes after the timeout.
The size is asked afresh because the only console
whose size changes is an SSH channel, and a cached size would answer a redraw with the size from
before the change; a caller that wants to be told of a change uses the parked resize call
(the shell).
On the machine, beamlet is the program beamlet, started like any other with a console, a
budget, and a connection to the userland disk’s erofsd, a named handle its argument
endpoint= names (erofsd:system). Its waiter threads are the runtime’s thread::spawn.
schedulers=Ngives the VM N scheduler threads, at most 8; absent or malformed, one. The count is the image’s choice: the steward gives a session’s VM two (the steward). The kernel does not tell a program how many harts there are, so the VM cannot follow the machine; a call that says so is a later package’s, if an image ever needs one. The first scheduler is beamlet’s own thread, and each other is a thread of the runtime’sthread::scopewith an 18-page stack, the size of a session VM’s first: twice the largest first-thread peak the memory cases find, the idle shell’s 36,136 bytes (beamlet-footprint) over a compile’s 34,280 (beamlet-compile-footprint), now that the compressorterm_to_binary(T, [compressed])runs is a few hundred bytes and not a 65 KiB frame (what the VM holds at its prompt); a module compiled at the prompt over SSH runs (shell-compile-module). A helper thread’s stack sits above an unmapped guard page (the native runtime): an overflow there faults at once and the kernel ends the VM at the overflow, not later from a heap it wrote over (thread-stack-guard). The runtime paints every spawned stack, so the scan measures them: at the idle prompt the second scheduler peaks between 21,496 and 25,200 bytes on rv64 over three runs and at 21,752 on rv32, the platform’s three other threads under 1,600 (beamlet-footprint,stack beamlet thread N). A compile runs the compressor’s frames on whichever scheduler holds the process;beamlet-compile-footprintruns one scheduler so that is the painted first thread, whose 34,280 bytes above are the figure every scheduler’s 18 pages are sized from; no case compiles on a helper, so a helper’s peak under a compile is not measured. Several schedulers below.bind=PREFIX=HANDLEputs a named handle beamlet was handed at a prefix of its namespace (bind=/home/alice=walfsd:data): thebind/2a session performs for itself (namespaces), for a VMinitlaunches alone, whose namespace holds only/dev/cons. In the steward’s sessions the session’s namespace does this. It creates no authority: the handle was handed already, and a bind naming a handle it was not handed, or a prefix that is not a clean absolute path, is refused before any server is asked anything, with one line on its console.report_iohas the platform say, when the VM ends, how many requests went through the hub and how many threads it ran (beamlet: io: N requests through the hub; threads: S schedulers, W waiters,1 schedulerfor one); bench:beamlet-files reads it.
In a boot-stats build (checked builds) beamlet says beamlet: first console read [t=N] at the VM’s first console read, with time_now in µs, and beamlet: first prompt drawn [t=N] when the shell’s driver has drawn its first prompt
(beamlet:prompt_drawn/0): the driver takes the console before it draws its banner and prompt,
so the second line is the boot’s time to its prompt, where a user sees the box ready, and the span
the target is on; both lines are drawn beside the prompt. boot-profile and
boot-profile-unverified measure it.
Measured in that build under icount (shift=3, sleep on) with seed 1, in guest time
(bench:boot-profile, bench:boot-profile-unverified):
| system volume | rv64 verified | rv64 unverified | rv32 verified | rv32 unverified |
|---|---|---|---|---|
littlefs (littlefsd, retired for this volume) | 1,016.7 s | 534.7 s | 1,044.2 s | 558.0 s |
EROFS (erofsd) | 16.0 s | 12.5 s | 15.7 s | 12.1 s |
| EROFS, with the boot pack | 12.4 s | 9.9 s | 12.0 s | 9.6 s |
| EROFS, with the boot pack, the shell the steward’s console session, to the driver’s first read | 15.4 s | 13.2 s | 17.6 s | 15.3 s |
| the same, 16-page lends for the public entry’s push and the session’s stream, to the first prompt drawn | 14.7 s | 12.2 s | 16.3 s | 13.7 s |
| the same, the shell’s commands loaded when first called, a boot pack of 78 entries | 10.3 s | 8.5 s | 11.8 s | 9.9 s |
On littlefs 99 % of the boot was in the VM’s 96 lookups: littlefsd found each file’s name in the
volume’s root directory again two or three times for every 9P operation, 77,710 block reads of 673
distinct blocks (littlefsd). On EROFS the same 96 loads
make the same 652 9P operations, and erofsd reads the volume 641 times (112 inodes, 243
directory blocks, 286 runs of a file’s blocks) in 642 calls to blkd of 3.35 MB in whole sectors;
the loads take 7.1 s verified and 3.7 s unverified, and the VM’s own work and its console about
6.5 s either way.
With the boot pack (below) the 96 lookups become one read of the pack, 115 9P reads of 16 KiB:
the boot makes 208 9P operations, and erofsd reads the volume 208 times (16 inodes, 35 directory
blocks, 157 runs of a file’s blocks) in 209 calls to blkd of 2.46 MB. (That build counted each
call erofsd made to blkd, and the whole sectors it carried; erofsd’s boot-stats now
counts each read it asks of its range, whatever calls the range client splits it into, and the
bytes it asks for.) Reading the pack takes 3.6 s verified and 1.2 s unverified, and the VM’s work
after it, decoding the modules as they are called, 8.0 s. On littlefs the prompt with the boot
pack is at 152 s verified, since littlefsd finds the file again for each read.
Since the steward starts the shell as the console principal’s session
(the steward), the verified first prompt on
rv64 comes at 10.3 s: init has started its servers and pushed beamlet’s 4 MB public entry to
bootfsd by 1.6 s, when it starts the steward; from 1.6 to 4.4 s the steward carves the
session’s budget, streams its image from bootfsd into the new process in the client library’s
launch batches of 64 pages (native programs), and the VM reads
its boot pack; from 4.4 to 8.4 s the shell’s application starts, until its driver takes the
console; from 8.4 to 10.3 s the shell starts under the driver, to its banner and first prompt.
Unverified the same points are at 1.5, 2.9, 6.7 and 8.5 s. On rv32 the steward starts at 1.9 s,
the boot pack is read by 5.1 s, the driver reads at 9.6 s and the prompt comes at 11.8 s;
unverified at 1.9, 3.5, 8.0 and 9.9 s. The push and the stream are calls of the same cost
whatever they carry, so init and the steward lend 16 pages a call: the add carries 32 KiB, a
power of two, so bootfsd’s entry doubles onto 4 MiB and its heap’s peak stays half its cap. The
shell’s commands are loaded when first called, so its start loads few modules. The kernel’s
audits and fault reports hold no other hart, and a slice’s end pays for its own work, not for
checked calls, whole budgets and three timer arms; each takes about 2 s off the prompt on both
widths. The row is seed 1, whose runs differ by under 0.03 s; in a whole run of the bench the
unverified prompts came within 0.03 s of it. Across seeds 1 to 4 the prompt moves by under
0.08 s; seed 5 is the slowest, at 10.9 and 8.8 s on rv64 and 12.3 and 10.2 s on rv32.
The boot-time target: in this build, the prompt within 15 s of guest time, verified and
unverified, on both widths: the slowest measured prompt plus a tenth, rounded up to 5 s, here
seed 5’s verified prompt on rv32, 12.3 s, so 13.5 s, rounded up to 15 s.
boot-profile and boot-profile-unverified fail past it, and run in every whole run of the bench.
How fast compiled code runs. In the console session of the image’s boot, under icount
(shift=3, so a microsecond is 125 guest instructions) with seed 1,
Enum.reduce(1..65536, 0, &max/2), compiled Elixir calling a native through a fun on every
step, takes 98,723 reductions; its fastest of 30 runs a second apart, a run the shell’s driver
did not share the hart with, gives the rate (bench:beamlet-reduction-rate):
| rv64 | rv32 | |
|---|---|---|
| reductions a second | 57,529 | 52,150 |
| before the kernel’s slice end picked and switched where the trap is: reductions a second | 55,674 | 49,928 |
| before the kernel’s slice end was cut (below): reductions a second | 52,936 | 46,959 |
| then: guest instructions a reduction | 2,374 | 2,657 |
| then: guest instructions a step of the loop | 3,577 | 4,002 |
| before the interpreter’s changes below: reductions a second | 34,041 | 31,215 |
| before: guest instructions a step of the loop | 5,531 | 6,033 |
A native call no longer fills all 255 argument slots it could pass (765 instructions on rv64 for
a native of two arguments), a fun’s arguments stay in the x registers instead of going through a
vector, and the interpreter’s step is inlined into its loop, so the registers it saves are saved
once a time slice rather than once an instruction. On the host, beamlet runs a tail-recursive
loop and lists:foldl/3 at 70-200 ns a step. Of what is left on rv64, about four fifths is the
interpreter’s own work, some 200 guest instructions a BEAM instruction, and the rest the
kernel’s, most of it the slice’s end: about 8,800 kernel instructions on rv64 and 12,900 on rv32
each time the VM’s slice ends, against 15,000 and 20,700 before the kernel stopped re-arming the
timer three times a preemption and decoding a whole budget to move one word
(scheduling). No yield and no count changed: a
process still yields after its 2,000 reductions or 200,000 instructions, and the kernel’s time slice still preempts the VM’s
thread whatever it runs. The floor: 42,000 reductions a second, the slower width’s rate less
a tenth, rounded down to a thousand; beamlet-reduction-rate fails below it, and the rate before
the interpreter’s changes is below it on both widths. A session where a line has just been typed runs slower
than this for a while: the shell’s driver measures the line being edited again for each piece of
input it echoes, which can take as much of the hart as the line’s own code for seconds after a
long line; the floor’s run is the fastest of its 30 for that reason.
Several schedulers. The VM’s schedulers share one run queue, under the lock on the VM’s
shared state, and take its processes in turn (vm.rs). A
process runs on one scheduler at a time, its heap, stack and collections its own, and the lock is
held between time slices and by the instructions and natives that touch what is shared: the atom
table, the loaded modules and loading them, ETS, persistent_term, registered names, timers, and
the mailbox of a process another scheduler runs, which takes its messages when its slice ends.
Loaded code and the literal chunks are read without it: each scheduler keeps the calls it has
resolved, checked against counters bumped when code changes. The platform, and the hub in it, is
locked after it, never before, and only a scheduler with nothing running anywhere waits in the
platform’s idle. A process still yields after its 2,000 reductions or 200,000 instructions, on
whichever scheduler runs it, and keeps that scheduler while nothing else waits: another is woken
only for more work than the schedulers ending a slice take back themselves. The kernel’s time
slice preempts each scheduler’s thread. On Redoubt the lock is the runtime’s mutex and the wait
for work its condvar, which sleep on endpoints (native programs).
Their endpoints are made when the VM starts, five pages with two schedulers: the system’s lock,
the platform’s, the wait for work’s own, and one for each scheduler that waits; so schedulers
contending allocate nothing, and a VM at its budget’s limit does not end at its first contended
lock. If the kernel refuses one, the VM runs on one scheduler. A resource’s lock (an atomics
array, a zlib stream, ETS’s count of what its objects hold) makes its endpoint, a page, the
first time two schedulers contend for it, and gives it back with the resource; one the kernel
refuses leaves that lock’s waiters polling.
The schedulers are the session process’s threads, in its one budget: the kernel charges each
hart’s runner to that budget’s one pass (scheduling),
so the session’s share among budgets is its weight’s as before, and on a machine whose other harts
are idle it runs on as many as it has schedulers busy. Nothing new is carved, and the heap, ETS and
persistent_term limits are a sixteenth of the budget each as before; the second scheduler’s
stack and thread page and the locks’ endpoints are 25 of the share’s pages
(above).
In the console session of the image’s boot, two Task.async loops of
Enum.reduce(1..65536, 0, &max/2), awaited, timed 10 times with one scheduler online and 10 with
two, alternated; the fastest of each (bench:beamlet-schedulers-mttcg, and
bench:beamlet-schedulers-one-hart at 1 hart):
| rv64 | rv32 | |
|---|---|---|
| 2 harts, one scheduler online | 417,514 µs | 487,274 µs |
| 2 harts, two schedulers | 234,728 µs | 257,310 µs |
| the ratio, one over two | 1.77 | 1.89 |
| 1 hart, one scheduler online, in guest time | 3,459,590 µs | 3,790,698 µs |
| 1 hart, two schedulers, in guest time | 3,601,614 µs | 3,999,102 µs |
| the ratio at 1 hart | 0.96 | 0.94 |
Across runs alone the 2-hart ratio measured 1.77 to 2.14 on rv64 (7 runs) and 1.83 to 1.94 on rv32 (5). The 2-hart times are host times: the case runs
without icount, in multi-threaded TCG, where the harts are host threads that run at once.
Under icount QEMU runs the harts in turn on one host thread and guest time counts every hart’s
instructions, so a second hart cannot make a run faster in guest time
(which cases run in guest time). Both times come from one boot,
so the ratio holds beside a steady load but not beside a stalled vCPU thread, and is a verdict
only alone. At 1 hart nothing runs at once, and the cost of two schedulers is the instructions
their switching takes: the kernel’s slice ends alternate the two threads where one scheduler
keeps the hart. So the one-hart case runs under icount (shift=3, seed 1), where the ratio is
a count of instructions, the same on any host however loaded: seven boots, two beside a
twelve-core load, read 0.94 on rv32 and 0.95 or 0.96 on rv64; across boots the one-scheduler
side’s fastest runs lie within 100 µs of each other and the two-scheduler side’s in two clusters
2.3 ms (rv64) and 2.8 ms (rv32) apart, within 0.1 % of a 3.6 or 4.0 s run, and a boot’s ten pairs
lie within 1.7 % of each other. On the host’s clock the same case read 0.81 to 0.95 across six
boots alone on rv32 (0.96 twice on rv64): a boot’s ten host times of either side spread by a
tenth, and the fastest of each side met by chance. Sampled beside those runs, the vCPU thread
moved among the four cores of q‘s quiet set 13 to 28 times a run, and those cores’ clocks
read anywhere from 0.8 to 4.7 GHz; pinned to one host core the case read 0.93 on rv32 and 0.96
on rv64, and a side’s ten times still spread by 3 to 9 %. The floor: a ratio of 1.40 at 2 harts, a fifth under the lowest measured
alone, since the train’s bench runs the case beside other work, where it once measured 1.53; a VM
whose schedulers ran one at a time would measure about 1.0. And 0.90 at 1 hart: two schedulers
sharing one hart keep nine tenths of one’s throughput, four points under the slower width’s 0.94
in guest time, where a boot’s pairs vary by less than two. The case does not measure what the kernel
charges for the two threads: by the kernel’s rule each hart’s run is charged to the session’s one
budget (scheduling), which the scheduling cases
judge, not this one. With two schedulers at 1 hart, beamlet-reduction-rate measures 52,936 (rv64) and
46,959 (rv32) reductions a second, the table’s rates within a fifth of a percent. The rate is
judged at the 1 hart its floor was set at, under --smp N too: under icount a second hart’s
instructions count in guest time, so at 2 harts main measured 45,354 (rv64) and 37,157 (rv32),
under the floor on rv32, as with two schedulers (45,778 and 36,967). Before a yielding
process kept its scheduler it changed threads every slice, a kernel wake-up each, and measured
43,511 on rv64.
Residual: loading a module holds the lock, so while one scheduler loads, no other starts or ends
a time slice. To the shell’s first prompt, 116 loads hold it 8.2 s of guest time on rv64 (8.9 s
on rv32), the longest unicode_util’s, 877 ms (969 ms). A typed call the platform makes in place
(the console’s size, littlefsd’s rename) holds the platform’s lock. The runtime’s heap is one
spin lock (native programs): two schedulers allocating
at once take turns there, and one waiting for a holder the kernel preempted spins. A native is
still not preempted, but holds only its own scheduler.
Before its VM starts, beamlet reads the volume’s boot pack, boot.pack, whole: one open, one stat
for its length, one allocation of that length charged to the VM, and reads of 16 KiB in order, the
lend a module’s file is read through (pack.rs). The
image’s builder makes the boot pack from the modules and resources the shell loads to its prompt,
which image/userland.toml names, their bytes copied from the files beside it: a magic and a
version word, the entry count, an index of each entry’s name, offset and length sorted by name, then
the entries’ bytes back to back. The same files and names give the same pack, byte for byte. beamlet
checks the boot pack whole before using any of it: the magic and the version, each name a module’s
or a resource’s file name in strictly ascending order, the entries’ bytes back to back from the
index’s end to the file’s, and each module’s first atom, its own name, the one its entry names. A
lookup takes a name the boot pack holds from it, a copy of its bytes that the loader decodes and
drops as it decodes a file’s, and asks the volume for any other name, as above, so the order is the
boot pack, then the volume’s file, then the code path. Once every entry has been taken, at the
prompt, the boot pack’s allocation goes; a module it holds that is never called keeps it, at most
the pack’s size. A boot pack the volume does not hold is said once on the console and the lookups go
to the files; one that cannot be read, or does not check, is said once, naming the boot pack and
why, and beamlet ends as below. The boot pack is per VM and read-only: no sharing of its pages
between VMs, no byte cache in the steward, no decoded snapshot, no pack per profile. It is a file of
the verified volume (R75 (verified userland)), never
written at run time.
If the volume does not attach, because erofsd serves it as corrupt, the boot pack cannot be read
or does not check, or the module it is told to start cannot load, what it does is decided by its
console. With one (a session’s VM, or one init starts), it says why there and waits without
exiting: a tampered disk must not become a restart loop that reboots the machine
(bench:userland-bad-start, bench:pack-bad-truncated). Without one, a lease’s VM
(agents), waiting would be silent, a budget held to its deadline with nothing
said: it exits, and its exit code, the one thing the steward’s lease … exited line carries
(the steward), is the diagnostic, 1 in a default build and the
end’s own code in the test-only exit-kinds build (bench:lease-bad-start), a halt’s the status
the process gave (an agent whose input could not be read, bench:lease-writes). That exit is an exit,
not a fault: crash blame is init’s, for a server it restarted whose exit notice names a faulting
thread’s current call (init), and a lease’s VM is the
steward’s launch, so its exits count toward no lockout
(R40 (blame by label set)): a sponsor’s retry of a lease that cannot start
costs its own budget and nothing else
(host:redoubt-steward::a_leases_exit_is_its_end_and_no_blame). A read refused by the
volume’s admission for the moment is tried again before it counts as refused
(the client library): a VM loading beside its account’s other VMs
rides out the moment.
Each label set that runs beamlet reads its own attachment of the userland image through its own
blkd, verityd and erofsd, all carrying that set
(R34 (confined placement)).
The timer’s counter frequency is not needed: time_now’s microseconds serve the clock and
idle’s deadlines (bench:beamlet-console).
Natives
Status: built · partly tested: the machine's cases run the VM under a tester in the steward's place, but for budgets and launching, which also run in the steward's own sessions over SSH, a vault session's among them · tested (40)
- bench:steward-vault-launch
- bench:beamlet-natives
- bench:beamlet-serve
- bench:beamlet-launch
- bench:beamlet-natives-attack
- host:beamlet-vm::a_lookup_gives_the_prefixs_connection_and_the_rest_and_refuses_by_name
- host:beamlet-vm::a_bind_names_a_connection_and_refuses_anything_else
- host:beamlet-vm::the_table_lists_path_name_and_handle
- host:beamlet-vm::a_calls_reply_arrives_as_a_message_with_its_handles_as_resources
- host:beamlet-vm::a_send_is_one_way
- host:beamlet-vm::budgets_are_carved_read_and_destroyed
- host:beamlet-vm::labels_are_fixed
- host:beamlet-vm::the_identity_is_the_platforms_and_without_a_system_is_not_supported
- host:beamlet-vm::a_launch_takes_everything_from_its_caller_and_its_end_arrives_as_a_message
- host:beamlet-vm::a_launch_that_serves_gives_its_caller_the_send_right
- host:beamlet-vm::requests_arrive_with_badge_account_and_labels_and_are_answered
- host:beamlet-vm::a_decoded_handle_grants_nothing
- host:beamlet-vm::arguments_of_the_wrong_type_are_badarg_and_reach_no_platform_call
- host:beamlet-vm::lists_past_their_caps_are_refused
- host:beamlet-vm::a_handle_of_the_wrong_kind_is_refused_by_name
- host:beamlet-vm::a_dropped_handle_is_closed_when_its_holder_is_collected
- host:beamlet-vm::without_system_calls_every_native_is_not_supported
- host:beamlet-redoubt::the_labels_are_the_kernels_stamp_on_the_vms_own_send
- host:beamlet-redoubt::a_lookup_gives_the_longest_prefix_and_the_rest_and_refuses_by_name
- host:beamlet-redoubt::a_bind_is_the_files_namespace_and_one_connection
- host:beamlet-redoubt::a_budget_is_no_connection_and_no_endpoint
- host:beamlet-redoubt::a_bind_to_a_server_that_never_answers_is_refused_within_its_bound
- host:beamlet-redoubt::binds_past_the_cap_are_refused_and_a_bound_prefix_is_still_replaced
- host:beamlet-redoubt::a_copy_within_one_volume_is_the_servers
- host:beamlet-redoubt::a_sessions_identity_is_read_from_its_arguments
- host:beamlet-redoubt::the_platform_tells_a_session_its_identity
- host:beamlet-redoubt::a_typed_call_goes_out_on_a_pool_thread_and_its_reply_is_an_event
- host:beamlet-redoubt::requests_arrive_with_badge_account_and_labels_and_an_answer_reaches_the_caller
- host:beamlet-redoubt::a_request_never_answered_is_answered_by_the_serve_thread_at_its_deadline
- host:beamlet-redoubt::a_request_already_waiting_ends_the_idle_that_takes_it
- host:beamlet-redoubt::an_endpoint_served_stays_open_when_its_term_is_dropped
- host:beamlet-redoubt::a_launch_takes_what_it_is_given_and_its_end_is_an_event
- host:beamlet-redoubt::a_launch_that_serves_gives_the_child_the_receive_right_and_its_caller_a_send_right
- host:beamlet-redoubt::a_launch_past_its_jobs_is_refused_and_a_serves_endpoint_with_it
- host:beamlet-redoubt::a_labelled_sessions_child_takes_its_labels_and_runs
A native is one of three kinds, and no other:
- OTP’s own, reimplemented in Rust where BEAM has C (
crypto,re,zlib, the file and buffer primitives): the API is OTP’s, so the differential test holds each to the real BEAM (what runs on it). - The system’s: what has no POSIX equivalent, the small fixed set below.
- A primitive the interpreter is too slow for, and no more than the primitive: the screen buffer (screen natives).
Anything else is Elixir. Every native’s own code holds no unsafe; it bounds the work one call
does, since a native is not preempted by reductions; it reaches no panic from any argument; and it
is attacked with hostile arguments, fuzzed where it parses. OTP’s own natives parse what OTP’s do
(certificates, compressed streams, patterns), held to BEAM by the differential test; a native of
the other two kinds never parses a large untrusted format, which goes to a native program in a
budget of its own instead (native programs).
What has no POSIX equivalent reaches Elixir through a small fixed set of beamlet natives, and every server binding is pure Elixir over them:
| Native | Shape |
|---|---|
ns_lookup/1, bind/2, ns/0 | the namespace table: the longest matching prefix and the rest of the path |
copy_file/2 | a copy the file server makes itself, within one server |
call/3 | submit a call; the reply arrives as a message to the calling Erlang process |
send/2 | one-way |
serve/1, reply/2 | serve an endpoint: requests arrive as messages carrying badge, account and labels |
budget_create/1, budget_destroy/1, budget_usage/1 | carve and end budgets; a deadline makes one a lease |
labels/0 | this VM’s label set, fixed when its budget was made |
identity/0 | what the steward told a session’s VM of itself: its principal, its labels’ names, its context, where its home is bound |
launch/1 | launching a native program: the image, budget, namespace, handles and arguments come from the Elixir caller, and the client library’s launch makes the calls and writes the startup block; a server is given a new endpoint to serve, and its caller the send right |
They are the module redoubt’s, over the platform’s System
(userland/otp/vm/src/platform.rs; the argument checks in
bif/system.rs, the calls in
userland/otp/redoubt/src/system.rs). A platform
with no System, the host CLI’s, answers each {error, not_supported}; the host’s
fake-redoubt runs Redoubt’s, on the fake kernel. Each native, as Erlang sees it:
ns_lookup(Path)is{ok, Handle, Rest}: the connection the longest matching prefix of a clean absolute path names and the rest below it, or, for a name with no/, the named handle the VM was started with (keyd,budget, a session’snotices) and<<>>. A path that is not clean (..,//) isbad_name, one nothing is bound atnot_found.bind(Prefix, Connection)puts a connection at a clean absolute prefix of the files’ own namespace: a path under an existing prefix shadows it for longer matches only. A handle that is not a 9P connection is attached once, then and there, each of the attach’s two calls waiting at mostATTACH_USon the VM’s thread; one whose server does not answer or does not speak 9P, or a budget, isnot_a_connection. A bind at a prefix already bound replaces it; the table holds at mostMAX_BINDINGS(64) entries, those the VM started with included, and a new prefix past them issystem_limit, so a loop of binds fails visibly and never on the VM’s heap.ns()is[{Path, Name | nil, Handle}], the namespace’s entries in binding order, then the named handles.copy_file(From, To)is{ok, Bytes}: the file server copies the fileFromto the new fileToitself, by its typedcopy_file, so no byte crosses into the VM. Both paths resolve asFile’s do, against the working directory; on two connections it is{error, exdev}, and the caller copies through the VM; onto a name that is there,eexist. It is a typed call, which no hub carries, made on the VM’s thread as a rename is, so the VM waits while the server copies. A platform whose servers do not copy, the host CLI’s, answersenotsup.call(Handle, {Words, Buffer, Handles}, TimeoutMs)is{ok, Ref}, and the reply arrives as{reply, Ref, {ok, {Words, Buffer, Handles}} | {error, Name}}. A buffer is a binary for a message that lends one andnilfor an inline one; the reply’s buffer is the bytes its word 1 says at the front of the lend. No hub carries a typed call, and no scheduler waits for one: a pool ofCALL_THREADSthreads makes them,MAX_QUEUEDmore wait, and past that a call isbusy; each is bounded by its timeout, at mostMAX_CALL_MS, a ceiling on what a caller may ask and not any call’s default: the generated clients pass 5 s for a short call and the ceiling itself for the one call measured past it, a lease’s launch (wire), and a timeout above the ceiling isbadargbefore any call is made. A VM that asks for the whole of it holds one of its own call threads that long, a cost inside its own trust domain. The caller waits inreceive, as for any message.send(Handle, {Words, Buffer, Handles})is one way, waiting at mostSEND_TIMEOUT_USfor its receiver. The natives encode nothing of a protocol: the generated clients do (wire).serve(Endpoint)serves a receive right the VM holds, on a thread of its own, which keeps the endpoint open and serves it for the VM’s life (at mostMAX_SERVEDendpoints; one served already, by the same handle, isalready_served; a second handle to the same receive right is not told apart): each call is admitted by the serving library’s admission, per (account, label set) with a share per badge (R26 (admission fairness)), and parked with the library’s deadline,REQUEST_WAIT_US(R28 (parked-call accounting)); it reaches the serving process as{request, Request, Badge, Account, Labels, {Words, Buffer, Handles}},Requestnilfor a one-way send; each request or send the thread takes goes into the VM’s forwards queue and the thread is back at its receive at once, the VM taking the queue at its next poll or woken if it idles (the forwards queue).reply(Request, {Words, Buffer, Handles})answers it once. A request the Erlang side never answers is ended by that thread at its deadline and its admission given back: the VM holds nothing for it. These are calls, not 9P, so the multiplexed connections’ rule (R77 (multiplexed requests)) is not this one’s.budget_create(#{pages, processes, weight, labels, account, deadline})carves a child from the VM’s own budget, the named handlebudget(no_budgetwithout one);labels,accountanddeadlinemay be left out, and a deadline, in the clock’s microseconds, makes it a lease (deadlines). Labels left out are the VM’s own, the setlabels()reads: a session isuser-class, and the kernel gives auser-class caller’s child exactly its parent’s labels, so in a labelled session that default is what lets it carve, and launch, at all. Labels given go to the kernel as they are: fewer than the session’s arelabel_denied, moreclass_denied(labels on budgets).budget_destroy(Budget)destroys it and everything in it;budget_usage(Budget)is{ok, #{pages => {Limit, Used}, processes => {Limit, Used}, weight => {Limit, Carved}}}. A handle of another kind iswrong_objectbefore the kernel is asked.labels()is the VM’s label set, read at its start off the first call thread’s first wake-up, which the kernel stamps with the sender’s labels as it stamps every message: the kernel’s word, not a launcher’s, and fixed.identity()is{ok, #{principal => Name, labels => [{Name, Id}], context => Name | nil, home => Path | nil}}: what the steward told a session’s VM of itself in its arguments (principal=,label=NAME:ID,context=,home=/PATH; sessions), read once at the start and fixed. It is the launcher’s word, information and not authority:labels()is the kernel’s, and what the session can reach is its handles. A VM started with noprincipal=, as one underinit, gets{error, not_found}.launch(#{image, budget, namespace, handles, args, stack_pages, heap_pages, serve})is{ok, Job}, and the job’s end arrives as{exit, Job, Cause, Code}(exited,faultedorkilled): the client library’slaunchwith the image the caller read, a budget it carved, its namespace entries and named handles (at mostMAX_START_HANDLEStogether,serve’s counted), and arguments; the platform adds the loader stub, which it carries asinitdoes, the job’s own exit endpoint (native programs), and the budget’s pages, which it recorded when it carved the budget, into the child’s startup block (init): one source, nothing a caller types. A receive right the VM serves among them goes to the program as a send right with a badge of the launch’s own, in a budget the VM carved (below). Withserve => Namethe program is a server: the platform makes an endpoint, gives the child its receive right as the named handleName, keeps none of it once the child has started, and answers{ok, Job, Connection}, the caller’s send right with the server’s own badge (below every minted one), whichbind/2attaches like any other; this is how a session starts the server of its pipes (native programs). At mostMAX_JOBSrun at once, each watched by a thread, started the first time one is needed, that waits for its exit notice; a launch past them istoo_many, before anything is made; a job no thread takes to watch is killed, its budget destroyed, since nothing would hear its end.
Every refusal is a Redoubt name: a term of the wrong type is badarg, as for any native, and a
well-formed request the platform refuses is {error, Name}, the kernel’s error by its name in the
ABI (wrong_object, label_denied), a 9P error by the one table’s
(wire), or the platform’s own (not_found, not_a_connection,
no_budget, busy). What waits on the VM’s thread is bounded and named: a send/2, at most
SEND_TIMEOUT_US; a bind of a handle not yet a connection, its attach’s two calls at most
ATTACH_US each; a hand-off to a thread of the platform’s, at most HAND_US; and the kernel’s
own calls for budgets and launching, which answer at once. Each native bounds its work: no list is
walked past its cap, and no binary is copied past its own:
| Bound | Value | Where |
|---|---|---|
MAX_MESSAGE_HANDLES, MAX_LABELS | 4 handles a message, 16 labels a spec, and a message’s 4 words | bif/system.rs |
MAX_START_HANDLES, MAX_ARGS | 128 namespace entries and named handles together, 128 arguments | bif/system.rs |
MAX_BUFFER, MAX_IMAGE, MAX_NAME | 64 KiB a message’s buffer, 8 MiB an image, 4 KiB a path, name or argument | bif/system.rs |
MAX_CALL_MS | 14 s, the longest a call may ask to wait: the system’s longest call, a lease’s launch under load, twice the worst start_agent the steward served under the bench’s rounds on both widths (6.64 s, rv32 at two harts; 5.851 s at one), rounded up to a second; the Elixir client’s launch_timeout/0 is the same number (the steward) | bif/system.rs |
CALL_THREADS, MAX_QUEUED | 2 calls out at once, 64 waiting | pool.rs |
SEND_TIMEOUT_US, ATTACH_US, HAND_US | 1 ms a send waits for its receiver, 1 s each of a bind’s attach calls, 1 s a thread of the platform’s to take its work (one that does not has ended, and is set aside) | system.rs |
MAX_SERVED, REQUEST_WAIT_US | 2 endpoints served, 5 s a request waits for its answer | serve.rs |
FILE_WAIT_US | 10 s a file operation waits for each request’s answer, for a share, or for a waiter’s place | files.rs |
MAX_JOBS | 16 jobs running at once, a thread of 4 pages each once one is needed | jobs.rs |
Handles are resource terms: unforgeable, collected, and never serialisable. A copy of a handle
inside the VM is the same connection (one badge, one client), so passing one to another Erlang
process is sharing it. A handle no process holds is closed at the next collection; a budget’s is
closed and the budget lives on, since destruction is a call. A handle dies with the object it names
or the budget that stamped it (objects), and the kernel gives
its index to the next handle the VM gets, so the VM marks a handle gone when it destroys the
budget it came through, and a collection closes nothing at a gone handle’s index. That covers the
budget’s own handle, and every handle the server launched with serve in that budget minted and
returned to calls on it, since its mints are stamped with that budget (a pipeline’s stage
connections, which piped mints). The VM cannot tell a handle such a server minted from one it
forwards, so a forwarded handle is marked gone too and is never closed: it stays in the VM’s table
until the VM ends, the price of never closing a reused index. The VM carves only from its own
budget, so no budget it carved is below another. What is the VM’s own goes the other way: the
send right a launch with serve gives it is to an endpoint the VM made, charged to the VM’s
budget, so the server’s death leaves the slot to the VM, and it is closed when the right’s last
term goes. A handle the VM started with is known to the namespace for the VM’s life; one a bind
made known lives with the budget it came through (the budget a launch with serve ran in, or
the one the connection a call brought it on came through), so destroying that budget forgets it,
unbinds its prefixes and ends its hub connection, and a session’s piped, bound at every
pipeline, leaves no entry and no handle behind (each once stayed for the session’s life). The
hub’s session on the forgotten connection is ended as the budget is destroyed, so a request on it
after (a stale file’s clunk at its term’s collection) is refused rather than sent: once the
handle’s last term is collected its index may be the next launch’s send right, and a request
sent on it then would reach that server, inside the session’s own table. Tested:
a_destroyed_budgets_index_is_not_closed_when_its_term_is_collected,
what_a_served_server_returned_goes_with_its_budget_and_is_not_closed_again,
a_served_servers_binding_and_handle_go_with_its_budget and
a_forgotten_connections_requests_reach_no_later_holder_of_its_index on the host, against
a fake kernel that frees those indices as the kernel does, and pipe-eight and pipe-sixty-four
on the machine. A request’s handles come with no budget: the kernel tells a receiver the sender’s
badge, account and labels, never its budget, and a carved budget carries the session’s account and
labels. So a receive right the VM serves, given in a launch‘s handles or namespace to a
program in a budget the VM carved, goes to the program as a send right with a badge of that
launch’s own (LAUNCH_BADGE on, the next for each launch on that endpoint), minted from the
receive right and stamped with the budget, never the receive right itself. What the program sends
through it is stamped with its budget at best, as its own mints are, so the handles a request with
that badge brings die with the budget and go under its Life: a program that sends a handle it
minted and is then destroyed leaves a term whose collection closes nothing at the index the kernel
has given on, be it a sibling job’s budget or a connection of the session’s. A handle such a
program forwarded rather than minted is kept, never closed, as a server’s is. The badge dies with
the budget, so no request carries it after; one on its way to the VM as the budget went is known
by its badge’s range, and its handles kept. A badge in that range the VM did not mint (a holder of
the receive right outside the VM mints what it likes) is taken for such a launch’s, so what it
brings is kept: a leak within the session’s table, bounded as a forwarded handle’s is, each
such handle staying in the table until the VM ends, and never a close. Tested:
what_a_launched_program_sends_to_a_served_endpoint_goes_with_its_budget_and_is_not_closed_again
on the host and pipe-stage-handle on the machine, where a stage given the session’s served
endpoint sends it the receive right of an endpoint it made, its budget is destroyed, and the
budget carved next is still held once the term is collected. The residual: a handle revoked
outside the VM, such as a grant whose stamp the steward destroys while the VM lives, is not known
gone, and its index may be reused before its term is collected; collecting it then closes the
handle that took the index, inside the session’s own table. A generation in the handle word is to
guard that, in work of its own. A handle cannot reach another VM in a
message: the term format writes a resource as a plain reference, with no state behind it, so a
decoded copy grants nothing, and a handle crosses between processes only in a kernel call that
names it. Delegation is always new_connection, a typed call on a connection, not a native
(sessions). Tested: a_decoded_handle_grants_nothing,
a_dropped_handle_is_closed_when_its_holder_is_collected,
arguments_of_the_wrong_type_are_badarg_and_reach_no_platform_call,
lists_past_their_caps_are_refused and a_handle_of_the_wrong_kind_is_refused_by_name on the host,
and beamlet-natives-attack on the machine, where a VM offers every native a handle another VM
wrote out, which it reads back as a plain reference, and asks the kernel for a child budget that
adds a label (class_denied: a user-class caller may not); a VM in a labelled session runs
nothing there, since the console carries no labels and its write-open of /dev/cons is refused
(consoled). Over SSH a vault session’s channel carries its labels, and
there steward-vault-launch carves with labels left out, launches a program the vault’s channel
shows, and has fewer and more labels refused, in the steward’s own session
(sshd). The cases’ tester carves each session as the steward does
(steward): the principal’s account, a
sub-budget per label set, the session from it; what it hands beyond a session’s own is said in
each case.
Residuals:
- A request the Erlang side never answers is ended at its deadline with
malformed(status 1): no status every protocol shares means a timeout, so its caller cannot tell the two apart. - A typed call whose caller exits is not cancelled: it holds its pool thread until its reply or its timeout, and the reply, with any handles it brought, is dropped and closed.
- A forgotten connection’s waiter calls on the server’s handle by its index until the server’s death fails the call. Between two of its calls, the handle’s last term could be collected and the index given to the next launch’s send right, so that one completion call reaches that server, inside the session’s own table. Holding the handle until the waiter has returned would close that window; it stays, stated.
- The six waiter places are a hard bound: a seventh live server a session uses at once is refused
emfileafterFILE_WAIT_US(10 s), and a session that needs seven servers at once has no way but to let one go first. The wait is the operation’s, parked off the VM’s thread: an ended server whose waiter has not handed its end over yet holds its place only until it does, and the fresh request is served then (once, with a fixed second on the VM’s thread, one pipeline in sixty-four was refused on rv32 beside ten bench jobs).
The namespace and launching natives are the client library’s ns and launch, so policy stays
in Elixir and encoding in Rust. The file server’s operations that name fids (Redoubt.File’s
rename, copy_file and attributes) go through the library’s littlefsd beside the file natives,
since the fids are the platform’s, not Elixir’s (native programs).
The Elixir modules over them are of two layers. A server’s typed calls are generated from its
wire table, one function per message (wire), so the
binding cannot drift from the server. Above the natives and the generated calls, a thin
hand-written module gives what is idiomatic and adds no authority: Redoubt.Namespace,
Redoubt.Budget and Redoubt.Process over the natives, Redoubt.Keys over keyd’s calls
(userland/shell/lib/redoubt).
The Elixir modules live on the userland disk, one file per module, on a volume whose root the
signed manifest pins (R75); only what the VM needs
before it can read the disk is embedded in it: its own console server, code and kernel modules
(beamlet_io, beamlet_code, beamlet_kernel, beamlet_port, beamlet_tcp) and its
stand-ins for application, gen_tcp and ram_file
(userland/otp/vm/src/vm.rs, EMBEDDED). The application
stand-in runs no kernel application; when the first application with a callback module starts,
it starts the kernel’s servers that other code calls, as BEAM starts them at boot:
erl_signal_server, global_name_server, and kernel_safe_sup, which OTP’s group waits for
before it serves a line. With no C library to ask a character’s width, prim_tty:wcwidth/1
answers {error, enotsup} and OTP measures with its own table, which agrees with BEAM’s libc on
ASCII and wide East Asian characters and parts from it on a combining mark: libc gives it no
column, the table one.
Screen natives
Status: built · partly tested: host only, but for `frame` and `draw`, which a native program's screen reads and draws its frames with on the machine (bench:nscr-interrupt, bench:nscr-beyond-cells); no boot draws a screen program's buffer · tested (30)
- host:beamlet-screen::a_buffer_is_at_most_1024_a_side_and_65536_cells
- host:beamlet-screen::the_first_frame_clears_and_sends_what_is_not_blank
- host:beamlet-screen::a_frame_sends_only_what_changed
- host:beamlet-screen::a_resize_is_blank_and_its_frame_clears_and_sends_it_all
- host:beamlet-screen::put_reads_no_more_graphemes_than_the_row_has_cells
- host:beamlet-screen::a_wide_grapheme_takes_two_cells_and_the_edge_cutting_one_leaves_a_space
- host:beamlet-screen::overwriting_half_of_a_wide_grapheme_leaves_a_space_in_the_other
- host:beamlet-screen::a_control_character_is_refused_and_nothing_of_the_call_is_written
- host:beamlet-screen::widths_are_otps
- host:beamlet-screen::a_control_character_is_badarg_and_nothing_is_drawn
- host:beamlet-screen::only_the_process_that_made_a_buffer_draws_into_it
- host:beamlet-screen::a_process_holds_at_most_four_buffers
- host:beamlet-screen::a_buffer_nothing_holds_is_given_back
- host:beamlet-screen::a_large_buffer_ends_its_owner_at_its_heap_limit
- host:beamlet-screen::a_buffer_resized_past_the_heap_limit_ends_its_owner
- host:beamlet-vm::sized_resources_past_a_processs_own_heap_limit_end_it
- host:beamlet-screen::every_vector_is_answered_as_the_decoder_answers_it
- host:beamlet-screen::a_frame_read_in_puts_what_the_reference_puts_whatever_its_bytes
- host:beamlet-screen::a_frame_of_another_size_touches_nothing
- host:beamlet-screen::a_joined_symbol_stops_the_reading_and_it_goes_on_only_from_where_it_stopped
- host:beamlet-screen::a_refused_frame_keeps_the_cells_before_the_refusal_and_a_wide_one_at_the_edge_is_refused
- host:beamlet-screen::a_clearing_frame_blanks_the_buffer_first
- host:beamlet-screen::the_positions_given_are_a_frames_own_and_a_resumed_frame_neither_puts_again_nor_forgets_them
- host:beamlet-screen::a_programs_frame_is_read_in_stopping_at_a_joined_symbol_and_refused_as_the_decoder_refuses
- host:beamlet-screen::a_buffer_is_drawn_as_the_shells_encoder_draws_its_frame_control_characters_made_visible
- host:beamlet-screen::no_byte_drawn_from_a_buffer_starts_a_sequence_but_the_encoders_own
- host:beamlet-screen::a_draw_beside_a_writer_on_another_thread_sees_whole_calls_and_loses_no_change
- host:beamlet-screen::another_process_draws_a_buffer_as_escape_sequences_once_and_may_not_draw_into_it
- bench:nscr-interrupt
- bench:nscr-beyond-cells
The screen buffer is beamlet-screen
(userland/otp/screen), a crate of the VM’s with no
unsafe and no dependency but the VM and the cells crate. Its natives, in the Erlang module
redoubt_screen, are primitives over cells; widgets, layout and focus are Elixir
(the shell).
| Native | What it does |
|---|---|
new(W, H) | a buffer: a resource, every cell blank, owned by the calling Erlang process |
resize(B, W, H) | a new size, blank; the next diff clears the screen and sends it all |
put(B, X, Y, Graphemes, Style) | writes Graphemes, a list of binaries, along the row from (X, Y), one a cell and a wide one two, clipped at the edge (a wide grapheme cut by it becomes a space, and a grapheme longer than a cell’s symbol may be, U+FFFD); returns the columns written |
fill(B, Rect, Symbol, Style) | one symbol, one code point of one column, and one style over a rectangle, clipped |
plot(B, Rect, Dots, Style) | a bitmap, one byte a cell holding its 2×4 dots in Braille’s own order, drawn as U+2800 plus the byte |
diff(B) | the cells changed since the last diff, as a cells frame, which then becomes what is shown |
draw(B) | the cells changed since the last diff or draw, as the escape sequences that draw them, which then become what is shown: the bytes the shell’s Redoubt.Term.Frame.draw/1 draws for diff’s frame. Any process holding B may call it; it writes no cell |
frame(B, Bytes, From) | reads a native program’s cells frame into the buffer from byte From (0 for its header), a cell at a time as the one decoder reads it (cells::reduce): ok; {size, W, H} for a frame of another size, nothing read; {joined, Next, X, Y, Symbol, Style} for a symbol joining code points, not put, for the caller to check is one grapheme, put, and go on from Next; {error, Why}, the decoder’s reason or cell (a wide symbol in the last column). A frame that clears blanks the buffer first |
A Style is {Fg, Bg, Modifiers}, each colour reset, {indexed, I} or {rgb, R, G, B} and the
modifiers the cell protocol’s bits; a Rect is {X, Y, W, H}.
- A control character is refused, not drawn.
putandfillraisebadargon one (the ASCII and 8-bit controls, DEL, the bidirectional embedding, override and isolate controls and the invisible format characters, a zero-width joiner inside an emoji sequence apart: thecellscrate’s own rule,userland/native/cells/forbidden.txt) and write nothing of that call, so a caller that forgot to make text visible fails loudly, and nothing reaches the encoder that the cell protocol would not carry. - Bounded. A buffer is at most 1024 cells on a side and 65,536 cells in all (larger is
system_limit);putreads no more of its graphemes than the row has cells; no native does more than one pass over a buffer;framereads a frame in place, allocating only its record of the positions given, a bit a cell of the buffer, made afresh for each frame, and charges by the bytes it read. So every call has a ceiling on its time and its allocation, and it is charged in reductions by the cells it touched. - Counted. A process holds at most four buffers (a fifth is
system_limit; one nothing holds any more is given back), counted in its process dictionary, and each buffer declares the bytes of its two grids and offrame’s record of positions, which count as its holder’s own memory, toward its own heap limit (limits), fromnewand fromresize. So a loop ofnew/2is ended by the limit that ends any runaway allocation. Code that erases the dictionary’s count starts a new one; the heap limit still holds. - One writer. A buffer answers only the Erlang process that made it; any other gets
badarg, but fordraw, which writes no cell. It sits behind a lock because a resource may move between schedulers, and because the shell’s driver draws it: a screen registers its buffer with the driver when it opens, and asks the driver to draw it between whole frames. Holding a buffer’s term gives nothing but that draw, whose bytes go nowhere but to its caller; the driver writes only the bytes it drew itself from the buffer the screen in front registered. - One segmentation and one width table, OTP’s. Text arrives split into graphemes by OTP’s own
segmentation (
String.graphemes/1), and a grapheme’s columns come from a table generated from OTP’sunicode_util:is_wide/1(tools/gen-width.escript, which fails the build’s check when the table is not current): wide if a presentation selector follows its first code point or any of its code points is wide. The shell measures with the sameis_wide/1, so what is measured is what the buffer lays out, at the pinned OTP’s Unicode version. The buffer does not segment text itself, sofilltakes a single code point, and a caller that handsputtwo characters as one grapheme draws them in one cell, misplacing what follows, and nothing more;drawthen places the next cell by the glyph’s width ascolumnsmeasures it, where the shell’sRedoubt.Term.Frame.draw/1sums its graphemes’, the one case where the two draw otherwise.frameputs a symbol of one code point, always one grapheme, itself, and hands back any other unput for the caller to segment. - The diff speaks the cell protocol, and
drawis the shell’s encoder for screens in Rust, over the buffer’s own cells: the bytesRedoubt.Term.Frame.draw/1draws for the diff’s frame, byte for byte, which the shell’s suite holds it to over every code point below U+3000 that a symbol may be, the planes’ edges, hostile text made visible, every attribute and colour form, and frames after partial changes; and this crate’s tests hold it todraw.json, frames thatFrame.draw/1draws, written and kept current by the shell’s suite, with symbols holding control characters forced in, whichdrawmakes visible again asRedoubt.Term.Textdoes (the shell). It writes only the style reset,CSI 2J,CSI row;col Hand SGR from a cell’s numeric colours and attributes. It holds the buffer’s lock across the diff and the advance of what is shown, so a draw beside its owner’s writes on another scheduler sees each call whole and loses no change, and it is charged in reductions by the cells it examined. A new buffer, and one resized, is drawn whole at its firstdraw, as at its firstdiff.
Asynchronous underneath, synchronous on top
Status: built · tested (12)
- host:beamlet-redoubt::the_console_is_one_hub_connection_with_one_waiter
- host:beamlet-redoubt::sixteen_sends_are_taken_at_once_and_arrive_at_one_poll
- host:beamlet-redoubt::a_send_to_a_served_endpoint_wakes_an_idle_vm
- host:beamlet-redoubt::a_full_forwards_queue_holds_the_next_send_until_the_vms_poll
- host:beamlet-redoubt::a_read_waits_for_typing_without_holding_the_vm
- host:beamlet-redoubt::a_vm_busy_past_the_session_bound_keeps_its_console
- host:beamlet-redoubt::two_askers_wait_at_once_and_each_gets_its_own_answer
- host:beamlet-vm::a_completion_reaches_the_process_that_asked_and_no_other
- host:beamlet-vm::a_message_does_not_end_a_wait_for_a_file
- host:beamlet-vm::a_process_killed_while_it_waits_has_its_operation_dropped
- bench:beamlet-console
- bench:beamlet-files
The VM is one trust domain on a few scheduler threads, so a blocking call would stop every Erlang
process on that thread. So the VM’s I/O is asynchronous underneath: a request is submitted and
the VM keeps running, its answer comes back later to whoever asked, and Platform::idle returns
on a timer deadline or an answer. The kernel has no queued sends (a message occupies its sender’s
thread until taken: IPC), so on Redoubt the asynchrony is the client
library’s hub (native programs) over the servers’ multiplexed
connections (the serving library),
in beamlet-redoubt (userland/otp/redoubt/src/io.rs):
- One hub per VM, owned by the platform and run by whichever scheduler holds it. A request is sent from the scheduler’s own thread, waiting at most 1 ms for its server to take it, and its buffer is the hub’s until its answer.
- One waiter thread per connection, started the first time the VM uses the connection, blocked in its completion call; it hands each batch of answers to the VM’s own endpoint with one send.
idleis areceiveon that endpoint until the next timer deadline, and the platform hands each answer to whoever asked: the console’s to its reader, a file’s to the Erlang process whose call it was. An answer, request, input or the input’s end that arrived before the VM idled returns the idle at once: thereceiveis entered only with nothing to hand over, so nothing taken waits for the next wake-up to be looked at.
Above that, Elixir is ordinary synchronous code: File.read/1 blocks the calling Erlang process,
not the scheduler. OTP’s prim_file runs unchanged: a file native whose operation the platform
has begun answers it later (Files names the asking process), the process waits, and only that
operation’s end (or an exit signal) wakes it, not a message; the native is then called again and
takes the result. Several requests of one operation (a walk, an open, the reads up to a count) go
out as each answer comes. A process that dies while it waits has its operation dropped with what
it holds, and an operation that opened a file closes it. The VM’s own code loading reads files
waiting in place. Concurrency is bounded by each server’s shares of requests and pages per
connection (R77 (multiplexed requests)); over
them, a request is answered busy and asked again RETRY_US (10 ms) later, a console write and
a file operation’s request alike; the answers are counted, and said with the I/O report.
Threads. A process has at most 255 threads (processes). The VM’s are
its schedulers (schedulers=N, two in a session) and its waiters, one per connection it uses, at most 6:
a session’s bindings (bootfsd, the home volume, a labelled volume, ipd, the console, the system
volume), a bind being one of them again. A server that ends takes its session with it: its waiter
hands the end over and returns, closing the handle it woke the VM through, and the connection
gives its place up when the next one needs it. A session’s piped is a new server at every
pipeline, so a session runs any number of pipelines, each taking one place while it runs
(servers_bound_and_ended_one_after_another_never_run_out_of_waiters; pipe-eight, eight in one
session). An ended server’s waiter hands its end over only once its completion call has failed
with the server’s death and the VM has received the hand-over. The VM waits for it when it
forgets the connection, as the server’s budget is destroyed, up to HAND_US (1 s); an operation
that finds every place held parks, off the VM’s thread, until an ended server’s waiter hands one
over, or fails emfile at FILE_WAIT_US (10 s), the VM’s own bound and not a device’s error
(a_fresh_connection_at_the_bound_waits_for_an_ended_waiters_hand_over: the connect returns
Later at once and the operation completes when the hand-over comes;
a_fresh_connection_past_the_bound_is_refused_emfile_and_the_live_ones_still_serve: every place
held by a live server, the live ones still served). Once, before the VM waited for a hand-over at
all, six ended pipeds whose waiters had not yet handed over refused a session’s seventh pipeline
its first request as eio, and once a wait of 1 s on the VM’s thread was outlasted by a hand-over
under load and refused a fourteenth (pipe-sixty-four on rv32 beside other work): the wait is
now the operation’s, not the VM’s, and ten times longer. No call holds a thread while it waits: a console read,
a file read and a read on a /net connection’s data file are each one request on the hub, so
what a person or a peer takes to answer costs nothing but the request. Every 9P server in the
image serves multiplexed sessions, so no call needs a thread of its own. What the scheduler’s
thread still waits for itself: typed calls, which no hub carries (the console’s size and
littlefsd’s rename), and the system volume’s module lookups, since loading code is
synchronous in the VM; a server that stops answering one of them stops the VM. A scheduler away
from its endpoint that long, past a server’s session bound, keeps its sessions: each waiter holds
what arrives meanwhile and keeps calling (a busy caller). The
serving natives and a job’s exit notice are launching’s (planned).
The forwards queue
A served endpoint’s thread never waits on the VM. Each call or send it takes is queued, with a
copy of its lent bytes, in the VM’s forwards queue (serve.rs, Forwards), and the thread is
back at its receive at once; the VM takes the whole queue at its next poll, the poll that takes
the hub’s completions, each entry reaching its serving process as a request. A VM in its idle
receive is woken by one send through the thread’s badge, carrying nothing: the VM says it is
about to idle under the queue’s own lock, before its last look at the queue, so a thread that
queues after that look sees the VM idle and wakes it, and one that queued before is found by the
look; the wake waits at most WAKE_US (1 ms) at a time and only while the VM still says it
idles, so a VM that woke on its own meanwhile is left to its poll. The queue is bounded:
MAX_FORWARDS = MAX_SERVED (2) x (the calls one endpoint’s admission parks, 8 buckets of 4 =
32, each forwarded once, + SENDS = 32 one-way sends) = 128. The sends are what the steward can
send a session before its VM polls: one lease_ended per live lease of the sponsor’s (sixteen
under the latency manifest), one server_restarted per slot of its binding table,
approval_waiting, with room over it (the steward). A thread
that finds the queue full waits for the VM’s next poll before it takes its endpoint’s next
message, as every forward once did
(a_full_forwards_queue_holds_the_next_send_until_the_vms_poll): the residual, and the one way a
sender’s bounded send to a served endpoint still expires with the serve thread alive.
The queue’s worst hold is 128 entries of a lend’s MAX_BYTES each, 65,280 bytes, so 2,040 pages
of the session VM’s heap (a cap of 11,220 pages holding the scanned peak twice, no forward in
that scan), copied as the thread takes the message; a copy the heap refuses refuses the
forward, as the lend’s own buffer once did, never a failed allocation in the VM.
Once the thread forwarded each message by a send to the VM’s endpoint that waited for the VM
to take it, and a VM busy in Erlang took one per scheduler turn (its zero-wait poll each 2,000
reductions), so the thread could not take its endpoint’s next message until then: a burst of
the steward’s lease_ended sends to one session was taken a turn each, 40 to 300 ms apart, the
last of six returning 1.5 to 1.8 s after the deadline and of sixteen 2.1 to 2.8 s, and one send
was lost at the steward’s 100 ms bound on rv64 at one hart. With the queue each send returns as
soon as the thread takes it: when the queue landed, 37 to 68 ms after the previous at one hart
on rv64, 42 to 86 on rv32 and 73 to 131 at two harts, the steward’s own work per exit between
them, the last of six 1.1 to 1.3 s after its deadline and of sixteen 1.8 to 2.5 s, none lost
over five boots; since the steward sends a burst’s notices before any exit’s effects the
figures are its page’s (steward.md, “Failure and restart”)
(steward-deadline-burst, steward-deadline-burst-16). On a block trace of the six-lease burst
the thread’s one wake to the idle VM is taken in the thread’s own kernel entry and no send of
the VM’s process blocks on its wake endpoint for the notices after it, where before each forward
blocked there 107 ms median until the VM’s next turn. A reply to an idle VM is unchanged (a file
read from an idle shell 1.0 to 1.3 s on the checked, traced kernel under icount, as before;
beside a spinning process 1.4 to 1.6 s, the read’s own VM work at half the reductions). The VM’s
poll cadence is unchanged: a busy VM still takes the queue once a turn, which under the checked
kernel’s audits (about 60 % of a hart in the burst) is 100 to 140 ms at a session’s quarter
share, a bench condition (a release kernel has no audits); a waiter’s hand-over of a completion
to a busy VM still waits for that turn, one send a batch, 10 to 135 ms on the same trace. What
the sponsor sees is the shell’s print of each notice, which its listener makes inline through
the relay at 250 to 370 ms each, so the last [lease ended] line of six is drawn 2.3 to 2.6 s
after the deadline and of sixteen 6.1 to 6.6 s: the shell’s, not the hub’s
(the steward).
Residual: each connection costs its waiter thread, since a thread waits on one endpoint at a time; one thread waiting on several at once would be kernel work (IPC).
Why
An interpreter, not a JIT or a port of BEAM. BEAM is C and a JIT emits machine code, and Redoubt has neither C nor writable-and-executable pages anywhere (R11 (memory)). An interpreter in safe Rust is slower (up to about five times, measured on the host) and keeps the whole VM inside the language’s memory-safety argument. Speed is not a goal; auditability is.
One boundary. Every service the VM can reach is a method of Platform, with a default of
nothing. A reader checks what a VM can do by reading one trait and one embedder, and an embedder
that grants less gets a VM that can do less, with no code in between to trust.
One pinned compiler. Accepting exactly one OTP version lets the loader refuse everything else instead of carrying compatibility code for old formats, which is where parsers go wrong.
Natives are few, and of three kinds. A native is code every Erlang process in the session can reach, the ones handling hostile data among them, and it is not preempted. So what may be one is closed: OTP’s own natives, which the differential test holds to BEAM; the system’s, a fixed set with Elixir above it; and a primitive an interpreter cannot do fast enough, kept to the primitive. Nothing else is.
Its dependencies are userland’s. beamlet’s own crates hold no unsafe; the crates it links
(RustCrypto, regex-automata, miniz_oxide, num-bigint) do, for speed and for the platform.
That is the latitude an application has and privileged code does not
(the tenets): the VM is
per-principal code, and a bug in it reaches that principal’s own capabilities, which the kernel
contains (trust tiers). What is shared with the servers is
built from the same vendored bytes, and the rest is vendored too, one version of each
(vendored dependencies); only what builds or tests on
the host comes from crates.io, pinned by the lockfile.
One 9P client, not a method per service. Files, TCP and the console are all 9P on Redoubt, so one generic client in Rust covers them, and the framing (packet modes, line mode) stays in Erlang where OTP already has it. Adding a service means serving a tree, not growing the trait.