Shell support matrix#
The simulated shell in crates/computer is a bounded reimplementation, not a POSIX
shell. This page is the published surface: if a flag is not listed here it is not
supported, and invoking it fails with a non-zero status rather than being ignored.
crates/computer/tests/shell_conformance.rs runs every row below, so the table cannot
drift away from the code.
Two words are used throughout:
- modelled — real semantics computed from simulation state (the VFS, the process table, the environment, the tick). The answer changes when the world changes.
- fixed — a plausible constant read from
Computer::hardware. The world does not simulate the underlying fact (cores, RAM, disk capacity, a NIC), so the value is stable per computer, identical on every call and every replay, and never sampled from the host. Change it by editingComputer::hardwarebefore the episode runs.
Exit codes#
Every command returns a truthful status in CommandResult::exit_code. Classify by the
code; do not regex-match stderr.
| Code | Meaning |
|---|---|
0 | Success. |
1 | A modelled negative or an operational error: no match, false test, missing file, permission denied. |
2 | Outside the simulated surface, or malformed: an unsupported flag, an unsupported find predicate, a sed script this world does not implement, a shell syntax error. |
126 | The command exists but cannot be executed (not executable, unsupported interpreter, package stub with no implementation). |
127 | No such command. |
This vocabulary is deliberately narrower than GNU coreutils, which spends 2 on
per-tool operational errors. Here 2 always means you asked for something this world
does not implement — the one distinction a porting consumer actually needs.
The error contract#
Every diagnostic this shell prints follows one shape, and every command that prints one exits non-zero. The wording is GNU coreutils' own, because that is what scripts and people match against:
| Shape | Example |
|---|---|
cmd: subject: reason | grep: nope: No such file or directory |
cmd: action 'operand': reason | rm: cannot remove 'x': Is a directory |
cmd: sentence | cp: -r not specified; omitting directory 'x' |
cmd: invalid option -- 'x' + Usage: … | a short flag the command does not have |
cmd: unrecognized option '--x' + Usage: … | a long flag the command does not have |
cmd: option requires an argument -- 'x' + Usage: … | a flag whose operand is missing |
The reason half is strerror's wording, mapped from the VFS's own errors:
| VFS error | Reason |
|---|---|
| not found | No such file or directory |
| already exists | File exists |
| not a directory | Not a directory |
| is a directory | Is a directory |
| directory not empty | Directory not empty (Is a directory from rm without -r) |
| permission denied | Permission denied |
| symlink loop | Too many levels of symbolic links |
| invalid path | Invalid argument |
An unknown or unimplemented flag is always status 2 and always names itself.
crates/computer/tests/shell_conformance.rs runs every command in the table below with
an invented long flag and an invented short flag and asserts both are refused by name;
a flag that is accepted and then ignored is the one bug this suite exists to prevent.
Where a flag is accepted but does nothing, the table says so explicitly and gives the
reason — xargs -P (commands run sequentially so a replay is identical), ls -1
(output is already one entry per line), md5sum -b/-t (the VFS has no text/binary
distinction), tee -i (this world delivers no signals). 127 is reserved for a
command that does not exist; nothing else uses it.
Nested shells (sh -c, bash script, executable scripts) and git propagate their
inner status rather than collapsing it to 1, and a nested shell's stderr reaches the
caller even when it exits 0, so a script never loses what it printed. CommandResult is serialised whole by
terminal.v1 execute, so the code reaches API consumers. The desktop terminal window
renders only stdout + stderr; GUI-driven agents read the code from the machine's
terminal state value instead.
Grammar#
| Construct | Status |
|---|---|
; \n && || | | modelled |
> >> < | modelled |
2> 2>> 1> 1>> | modelled |
2>&1 1>&2 >&2 | modelled; descriptors are resolved left to right, so >f 2>&1 and 2>&1 >f differ as in bash |
&> &>> | modelled |
/dev/null as a redirect target | modelled: bytes are discarded and no file is created |
<<HEREDOC, <<-, quoted delimiters | modelled |
'single' "double" \escape, backticks | modelled |
$VAR ${VAR} ${VAR:-default} $? $0..$n $# ${#} ${#VAR} $@ $* | modelled |
$(command) `command` $((arithmetic)) | modelled, nesting bounded at 32 |
* ? [...] globbing | modelled, one path component at a time; [abc], [a-z], [!abc]/[^abc]; no **. A no-match word stays literal, as bash does without nullglob, and expansion happens before the argument list is built, so every command that takes a path sees it |
{a,b} {1..9} {1..9..2} {a..e} brace expansion | modelled, nested and repeated, before globbing. A brace with no comma and no range is left alone, so {} survives for find -exec |
NAME=value cmd prefix assignments | modelled |
trailing & | only sleep N & truly backgrounds; elsewhere & acts as a separator |
if … then … elif … else … fi | modelled; the condition list's last status decides |
for NAME in WORDS; do … done, for NAME; do … done | modelled; the second form walks $1…$# |
while / until … do … done | modelled |
case WORD in PAT|PAT) … ;; esac | modelled; a leading ( is accepted and patterns use the glob matcher, so *, ? and [a-z] all work |
NAME() { … }, function NAME { … } | modelled; the body's output is the call's output, so a function pipes |
( … ) subshell | modelled; environment, working directory and function table are restored afterwards |
{ … ; } group | modelled; runs in the caller's scope |
break [N] continue [N] return [N] | modelled; outside a loop or a function they are refused with status 2, never a silent no-op |
( ) unquoted | metacharacters, as POSIX defines them: quote them to use them in a word |
[[ … ]] | modelled, including && || ! and parentheses inside it, ==/!= pattern matching, =~, and </> string order |
redirection on a compound (done < f, done > f, done 2>&1) | modelled; < f becomes the shared input stream the body reads |
a pipe through a compound (cat f | while … done, for … done | wc -l) | modelled |
Word splitting#
An unquoted expansion is split on whitespace and a quoted one is not, as in bash, so
X='a b'; cmd $X passes two arguments and cmd "$X" passes one. An unquoted expansion
that comes out empty contributes no argument at all, which is the difference
between cmd $EMPTY and cmd "$EMPTY". A * that arrives from a variable stays
literal; only a * written in the source globs, so data never becomes a pattern.
Standard input inside a compound#
< f on a compound, and a pipe into one, give its body a single shared stream. read
takes one line from it and leaves the rest — that is what makes while read line; do …;
done < f advance and then stop. Any other command is handed the stream only if it
would actually read it (cat, tr, tee, xargs, and grep/sed/awk/cut/
head/tail/wc/sort/uniq/nl/rev/fold/expand/unexpand/paste/shuf/
split/strings/base64/md5sum/sha1sum/sha256sum/xxd/od/hexdump when no
operand names an existing file), and it consumes the whole of it. A loop
body that ignores stdin therefore cannot swallow the lines the loop is reading.
Limits#
A deterministic simulator must never hang, so every bound is a status, not a wait.
All four are per execute call and are refused with status 2:
| Limit | Value | Message |
|---|---|---|
| Source length | 64 KiB | command exceeds 64 KiB limit |
Nesting depth ($( ), sh -c, scripts, source, subshells, function calls) | 32 | execution nesting exceeds 32 |
| Simple commands run | 10 000 | command budget exhausted after 10000 commands |
| Loop iterations, summed over every loop | 10 000 | loops exceeded 10000 total iterations |
The step budget usually trips first, because every iteration of a loop runs at least
one command. A nested shell (sh -c, a script, a command substitution) starts a fresh
budget, so the worst case is bounded by depth × budget — finite, and identical on
every replay.
Commands#
| Command | Supported | Unsupported | Behaviour |
|---|---|---|---|
true / : / false | — | — | modelled; : is the null command, so a loop body can do nothing |
echo | -n | — | modelled |
printf | the whole conversion set — %s %b %c %d %i %o %u %x %X %e %E %f %F %g %G %%, the flags - + space 0 #, width and precision including *; escapes \n \t \r \a \b \f \v \e \\ \NNN \0NNN \xHH \c | %q, --help/--version (the first operand is always the format, as POSIX requires) | modelled; the format is reused until the operands run out, so printf '%s\n' a b c prints three lines |
pwd | — | all flags | modelled |
whoami | — | all flags | modelled |
hostname | — | all flags | modelled (the computer id) |
uname | — | -a -r -m and the rest | modelled (the OS family only) |
date | +FORMAT, -u; %Y %y %m %d %e %H %M %S %N %s %F %T %D %a %A %b %B %h %u %w %Z %z %% | -d -r -s, any other conversion | modelled from the tick; see Clock below |
cd | — | all flags | modelled |
env / printenv | NAME | all flags, and env NAME=V CMD (each would need a model this world does not have) | modelled |
export / unset | NAME=VALUE / NAME | all flags, including export -f | modelled |
ls / dir | -a -A -l -h -d -F -p -1 -i -n -r -t -S -R, --color=never|no|none|auto, --json, clusters (-la), --all --almost-all --human-readable --reverse --recursive --directory --classify --inode --numeric-uid-gid | -Q -c -u --color=always, column output, and any short spelling of --color/--json | modelled; output is always one entry per line (-1 is the default), -l prints mode, link count, owner, group, size, world-clock date and -> target, and directories report one 4 KiB allocation unit as their size. --json is documented under ls --json below |
cat | -n -b -E -T -A -s -v, --number --number-nonblank --show-ends --show-tabs --show-all --squeeze-blank --show-nonprinting; -u accepted and inert (output is never buffered) | every other flag, refused by name | modelled; no operand reads stdin, - names stdin |
touch | -a -m (separate fields), -c / --no-create, -d DATE / --date, -t STAMP, -r FILE / --reference, -h / --no-dereference | --time=, relative dates (yesterday), timestamps before the epoch | modelled; creates missing files and sets the access and modification ticks. -d takes @SECONDS or YYYY-MM-DD[ HH:MM[:SS]], -t takes [[CC]YY]MMDDhhmm[.ss] |
mkdir | -p / --parents, -m MODE / --mode (octal or symbolic), -v / --verbose | -Z | modelled; without -p an existing target or a missing parent is an error, and -m overrides the umask |
cp | -r -R -a -p -d -L -P -i -n -f -v -t DIR -T, long forms | -u -l -s --preserve=LIST --parents | modelled; copying into an existing directory keeps the name, a directory without -r is refused with -r not specified; omitting directory 'X', -p carries mode and timestamps (ownership only for root), -a is -dR -p, and a new copy without -p takes the source's permissions through the umask, losing its setuid/setgid bits as coreutils does |
mv | -i -n -f -v -t DIR -T, long forms | -u -b -S | modelled; into a directory the name is kept, a file cannot overwrite a directory (or the reverse), and a non-empty directory cannot be overwritten |
rm | -r / -R / --recursive, -f / --force, -d / --dir, -i / --interactive, -v / --verbose, PowerShell -Recurse -Force | -I, --one-file-system | modelled; a directory without -r or -d is refused with cannot remove 'X': Is a directory, a non-empty directory with -d with Directory not empty, and a missing operand is an error unless -f |
rmdir | -p / --parents, -v / --verbose, --ignore-fail-on-non-empty | — | modelled; empty directories only. -p walks up the components the operand names and stops, loudly, at the first non-empty parent |
chmod | octal mode, symbolic modes (u+x, go-w, a=r, +X, u+s, +t, comma lists), -R / --recursive, -v / --verbose | --reference, copying permissions (u=g) | modelled and enforced: a mode a read cannot satisfy makes the read fail. X reads the mode as the clauses before it left it |
ln | -s -f -n -v -r -T -P -t DIR, long forms | -b -S -i | modelled; hard links share an inode and a link count, -s stores the text given, -r stores it relative to the link's own directory, and into a directory the target's name is kept |
chown / chgrp | OWNER, OWNER:GROUP, OWNER:, :GROUP, -R / --recursive, -v, -h / --no-dereference | -c --reference, --from, numeric ids | modelled; only root hands a node to another user, and the owner may set its group |
umask | [-S] [-p] [MASK], octal or symbolic | — | modelled; one mask per filesystem, read by every file and directory a command creates |
truncate | -s SIZE (N, +N, -N, <N, >N, /N, %N, with K/M/G), -r FILE, -c / --no-create | -o (block units) | modelled; growing pads with zero bytes |
install | -m MODE, -d / --directory, -D, -p, -v, -t DIR, -T | -o -g -s -C --backup | modelled; the ownership flags are refused rather than half-honoured |
readlink / realpath | -f -e -m -s/-q, long forms | --relative-to, -z | modelled; readlink prints the stored link text, the canonicalising forms resolve every link on the path |
trash / trash-put / trash-list / trash-restore / trash-empty / gio trash | see The trash below | trash-rm, an age operand on trash-empty | modelled against ~/.local/share/Trash |
stat | -c FMT / --format= / --printf=, -L, -t, -f (with its own %n %i %l %T %t %s %S %b %f %a %c %d); %n %N %s %b %B %o %f %a %A %F %U %G %u %g %i %h %m %d %t %T %W %X %Y %Z %w %x %y %z %% | --cached, any other conversion | modelled; see Stat fidelity below |
find | see find below | every other predicate, refused by name | modelled |
grep / select-string | -i -v -n -c -l -L -F -E -G -q -s -h -H -w -x -r -R -e -o -A N -B N -C N, long forms | --include -P -m | modelled; 0 matched, 1 did not. A pattern is a basic regular expression unless -E says otherwise, so a\+ repeats and a+ is a literal plus; the last of -E/-G wins. Context lines are prefixed with - where a matching line uses :, and -- separates non-adjacent groups |
sed | -n -e -f -i[SUFFIX] -E -r -s --quiet --silent --expression --file --in-place --regexp-extended --separate; addresses N, $, /RE/, \cREc, N,M, first~step, addr,+N, addr,~N, 0,/RE/, the I/M regex modifiers and !; commands { } s y p P d D a i c r R w W n N h H g G x b t T :label q Q = l z F #; s flags g N p i/I m/M w FILE, &, \1–\9, \U \L \u \l \E | -z, the e substitution flag, a backreference inside a pattern (\(a\)\1 — the engine has no backtracking) | modelled; see sed below |
awk / gawk / mawk / nawk | the POSIX language: patterns /re/, expressions, ranges, BEGIN/END; $0–$NF with assignment rebuilding $0; NR NF FS OFS ORS RS FILENAME FNR SUBSEP RSTART RLENGTH CONVFMT OFMT ENVIRON; -F -v -f (repeatable), --field-separator --assign --file --source; if/else while for for-in do-while break continue next nextfile exit return delete; arrays incl. multidimensional; user functions with local parameters and array parameters by reference; length substr index split sub gsub match sprintf toupper tolower sin cos atan2 exp log sqrt int rand srand system close fflush; print/printf with > >> | "cmd"; every getline form | -W, gawk extensions (gensub, asort, PROCINFO, RT, BEGINFILE), |& co-processes | modelled; see awk below |
xargs | -0 -d DELIM -n N -I REPL -r -t -P N --, --null --delimiter --max-args --replace --no-run-if-empty --verbose --max-procs; quoting ('…', "…", \) | -L -a -s -E -p | modelled; -P is accepted and commands still run one after another, because a replay must be identical. Status 123 if any command failed, 124 for a command that exited 255, 126/127 passed through |
tr | SET1 [SET2], ranges a-z, [:alpha:] and the other classes, [c*n], [c*], escapes; -d -s -c -t, --delete --squeeze-repeats --complement --truncate-set1 | operands beyond two | modelled, stdin only |
cut | -b -c -f -d -s --complement --output-delimiter, ranges N, N-, -M, N-M and lists, on file operands as well as stdin | -z, --characters on multibyte boundaries other than char | modelled; a line with no delimiter passes through unless -s |
head / tail | -n N, -n +N, -n -N, -N (bare count), -c N, -q -v, --lines --bytes --quiet --silent --verbose; several file operands with ==> name <== headers | -z; tail -f refused by name | modelled; head -n -N drops the last N lines, tail -n +N starts at line N |
wc | -l -w -c -m -L, --lines --words --bytes --chars --max-line-length, several operands with a total row | -z | modelled; the file name is printed beside the counts whenever an operand names one, as in coreutils |
sort | -n -g -h -r -u -f -b -M -V -c -s -d -i -k KEYDEF -t SEP -o FILE, long forms; KEYDEF is F[.C][opts][,F[.C][opts]] with per-key n g h M V f b d i r | -z, -m, -S, --parallel, locale collation (the order is C/byte order) | modelled; without -t a field carries the blanks before it, which is what makes -k2 and -k2b differ. -c reports sort: FILE:N: disorder: LINE and exits 1 |
uniq | -c -d -D -u -i -f N -s N -w N, long forms | -z, --group, --all-repeated=METHOD | modelled over adjacent lines only, as coreutils does |
paste | -s, -d LIST | -z | modelled; the delimiter list cycles |
join | -1 -2 -j -t -a N -v N -o LIST -e TEXT -i --check-order --nocheck-order | --header, -z | modelled; --nocheck-order names the default, --check-order really checks and fails with status 1 |
comm | -1 -2 -3, --output-delimiter | --total, -z | modelled; both inputs are assumed sorted, exactly as coreutils assumes |
diff | -u/-U N, -c, -q, -r, -s, -i, -w, -b, -B, -N, long forms | -y, --label, -D, binary comparison | modelled with Myers' algorithm; 0 identical, 1 differ, 2 an error such as a missing operand |
tee | -a / --append; -i accepted and inert because this world delivers no signals | -p, --output-error | modelled; /dev/null is discarded |
nl | -b a|t|n|pRE, -n ln|rn|rz, -w N, -s STR, -v N, long forms | -p, page sections (\:\:\:), -d, -l, -f, -h | modelled |
rev | — | all flags | modelled |
fold | -w N, -N, -s, -b, long forms | — | modelled |
expand / unexpand | -t LIST / --tabs, expand -i, unexpand -a | -t with a multibyte tab character | modelled; each command refuses the other's flag |
shuf | -n N, -e, -i LO-HI, -r, long forms | -z, --random-source | modelled; seeded from the tick and the machine id, so a replay prints the same permutation |
seq | [FIRST [INCR]] LAST, -s SEP, -w, -f FORMAT, long forms | more than 1 000 000 values, refused | modelled |
yes | [STRING…] | short flags are operands, as in coreutils; a long flag is refused | modelled with a published bound: 10 000 lines, then it stops, because this world's pipelines are not lazy and a truly endless yes could never return. Use seq/head for an exact count |
basename | NAME [SUFFIX], -a, -s SUFFIX, long forms | -z | modelled |
dirname | NAME… | -z | modelled |
split | -l N, -b SIZE (with b K M G), -a N, -d, long forms | -n CHUNKS, -C, --filter, --additional-suffix | modelled; the default prefix is x and the default is 1000 lines |
strings | -n N; -a accepted and inert because every file here is scanned whole | -t, -e, -f | modelled over the stored bytes |
base64 | -d, -i, -w N, long forms | --base64url | modelled; wraps at 76 columns, -w0 never wraps |
md5sum / sha1sum / sha256sum | -c; -b and -t accepted and inert because the VFS has no text/binary distinction | --tag, --quiet, --status, --ignore-missing | modelled; the digests are the real ones, computed from the stored bytes |
cmp | -s / --silent / --quiet, -l / --verbose | -i, -n, --bytes | modelled; the differ: line goes to stdout, as in coreutils, and the status is 1 |
xxd | default, -p, -c N, -l N, -s N, -g N, -u, -r (with and without -p), long forms | -i, -b, -e, -s with +/- | modelled |
od | -c -b -x -d -o, -A d|o|x|n, -t c|a|x1|o1|d1|o2, -N N, -j N, -v | every other -t format, refused by name | modelled; repeated lines collapse to * unless -v |
hexdump | default (two-byte octal), -C -c -b -x -d -o, -n N, -s N, -v | -e format strings | modelled |
file | -b, -i / --mime, -L; -h accepted and inert because not dereferencing is the default | -z, -f, --magic-file | modelled; see file below |
du | -s -a -h -k -b -m -c -d N, --max-depth=, --summarize --all --human-readable --bytes --total | --exclude, -x, -L | modelled over the VFS; block accounting assumes a 4 KiB allocation unit |
tar | -c -x -t, -f FILE (required), -v, -z, -C DIR, --strip-components=N, --; long forms --create --extract --get --list --file= --verbose --gzip --directory= --strip-components= | -f - (a pipe archive), -j -J --exclude -u -r -A | modelled; real ustar bytes, so an archive written here unpacks with host tar and a host archive unpacks here. Regular files, directories and symlinks round-trip with their modes and modification times |
gzip / gunzip / zcat | FILE…, -d (--decompress), -c with -d (text to stdout), -k -f -v -l -t -q, -1…-9 (--fast, --best) | compressing to standard output or from standard input (the shell's pipes carry text, not bytes) | modelled with cw-zlib: files compressed in place to NAME.gz with GNU gzip's header (original name, modification time, OS 3), concatenated members decompressed; the deflate stream is zlib's at the chosen level (GNU gzip's own deflate is not reproduced byte for byte) |
zip / unzip | zip [-r] [-q] ARCHIVE FILE…; unzip [-l] [-o] [-q] [-d DIR] ARCHIVE [FILE…] | encryption, -u -m -9, split archives | modelled; real PKZIP local headers, central directory and end record, CRC32 and a DOS date-time from the world clock. Written with method 0 (stored); read with method 0 or 8 |
rsync | -a -v -n / --dry-run, --delete, long forms | every remote spec (host:path, user@host:path, rsync://), -z -u --exclude -r without -a | modelled for local trees only, including the trailing-slash rule; a remote spec is refused by name rather than faked |
df | -h -k -T, --human-readable --print-type | -i -a -B | mixed: capacity and device name are fixed, usage is summed from the VFS; one filesystem mounted at / |
which | -a, --all | -s | modelled over PATH; see which and builtins below |
nproc | --all | --ignore | fixed (hardware.cpus, default 4) |
uptime | -p -s, --pretty --since | -h -V | modelled from the tick and hardware.boot_tick; the load average and user count are fixed at 0.00 and 1 |
clear / cls | — | all arguments, refused by name | modelled as a screen action: no output, CommandResult::clear is set |
ip | addr | a | address, link | l, route | r, optional show/list | -4 -6 -o -brief (the printout is a fixed block and cannot be filtered), every other object (netns, tuntap, rule, …) and every mutating action | fixed (hardware.ipv4, prefix, mac, gateway, interface) |
sudo | -u USER, --, -v -k -K (succeed and do nothing); -n -E accepted and inert | password prompts, a sudoers policy, -i/-s login shells | modelled thinly: it swaps only the identity access checks use (default root, which bypasses VFS permissions). HOME and USER are left alone |
test / [ | -e -f -d -s -r -w -x -L -h -n -z, = == != -eq -ne -lt -gt -le -ge -nt -ot | -a/-o, -p -S -g -u -k | modelled against the VFS and its permissions |
[[ … ]] | everything test takes, plus && || ! and ( … ) inside the brackets, ==/!= glob matching (an unquoted right side is a pattern, a quoted one a literal), =~ regex, < > string order | &&-chaining onto other commands inside the brackets, -v, -o | modelled; a missing ]] is a syntax error |
read | -r (accepted; this shell never unescapes a read line), any number of names, the last taking the remainder; no name sets REPLY | -p -t -n -d -s -u -a and every long option, refused by name | modelled; fields split on whitespace. Status 1 at end of input, and also when the last line had no terminating newline — the same end-of-file report bash gives |
exit | [N] | — | modelled; ends this shell (or this sh -c, script or subshell) with N, or with the last status. It does not end the caller's shell |
shift | [N] | — | modelled; status 1 when N exceeds $#, which changes nothing |
local | NAME[=VALUE]… | -r -i -a | modelled; shadows the name until the enclosing function returns. Outside a function it is refused with status 2 |
source / . | FILE [ARG…] | — | modelled; runs the file in this shell, so its variables, working directory and functions persist. return ends it, exit ends the whole shell, and it counts against the nesting limit |
getopts | OPTSTRING NAME [ARG…], clusters (-ab), glued and separate option arguments, a leading : for silent mode | --long options | modelled; OPTIND and OPTARG are ordinary shell variables, so resetting OPTIND=1 restarts the scan |
ps / Get-Process | aux, -e / -A, -f, -u USER, -p PID, -o COLUMNS, --sort=[+-]COLUMN, --json | -l --forest, -o rss=LABEL header renaming, every other BSD operand | modelled; column output by default, from the process table. See Process table below for the column schema. With no selector ps lists the current user's processes. --json dumps the whole table as JSON |
top | -b -n1, -o COLUMN | interactive mode, -n other than 1, every other option | modelled as one batch snapshot of the same table ps reads. PR, NI and the %Cpu(s) line are fixed (20, 0 and idle): no scheduler is simulated |
pgrep / pkill | -f -l -x -n, -u USER, pkill -SIGNAL; long forms | regular expressions (the pattern is a plain substring, and a pattern that looks like a regex is refused) | modelled; matches the program name, or the whole command line with -f. Status 1 when nothing matches, as on Linux. pgrep never reports itself |
kill | -SIGNAL / -N, -l, several pids | — | modelled against the process table and its signal dispositions. Killing the process of an open application window closes that window |
free | -b -k -m -g -h | -s -c -w --si | modelled: total is hardware.memory_bytes, used is the sum of the running processes' modelled footprints. Swap, shared and buff/cache are 0 because none is simulated |
lsof | -p PID, -u USER, one path operand; -n accepted and inert | every other option; the DEVICE and SIZE/OFF columns | modelled over the process table's real descriptors and listeners. Status 1 when nothing matches |
apps | --json | — | modelled: the application ids this machine has installed. The desktop's own view, with labels, is application.v1 list |
xdg-open / gio open / open (macOS) / start (PowerShell) | one file, folder or URL | every option; gio takes only open and trash, and every other subcommand is refused by name | modelled: the shell checks that the target exists and that the machine has applications, and hands it to the desktop, which opens the same application a file manager would. Status 2 for a missing target, 3 when nothing can open it |
nice / renice | — | — | refused by name: no scheduler is simulated, so a priority would change nothing |
jobs / bg / fg / disown / wait | — | — | refused by name: job control is not modelled. Every command runs to completion before the next starts, and sleep N & is the only background process — find it with ps -e, end it with kill |
vmstat / iostat / mpstat / sar | — | — | refused by name: no paging, block-device or interrupt counters are simulated. free reports memory and ps/top report the process table |
sleep | fractional seconds, trailing & | — | modelled against simulated time; never blocks the host |
systemctl / service | start stop restart status | every flag (--user, --now, --no-pager … all imply machinery this world lacks), enable disable daemon-reload | modelled against the process table and the service adapter |
apt / apt-get / brew / winget / pip / npm | install, remove/uninstall, list | update upgrade search | modelled against the package manager, offline |
curl / wget | -X / --request, -d / --data / --data-raw, -H / --header, -o / --output, -f / --fail; -s / --silent and -S / --show-error accepted and inert because there is no progress meter and no TTY | -L -I -u -k -A, and every other flag, refused by name | modelled against the network adapter |
git | init, clone, add [-A] PATH…, status, commit -m MSG (-a/-am), log, diff [--staged|--cached], reset [--soft|--mixed|--hard] [REV] [--] [PATH…], restore [--staged] [--worktree] [--source=REV] PATH…, checkout/switch [-b|-c] BRANCH, checkout -- PATH…, branch [NAME], remote [add NAME URL], fetch, pull, push, config KEY [VALUE], -C DIR | every other subcommand and every global option, refused by name with status 2 | modelled, content-addressed (crates/computer/src/git.rs): objects, refs and the index are the repository's own state under .git/state.json, and the worktree is the machine's files. See git below |
sqlite3 | [OPTIONS] [FILE [SQL…]]; SQL and dot-commands on stdin (pipe, heredoc, <); -header -noheader -csv -column -list -line -json -box -table -markdown -tabs -quote -html -ascii -separator SEP -newline SEP -nullvalue TEXT -cmd CMD -init FILE -bail -echo -version -help; -batch -readonly -safe accepted and inert | every other option, refused by name with status 2; an interactive prompt | modelled: the cw-sql engine over the VFS, reading and writing real SQLite 3 files; see sqlite3 below |
sh / bash | -c SCRIPT [NAME [ARG…]], script path plus arguments | -e -x | modelled; a nested run of the same shell, with its own budget and its own function table |
break / continue / return | [N] | — | modelled as shell signals; see Grammar |
python3 / python | FILE [ARG…], -c CODE, -m MODULE, - or no operand (program on stdin), -V / --version, -h; -B -E -I -O -q -s -S -u -v -d -b -i -W ARG -X OPT accepted and inert | pip inside the interpreter, C extensions | modelled by an in-process CPython 3.12 interpreter; see Language runtimes below |
node / nodejs | FILE [ARG…] (.js, .cjs, .mjs), -e / --eval, -p / --print, -c / --check, -r / --require, --input-type=module, --stack-trace-limit=N, - or no operand (program on stdin), -v / --version, -h; V8 and diagnostic flags (--no-warnings, --max-old-space-size=…, --experimental-*, …) accepted and inert | --inspect, --watch, --test, native addons | modelled by an in-process ES2023 interpreter with Node 24.21 semantics; see Language runtimes below |
| PowerShell aliases | Write-Output Get-Location Set-Location Get-ChildItem Get-Content Set-Content Add-Content Copy-Item Move-Item Remove-Item Select-String Get-Process Stop-Process Invoke-WebRequest Test-Path | the rest of PowerShell | modelled; only available when the computer's dialect is powershell |
| anything else | — | — | status 127, command not found |
find#
Every predicate below really filters; an unknown one is refused by name with status
2 rather than ignored, because a search that silently returns the wrong set is worse
than one that says it cannot.
| Group | Supported |
|---|---|
| Global options | -maxdepth N, -mindepth N, -depth / -d, -P |
| Name and path | -name GLOB, -iname GLOB, -path GLOB, -ipath GLOB, -wholename GLOB, -regex RE, -iregex RE |
| Kind | -type f|d|l |
| Size | -size N[c|w|b|k|M|G] with +/-; a bare N is 512-byte blocks rounded up, as GNU counts them |
| Mode | -perm MODE (exactly), -perm -MODE (all of these bits), -perm /MODE (any of these bits); octal or symbolic (u+w,go=r) |
| Time | -mtime -atime -ctime and -mmin -amin -cmin, each with +/-; -newer FILE, -newermt DATE (@SECONDS or YYYY-MM-DD[ hh:mm[:ss]]) |
| Ownership | -user NAME, -group NAME, -nouser, -nogroup |
| Emptiness | -empty |
| Operators | ! / -not, -a / -and (implicit between adjacent predicates), -o / -or, ( ), with GNU's precedence and real short-circuiting |
| Actions | -print (the default), -print0, -printf FORMAT, -ls, -delete (implies -depth), -quit, -prune, -exec CMD ;, -exec CMD {} +, -execdir CMD ;, -execdir CMD {} + |
-printf | %p %f %h %n %s %m %M %u %g %y %i %d %P %l %%, %T@ %A@ %C@, and %TY %Tm %Td %TH %TM %TS (plus the %A…/%C… spellings for the other two stamps); escapes \n \t \0 \\ |
-print is appended only when nothing in the expression already has an effect, so
find . -prune still prints and find . -quit does not.
Refused by name rather than half-done: -L / -H / -follow and -xtype (only -P,
not following links, is honest here), -ok / -okdir (there is no terminal to prompt
at), -type b|c|p|s (this world has no device, FIFO or socket nodes), -perm +MODE
(withdrawn by GNU itself), symbolic X in -perm, relative words in -newermt, and
any -printf conversion or escape not listed above.
Two deliberate differences from GNU: a directory's entries are visited in sorted
order rather than readdir order, because a deterministic simulator must give the same
answer twice; and -exec hands its argument vector to a nested shell run, which starts
a fresh nesting budget, so find refuses an -exec that is already 32 levels deep.
Archives#
tar, gzip and zip write and read real container bytes, verified in both
directions against GNU tar, gzip, unzip and Python's tarfile: an archive made
in the simulation unpacks on a host, and a host archive unpacks in the simulation. That
is why these are commands rather than a convenience format — a world where tar -czf
produced something only this world could read would be a trap.
There is one compressor under all of it, cw-zlib, the same one Python's zlib and
Node's zlib use here: a real DEFLATE encoder at the level asked for, and an inflate
covering stored, fixed-Huffman and dynamic-Huffman blocks. So tar -czf, gzip and a
method-8 .zip member all really compress, and real-world .gz and .zip files
really decompress. GNU gzip's own encoder is not reproduced byte for byte — the bytes
are zlib's — but every reader accepts them.
Anything the format cannot carry faithfully is refused by name: a member whose path does not fit ustar's prefix/name split, a link target over 100 bytes, a hard-link or device typeflag, a zip compression method other than 0 or 8, and any member whose path would escape the extraction directory.
awk#
awk (crates/computer/src/awk.rs) is the POSIX language, not a field-printing
shortcut: a lexer, a recursive-descent parser and an interpreter with the whole value
model. gawk, mawk and nawk are the same command.
Values. A scalar is uninitialised, a number, a string, or a string from input.
The last is the rule that makes real scripts work: a field or a getline result that
reads entirely as a number compares numerically, so $1 == 10 is true for a line
containing 10.0, while "10" == 10 compares the string constant as a string. An
uninitialised value equals both 0 and "". Numbers print as integers when they are
integral and through CONVFMT (OFMT for print) when they are not.
Records and fields. RS is a single character, a multi-character regular
expression, or "" for paragraph mode (a blank line separates records and a newline
always separates fields). FS is a single character taken literally, a regular
expression when longer, " " for the default blank-run split, and "" to split into
characters; -Ft means a tab, as in every awk. Assigning $n past NF pads the
record, assigning NF truncates it, and either rebuilds $0 with OFS.
Determinism. for (k in a) walks the subscripts in sorted order. POSIX leaves
the order unspecified; this world fixes it so a replay is identical. rand() is a
48-bit LCG seeded from the world; srand() with no argument seeds from the simulated
tick, not a host clock, and returns the previous seed.
Streams. print > "file" and print >> "file" buffer and write through the VFS.
print | "cmd" buffers its text and runs cmd when the pipe is closed — by
close("cmd"), fflush(), system(), or the end of the program — and the command's
output is spliced into awk's own at that moment. There is no second process to
schedule, so this is the honest ordering; it makes print | "sort" behave exactly as
expected. "cmd" | getline runs the command once and reads its output as records.
getline < "file" returns 1, 0 at end of file, and -1 when the file cannot be
opened — it never aborts the program.
Function parameters. Parameters beyond the arguments are locals. Whether a
parameter is a scalar or an array is decided from how the function body uses it
(subscripted, walked with for … in, deleted, filled by split, or passed on to
another function's array parameter), computed once as a fixpoint over the whole
program; an array parameter is shared with the caller by reference.
Bounds. A program is stopped with status 2 after 2 000 000 evaluation steps or
256 nested function calls, so a runaway while(1) ends rather than hanging the world.
awk --version and sed --version print … (computerworld) POSIX profile, so a
script that probes for a GNU-only feature by version string gets an honest answer
rather than a number it can compare against.
sed#
sed (crates/computer/src/sed.rs) runs a real cycle: a pattern space, a hold space,
an append queue, branch labels and a program counter. -i and -s process each file
separately; otherwise every operand is one stream, so $ is the last line of the last
file and line numbers run on.
Basic and extended regular expressions really differ. In a BRE, \(…\) groups,
\{n,m\} repeats, \| alternates and \+/\? are GNU's extensions, while the bare
characters are literals; * is a literal at the start of an expression and ^/$
anchor only at the edges. -E (or -r) swaps the two. \< and \> both become a
word boundary, because the engine has no lookaround. A backreference inside a
pattern is refused by name — the engine cannot backtrack — while \1–\9 in a
replacement work, as do &, \&, and GNU's \U \L \u \l \E case operators.
a, i and c take both the POSIX a\ + text form and GNU's one-line a text.
c on a range prints its text once, at the end of the range. w and s///w write
through the VFS (with /dev/stdout writing to standard output), r and R read from
it. q's code becomes the exit status and everything already printed still reaches the
caller; Q quits without the final auto-print.
file#
file reads the stored bytes and reports only what this world can actually produce, so
it never guesses:
| Magic | Reported as |
|---|---|
| empty file | empty |
\x89PNG\r\n\x1a\n | PNG image data, W x H, D-bit/color KIND, non-interlaced, with , APNG appended when an acTL chunk is present |
\xff\xd8\xff | JPEG image data, JFIF standard |
GIF87a / GIF89a | GIF image data |
RIFF….WAVE | RIFF (little-endian) data, WAVE audio |
SQLite format 3\0 | SQLite 3.x database |
%PDF- | PDF document, version N.N |
PK\x03\x04 | Zip archive data, or Microsoft Excel 2007+ / Word / PowerPoint / OpenDocument … when the member names say so |
#! | NAME script, ASCII text executable |
\x7fELF | ELF binary (this world runs no native executables) — nothing here writes one |
| valid UTF-8, no control characters | ASCII text, Unicode text, UTF-8 text, JSON text data or CSV text, with , with no line terminators when the last line is unterminated |
| anything else | data |
A directory is directory and a symlink is symbolic link to TARGET unless -L
follows it. -i maps the same table onto a MIME type.
git#
git is a synthetic, content-addressed Git: commits hold whole file trees, the index is
a real staging area and the worktree is the machine's own files, so status, diff and
a commit all say what the disk says. A revision is HEAD, HEAD~<n> (or HEAD^…), a
branch name, or a commit hash (a unique prefix of at least four characters is enough).
| Command | Effect |
|---|---|
git diff / git diff --staged (--cached) | The worktree against the index, or the index against HEAD |
git reset [REV] | --mixed (the default) moves the branch to REV and resets the index to it, leaving the worktree alone and listing what is now unstaged; --soft moves the branch only; --hard also throws away the working copies |
git reset [REV] [--] PATH… | Copies those paths from REV (default HEAD) into the index, leaving the worktree alone: this is what unstages a file. A --soft or --hard reset with paths is refused, as git refuses it |
git restore PATH… | The worktree comes back from the index (Discard Changes) |
git restore --staged PATH… | The index comes back from HEAD (Unstage); --staged --worktree does both, and --source=REV takes the content from another commit |
git checkout -- PATH… | The older spelling of git restore PATH… |
A folder (or .) as a path stands for every file under it. Visual Studio Code's Source
Control view runs exactly these commands: its + is git add, its − is git restore
--staged, Unstage All Changes is git reset, and Discard Changes is git restore (an
untracked file is moved to the trash instead).
sqlite3#
sqlite3 runs the pure cw-sql engine (crates/sql), which follows SQLite 3.45.1's
dialect, messages and shell output. FILE is resolved against the working directory and
read and written through the same permission checks as cat and a redirect; :memory:
or no file is a scratch database. Every argument after FILE is run in order (SQL or a
dot-command) and the first error ends the run; with no SQL arguments the shell reads its
standard input instead, so echo 'select 1;' | sqlite3 f.db, a heredoc and < script.sql
all work. There is no interactive prompt: each terminal line is one invocation.
| Dot-command | Behaviour |
|---|---|
.tables ?PATTERN? / .indexes ?TABLE? | names in columns, as the real shell lays them out |
.schema ?PATTERN? | stored CREATE text, one statement per line |
.mode MODE ?TABLE? | list csv tabs column table box markdown json line insert quote html ascii; csv ends rows with CRLF and column turns headers on |
.headers on|off, .separator COL ?ROW?, .nullvalue TEXT, .width N… | output settings; .show prints them |
.import ?--csv? ?--skip N? FILE TABLE | a missing table is created from the header row with TEXT columns; short and long rows are filled or trimmed with the shell's warnings |
.dump ?TABLE? / .read FILE | SQL text out and back in |
.open ?--new? FILE, .save FILE, .output ?FILE?, .once FILE | switch databases, copy one, redirect output to a file |
.bail, .echo, .changes, .print, .databases, .help | as in SQLite |
.quit / .exit ?CODE? | stop; the exit status is CODE, or 1 if anything failed |
Errors use the real shell's wording and go to stderr: Error: in prepare, … and
Error: stepping, … (19) for arguments, Parse error near line N: … and
Runtime error near line N: … for scripts, with a caret under syntax errors. The status
is 1 when any statement failed. A database is written back only when its content
changed, as one whole-file VFS write (so no reader ever sees half a save); a file that
was only read is never created, and a transaction still open at exit is rolled back.
'now' and CURRENT_TIMESTAMP read the simulated clock, and random() is a seeded
stream, so every replay agrees.
The engine implements tables with PRIMARY KEY, NOT NULL, UNIQUE, CHECK,
DEFAULT, COLLATE and REFERENCES (enforced with PRAGMA foreign_keys = ON,
including CASCADE, SET NULL and SET DEFAULT), AUTOINCREMENT, WITHOUT ROWID
tables (stored, as SQLite stores them, as an index B-tree in primary key order),
B-tree indexes the planner uses for equality, IN and range lookups and to avoid
sorts (EXPLAIN QUERY PLAN shows the choice, including USING COVERING INDEX when
the index holds every column the query reads and USING PRIMARY KEY for a WITHOUT
ROWID table), views, triggers (BEFORE, AFTER and INSTEAD OF on INSERT,
UPDATE [OF columns] and DELETE, FOR EACH ROW with WHEN, NEW and OLD, and
RAISE(IGNORE | ABORT | FAIL | ROLLBACK, message); the newest fires first,
recursive_triggers is off, foreign key actions fire the child's triggers, and a
view with INSTEAD OF triggers takes writes), ALTER TABLE (rename, add, rename and
drop column; renames rewrite triggers, indexes and the views that read the table,
quoting the new name as the statement did), joins (inner, left, right, full, cross,
USING, NATURAL), grouping, aggregates, compound selects, scalar, IN and EXISTS
subqueries, recursive CTEs, upsert, RETURNING, transactions and savepoints. Refused
by name rather than half-done: window functions, partial and expression indexes,
generated columns, ATTACH, virtual tables, JSON operators and bytecode EXPLAIN.
Foreign keys are checked immediately rather than deferred to the end of the
statement.
Language runtimes#
python3 (crate cw-pyvm) and node (crate cw-jsvm) are interpreters written in
Rust that run inside the simulation. They are commands like any other: they resolve
through PATH and which, read stdin from a pipe or here-document, write to the pipe
or redirection that follows them, and set $?. A script with a #!/usr/bin/env
python3, #!/usr/bin/python3, #!/usr/bin/env node or #!/usr/bin/node line runs
under that interpreter when executed by path after chmod +x.
Both see exactly what the rest of the shell sees and nothing of the host:
- Files are the computer's VFS, with the current user's permissions, relative to the
shell's working directory. A program's
os.chdir/process.chdirmoves only the program, never the shell. - Time is the simulated clock.
time.time(),Date.now()andnew Date()start at the world tick; timers,time.sleep,setTimeoutandsetIntervaladvance a virtual clock and never block the host. Executing code takes virtual time as well (one millisecond per 100 000nodeinstructions), so a busy-wait onDate.now()ends. The timezone is UTC. - Randomness (
random,secrets,Math.random,crypto.randomBytes,crypto.randomUUID) is drawn from the world's seeded entropy, so a replay prints the same numbers.random.seed(n)streams match CPython exactly. - Resources are bounded: a program that exceeds its instruction budget (50 million
steps for
python3, 200 million fornode) stops with aTimeoutErrorand status124; the budget cannot be caught. Deep recursion is Python'sRecursionErroror Node'sRangeError: Maximum call stack size exceeded, not a host crash.
Output reproduces the real tools, byte for byte where it is observable: print, repr
and tracebacks for Python; console.log / util.inspect formatting, uncaught-error
reports (source line, caret, stack with Node's internal frames, Node.js v24.21.0),
unhandled rejections and exit codes for Node. Conformance corpora of whole programs
with outputs recorded from CPython 3.12 and Node 24.21 live in
crates/pyvm/tests/programs and crates/jsvm/tests/programs.
node implements the language through ES2023 (classes with private members,
generators, async functions and async iterators, destructuring, spread, optional
chaining, BigInt, tagged templates, Proxy/Reflect, typed arrays, DataView, WeakRef,
labelled statements, getters and setters, ES modules with top-level await and dynamic
import()), CommonJS require with Node's resolution (node_modules, index.js,
package.json main, JSON files, require.cache), and a Node-shaped event loop
(process.nextTick, microtasks, timers, immediates, process.on('exit'),
'uncaughtException' and 'unhandledRejection'). Built-in modules: fs (sync,
callback and promise APIs), fs/promises, path, os, events, util, assert
(assert/strict), readline (readline/promises), url, querystring,
string_decoder, stream (a subset), buffer, crypto (hashes, HMAC, random),
timers, timers/promises, perf_hooks, process, child_process, http,
https, net, dns (dns/promises), zlib. Globals include Buffer, URL,
URLSearchParams, TextEncoder, TextDecoder, AbortController, structuredClone,
atob/btoa, queueMicrotask, a crypto object, and fetch with Headers,
Request, Response, FormData, Blob, File and a minimal ReadableStream.
Network#
Both runtimes reach the simulated network exactly as the machine's other clients do
(the browser, curl): every request goes through the world's DNS, routes, gateway
policy and listeners, reaches the service's handler, and takes the simulated time the
world charges for it. Nothing reaches the host.
- Python:
urllib.request(urlopen,Request, openers and handlers, redirects,HTTPError/URLError,file:anddata:URLs),urllib.parse,http.client(HTTPConnection,HTTPSConnection),http.HTTPStatus,ssl(contexts that carry settings), andsocket(getaddrinfo,gethostbynameand friends against the world's DNS;create_connection,connect,sendall,recv,makefile). - Node:
http/https(request,get,Agent,IncomingMessage,createServer), globalfetch(redirects,AbortSignal),net(Socket,connect,createServer) anddns(lookup,resolve4/resolve6/resolve,reverse, promises). Replies arrive as I/O completions at their simulated time.
A TCP connection to another machine is accepted or refused by the world (DNS, route,
a listening service). Every simulated service speaks HTTP, so the bytes written on a
socket are parsed as HTTP/1.x requests and each one is carried through the world's
network; the answer comes back as HTTP/1.1 bytes (Content-Length, Connection
honoured). Bytes that are not HTTP get 400 Bad Request and the connection closes, as
a web server would. UDP datagrams to other machines are sent and lost (no UDP
services exist). Servers a program creates (socket.bind/listen/accept,
http.createServer, net.createServer) accept connections from that same program:
other machines cannot reach a process that lives for one command.
TLS follows the browser's model: an https:// request goes to port 443 of the host
and the world decides whether anything answers there; in the reference world the
services listen on port 80, so https:// is refused (ECONNREFUSED,
[Errno 111] Connection refused) exactly as the browser sees it. Errors carry the
real vocabularies: socket.gaierror: [Errno -2] Name or service not known,
getaddrinfo ENOTFOUND host, connect ECONNREFUSED 10.0.1.10:443,
TypeError: fetch failed with the cause attached. A client timeout that the
simulated latency exceeds raises TimeoutError: timed out / ETIMEDOUT.
Threads#
Python's threading runs on a deterministic green-thread scheduler inside the
interpreter: Thread (with name, daemon, join(timeout), is_alive,
ident, native_id), Lock, RLock, Condition, Event, Semaphore,
BoundedSemaphore, Barrier, Timer, local, current_thread,
main_thread, enumerate, active_count, excepthook/ExceptHookArgs,
stack_size and get_ident; queue (Queue, LifoQueue, PriorityQueue,
SimpleQueue, task_done/join, shutdown) and concurrent.futures
(Future, Executor, ThreadPoolExecutor, map, as_completed, wait,
FIRST_COMPLETED/FIRST_EXCEPTION/ALL_COMPLETED) are built on it.
Only one thread runs at a time, as under CPython's GIL, and the interpreter
switches between threads after a fixed number of bytecode instructions. That
quantum derives from the world seed (scaled by sys.setswitchinterval), so a
race — a lost update, an interleaved log, the order two threads leave a
semaphore — replays exactly the same way every time the world runs, and
differently under a different seed. time.sleep in a thread advances only that
thread on the simulated clock: three threads sleeping 50 ms each finish after
50 ms of simulated time, and waits end in deadline order. A thread that raises
is reported as threading.excepthook does (Exception in thread NAME: and the
traceback on standard error) and the program goes on; the interpreter waits for
non-daemon threads to finish and abandons daemon ones. A wait nothing can
satisfy is not a hang: the blocked thread gets
RuntimeError: deadlock: every thread is waiting and none can make progress,
which unwinds its frames (releasing what it held) and is reported like any other
thread failure.
Node's worker_threads runs the same way: a worker is another JavaScript
context — its own global object, module registry, microtask queue and timers —
that the interpreter swaps in when it is that context's turn. Worker
(workerData, eval, transferList, postMessage, terminate, threadId,
and the online, message, error and exit events), parentPort,
isMainThread, threadId, MessageChannel, MessagePort (postMessage,
on('message'), start, close, ref/unref, onmessage),
receiveMessageOnPort and markAsUntransferable are there, together with
SharedArrayBuffer and the whole of Atomics (add, and,
compareExchange, exchange, load, or, store, sub, xor,
isLockFree, pause, wait, waitAsync, notify).
A message is structured-cloned on its way across, so the two sides share
nothing — except a SharedArrayBuffer, whose bytes both contexts go on
reading and writing, which is what Atomics works on. Only one context runs
at a time, and it runs until it could make no more progress on its own, so the
result of a race between two workers is the same in every run of a world.
Starting a worker costs 10 ms of simulated time (a real thread spends about
that long building its isolate), which is why a timer of a millisecond or two
fires before a freshly started worker's first message arrives. What a worker
writes reaches the terminal through its parent, as Node's pipe does, so it
appears when the parent next comes round its loop and not in the middle of a
line the parent is writing. Atomics.wait on any thread hands the turn to the
other contexts and comes back when the cell changes or the timeout passes; a
wait that nothing could ever end stops the program with
Atomics.wait: every thread is waiting rather than hanging. A worker left
waiting for a message that can no longer come ends with code 0 once nothing
anywhere can move, where Node would keep the process alive for ever.
Not there: worker.resourceLimits, BroadcastChannel,
moveMessagePortToContext, setEnvironmentData/getEnvironmentData, the
argv/env/resourceLimits options (a worker shares its parent's process
object), worker.stdin, and BigInt64Array/BigUint64Array for Atomics.
An uncaught error in a worker still ends the program with status 1, but the
frames printed with it are the main thread's, not the worker's.
Child processes#
subprocess (run, Popen with pipes, communicate, call, check_call,
check_output, getoutput, getstatusoutput; shell=, cwd=, env=, input=,
text=, timeout=), os.system and os.popen in Python, and child_process
(spawn, exec, execFile, fork, spawnSync, execSync, execFileSync, with
stdio pipes, input, cwd, env, encoding, exit codes) in Node run the machine's
own shell commands — builtins, scripts, pipelines and nested python3/node — one
nesting level below the program (the shell's 32-level cap applies). A child runs to
completion when it starts: Popen with stdin=PIPE starts once its input is closed,
and asynchronous Node children deliver their output and exit as I/O completions at
the simulated time the child took. A child's virtual run time (its sleeps, timers and
network waits) is charged to the parent's clock, which is what timeout= compares
against; a timed-out child has still run to its end. Output a Python child writes to
an inherited stream lands where CPython's would: after the parent's already-flushed
output (standard output to a pipe is block-buffered, as in CPython), so
print('a'); os.system('echo b') prints b first unless the parent flushed.
Compression#
cw-zlib is a port of zlib 1.3.1's deflate, so compressed bytes are the real
library's, byte for byte, at every level, window size, memory level and strategy.
Node ships Chromium's fork of zlib, whose string hashing differs, and CPython links
the system zlib; the port reproduces both, so zlib.deflateSync in the simulated
node and zlib.compress in the simulated python3 agree with the real programs
(and with each other's decompressors). crates/zlib/tests/vectors.json holds the
hashes of 434 outputs recorded from CPython 3.12 and Node 24.21 for that check.
- Python:
zlib(compress,decompress,compressobj/decompressobjwith flush modes, dictionaries,unused_data/unconsumed_tail,crc32,adler32),gzip(compress,decompress,open,GzipFile) andstruct. - Node:
zlib—deflate/inflate/gzip/gunzip/unzip/deflateRaw/inflateRawand brotli, in sync, callback and stream forms, withcrc32,constantsand the option checks. Asynchronous results arrive as I/O completions after simulated work proportional to the bytes handled, so several jobs complete in the order their sizes imply. - Brotli is the
brotlicrate (a port of Google's encoder and decoder); its compressed output is not promised to match the C library bit for bit, though it does for the recorded cases, and anything it produces or accepts is valid brotli. - Python's
bz2andlzmaare not implemented.
Consoles and reading from the terminal#
python3 and node with nothing to run start their console when standard input
is the terminal (a pipe or a redirect still means "read a program"), and a
program that reads a line — input(), sys.stdin, process.stdin,
readline's question — stops until the next line is typed. Both print what
CPython 3.12 and Node 24.21 print: the same banner, the same prompts (>>> and
... , > and | ), values echoed with repr() and util.inspect,
Traceback (most recent call last): and Uncaught TypeError: …, Node's dot
commands (.help, .break, .clear, .exit). At a terminal both streams are
one screen, so an interactive run returns one stream with prompts, output and
errors interleaved in the order they appear. The shell's prompt while a console
is open is the console's, and the terminal's next line goes to it rather than to
the shell; exit(), .exit or a Ctrl-D line (\u0004) ends it.
An interpreter cannot be kept alive between two actions of the world (its heap is not serializable, and a snapshot may be restored anywhere), so a waiting session is resumed by replay: the program is run again from the start with the new line appended to its input, and every host call the earlier lines made — files written, requests sent, the clock, the world's entropy — is answered from a journal recorded the first time instead of being made again. The interpreter is deterministic, so the replay reaches the same place; only what the new line produced is shown. Two consequences are worth knowing: a session is part of the machine's state and survives a snapshot, and a console line that runs for a long time is re-run (not re-executed against the world) on every later line.
In the Node console a binding a line makes (const x = 1, function f() {},
class C {}) is copied into the global object when the line finishes, which is
how the next line sees it; a closure that later changes such a binding does not
change what the next line reads. Top-level await is not transformed, so it
yields a promise rather than its value.
Languages, regions and time zones#
Both runtimes format dates, numbers, currencies and lists in fourteen
languages: en-US, en-GB, de, fr, es, it, pt-BR, ja, zh-CN, zh-TW, ko, ru, ar
and hi. A locale that is not one of them falls back to its language (de-AT
formats as de-DE, en-AU as en-GB, pt-PT as pt-BR) and an unknown
language falls back to en-US.
- Node has ECMA-402:
Intl.DateTimeFormat(component options anddateStyle/timeStyle,timeZone,hour12/hourCycle,formatToParts),Intl.NumberFormat(decimal, percent, currency and unit styles, grouping, fraction and significant digits,signDisplay, standard, compact and scientific notation,formatToParts),Intl.PluralRules(cardinal and ordinal),Intl.RelativeTimeFormat,Intl.ListFormat,Intl.DisplayNames(languages, regions, scripts and currencies of the covered set),Intl.Collator,Intl.Locale,Intl.getCanonicalLocalesandIntl.supportedValuesOf;toLocaleString,toLocaleDateString,toLocaleTimeStringandlocaleComparego through them.Intl.SegmenterandIntl.DurationFormatare not implemented, the only calendar isgregoryand the only numbering systems are the ones those locales use (latn, andarabwhere Arabic asks for it). - Python has
locale(setlocale,getlocale,localeconv,nl_langinfo,format_string,currency,str,atof,atoi,delocalize,normalize), andtime.strftime/datetime.strftimefollowLC_TIME:%a,%A,%b,%B,%p,%c,%x,%Xand%rare the locale's, with glibc's-,_,0and^flags. A locale outside the fourteen raiseslocale.Error, as CPython does for one that is not installed. - Time zones:
zoneinfo.ZoneInfoandIntl'stimeZoneoption know the 70-odd IANA zones incrates/tz(every offset in use, and the places a world's machines and services are plausibly in), with their transitions between 1970 and 2050, their abbreviations and their aliases (US/Eastern,Asia/Calcutta). A local time that happens twice or never resolves the way CPython'sfoldand V8 do. The machine's own clock stays UTC: a zone is something a program formats with, not somewhere the machine is.
The data is compiled in and generated by hand from the host's own libraries, so
the simulated runtimes agree with the real ones: crates/jsvm/data/cldr.json
(261 KiB, recorded from Node 24.21's ICU by crates/jsvm/tools/generate_cldr.js,
parsed on the first Intl use and never otherwise),
crates/pyvm/src/locale_data.rs (18 KiB, recorded from the host's glibc by
crates/pyvm/tools/generate_locales.py; Arabic and Hindi are filled in from the
CLDR file, since glibc had no data for them there) and crates/tz/src/data.rs
(134 KiB of source for 6,219 transitions, from the host's IANA database by
crates/tz/tools/generate.py). Together that is about 410 KiB of tables in the
binary.
Debugging#
Both runtimes can run under a DAP-shaped debugger — breakpoints with conditions,
hit counts and logpoints, stepping, exception filters, frames, scopes, variables,
evaluation in a frame and changing a value — and both are wired to the machine's
debug seam, so Visual Studio Code's Run and Debug view stops a python3 or
node program on this machine. A session cannot hold a live interpreter between
two actions of a world, so it keeps what it takes to be back at the stop and
replays the program, answering the host calls the earlier runs made from a
journal. See docs/debugging.md.
Event-loop timing#
Node's loop phases (timers, poll, check), process.nextTick and promise jobs
run in Node's order, and when a callback is due follows from what the program
costs in simulated time rather than from any fixed assumption:
- executing code costs 100,000 interpreter instructions per millisecond;
- preparing a function body the first time it runs costs a millisecond per kibibyte of its own source — V8 compiles it then, and Node reaches into the internals a body that size needs. Node's own builtins are in V8's startup snapshot and cost nothing; resolving and reading each module of the program costs 0.05 ms;
- simulated I/O (a network reply, a compression job, a child process) costs the time the world says it takes.
So setTimeout(f, 0) against setImmediate(g) from the main module — the case
that depends on wall-clock jitter in real Node — comes out of the program: a
short program reaches the first turn before the 1 ms timer is due and the
immediate wins, while one that loads or computes for longer than a millisecond
sees the timer fire first. A timer started inside a callback counts from the
moment it is started, as Environment::GetNow does, so work done in a callback
pushes back what was queued behind it. The rates are model constants (not
measurements of any real machine); they are calibrated so that the orderings
recorded from Node 24.21 in crates/jsvm/tests/programs come out the same way.
Known gaps shared by both: no native extensions. Strings that contain unpaired UTF-16 surrogates are carried as the replacement character.
Process table#
Every process on a machine is a real entry in crates/computer/src/process.rs, and
ps, top, pgrep, pkill, kill, free and lsof all read that one table. There
are four kinds of entry, and nothing else is ever in it:
| Entry | Started by | TTY |
|---|---|---|
init, pid 1 | the machine | ? |
service <id> | a placed service, or systemctl start | ? |
| a command line | every command the shell runs, including sleep N & | pts/0 |
| an application | an open window on the desktop; the command is the application's id plus the document it has open | ? |
An open window really is a process: launching an application adds one, closing the
window ends it, and killing the process closes the window. pkill browser shuts the
browser. A machine models one pseudo-terminal, pts/0, which is the one the shell runs
on.
Columns#
ps -o takes these names, comma-separated, and --sort=[+-]NAME orders by any of them
(- for descending). ps --json serializes the whole Process struct instead.
-o name | Header | Aliases | Source |
|---|---|---|---|
pid | PID | the process id | |
ppid | PPID | its parent; an orphan is reparented to 1 | |
pgid | PGID | pgrp | process group |
user | USER | ruser | the owner's name |
uid | UID | 0 for root, otherwise hardware.uid | |
comm | COMMAND | ucomm | the program name alone |
args | COMMAND | cmd, command | the whole command line; a zombie adds <defunct> |
stat | STAT | R running, S sleeping, T stopped, Z zombie | |
state | S | s | the same letter, one column wide |
tty | TTY | tt, tname | pts/0 or ? |
time | TIME | cputime | CPU time; see below |
etime | ELAPSED | wall time since it started, [D-]HH:MM:SS | |
etimes | ELAPSED | the same, in seconds | |
rss | RSS | rsz, rssize | modelled resident memory in KiB; see below |
vsz | VSZ | vsize | rss plus one fixed 64 MiB mapping, in KiB |
pmem | %MEM | %mem | rss over hardware.memory_bytes |
pcpu | %CPU | %cpu | CPU share; see below |
c | C | System V's CPU utilisation; see below | |
start | START | stime, lstart, bsdstart | HH:MM the process started, from the world clock |
The JSON form#
ps --json prints the whole Process record for each selected process, which is the
serialized Rust struct and therefore always in step with the table above:
[{"pid":2,"parent":1,"group":2,"owner":"ada","command":"browser https://wiki.internal/",
"state":"Running","started":0,"ended":null,
"fds":{"0":"Stdin","1":"Stdout","2":"Stderr"},"listeners":[],
"signal_dispositions":{},"pending_signals":[],"wake_exit":null,
"tty":"","rss_bytes":335544320,"cpu_us":0}]
Every field is always present. state is "Running", "Stopped",
{"Sleeping":{"until":<tick>}}, {"Zombie":{"code":N}} or {"Exited":{"code":N}};
started/ended are world-clock ticks (microseconds), and ended is null while the
process runs; parent is the PPID column and group the PGID; fds maps each open
descriptor number to "Stdin"/"Stdout"/"Stderr", {"File":{…}}, {"Pipe":{…}} or
{"Socket":{…}}, which is what lsof prints; listeners are the node:port pairs a
service process owns; tty is empty rather than ? when the process is attached to
none; and rss_bytes and cpu_us are the raw byte and microsecond values the rss and
time columns format.
The three ready-made formats are the real ones: ps is pid,tty,time,args, ps -f is
user,pid,ppid,c,start,tty,time,args (headed UID … STIME …), and ps aux is
user,pid,pcpu,pmem,vsz,rss,tty,stat,start,time,args.
Memory is modelled; CPU time is not#
Memory is a published model, not a measurement: this world has no allocator. A
process's RSS is the footprint of the program it runs, plus what it is actually
holding — for a window, the bytes of the document it has open. Every number is fixed
when the process starts, so ps reports the same figure on every replay, and the
ordering is the ordering the real programs would have. The program table
(crates/computer/src/process.rs) is:
| Program | RSS |
|---|---|
init | 2 MiB |
| a service | 24 MiB |
node | 48 MiB |
python3 | 28 MiB |
git | 8 MiB |
sqlite3 | 6 MiB |
sh, bash, sudo | 3 MiB |
| any other command | 2 MiB |
and the application table (crates/environment/src/lib.rs) is:
| Application | RSS |
|---|---|
browser | 320 MiB |
freecad, kicad | 240 MiB |
kdenlive, imovie, clipchamp, videoeditor | 210 MiB |
code | 180 MiB |
gimp, pixelmator, sketchbook, pinta, paint | 140 MiB |
docs, spreadsheet, excel, database | 120 MiB |
photos, preview, music, maps | 90 MiB |
files | 45 MiB |
editor | 30 MiB |
calculator, clock, weather, notes, contacts | 24 MiB |
terminal | 12 MiB |
| any other application | 60 MiB |
So "the ten processes using the most memory" is a question with a real answer:
ps -e -o pid,rss,comm --sort=-rss | head -n 11
CPU time is not modelled and is not faked. Commands here run in zero simulated
time — only sleep occupies the clock, and sleeping is not CPU — so TIME, %CPU and
C are 00:00:00, 0.0 and 0, and top's %Cpu(s) line is idle. cpu_us is a real
field on every process rather than a constant, so the day something charges it the
column will say so; until then, use etime/etimes, which are real. top's PR and
NI are fixed at 20 and 0 for the same reason, and nice is refused by name.
Clock#
date is derived from the simulated tick, never the host clock. Tick 0 is
09:00:00 UTC on Thursday 17 September 2026, the same origin the desktop clock and
the GUI status bar use, so they never disagree. A tick is one microsecond. There is one
timezone (UTC) and no way to set the clock from the shell; advance simulated time
instead.
ls --json#
ls --json answers "what is every entry in this directory?" in one call, instead
of a listing followed by an test -d per name. It prints a single JSON array, one
object per entry, in the same order and under the same -a/-A/-d/-R/-t/-S
rules as the text form:
| Field | Meaning |
|---|---|
name | the entry as ls would print it |
path | its absolute path |
kind | file, directory, symlink, or unknown when it could not be read |
size | bytes; a directory reports one 4 KiB allocation unit |
mode | four octal digits, e.g. "0755" |
owner / group | names, as %U/%G give them |
links | hard links, as stat counts them |
inode | the node's identity in this filesystem |
atime / mtime / ctime | world-clock stamps, YYYY-MM-DD hh:mm:ss |
target | present only on a symlink: the stored link text |
ls -R --json prints one array per directory, separated by a blank line, and no
dir: headings — each object's path already says where it is.
File metadata#
Every node carries an identity and a full set of attributes, all of them deterministic:
| Attribute | Moved by |
|---|---|
| inode | creation; a hard link shares it |
| owner / group | creation (the creator, and their login group, or the parent's group under a setgid directory), chown, chgrp |
| mode, including setuid/setgid/sticky | creation through the umask, chmod, mkdir -m, install -m |
| link count | ln, rm; a directory reports 2 plus one per subdirectory |
created (%W, Birth:) | creation |
accessed (%X) | creation, touch -a, cp -p |
modified (%Y) | a write, touch -m, cp -p |
changed (%Z) | a write, chmod, chown, touch |
| symlink target | ln -s |
The filesystem behaves as if mounted noatime: a read does not move the access
time, because reading takes a shared borrow of the VFS. touch -a, cp -p and a
creation do. There is no group database, so membership is a convention: every user is
in the group that carries their own name and in the shared group users. That is what
makes the group permission bits sit genuinely between the owner's and everyone else's.
Permissions#
The mode is enforced, not decoration. read_as, write_as, list_as and every
command built on them check the owner bits, then the group bits, then the other bits,
and every directory on the path needs its execute bit. A file a user cannot read fails
to read for that user with permission denied: PATH and status 1; sudo (default
root) bypasses the check, as root does. The sticky bit on a directory (/tmp is
1777) stops one user removing or renaming another's files there. The desktop file
managers go through the same calls, so a GUI cannot reach what the shell cannot.
umask is one mask for the filesystem rather than one per process — this world runs
one shell per computer. It starts at 0022, so a new file is 0644 and a new
directory 0755.
The trash#
Deleting from a desktop, and trash from the shell, both write a real FreeDesktop
trash under ~/.local/share/Trash:
~/.local/share/Trash/files/todo.md the file itself, moved
~/.local/share/Trash/info/todo.md.trashinfo [Trash Info]
Path=/home/user/notes/todo.md
DeletionDate=2026-09-17T09:00:00
Path= is percent-encoded and DeletionDate= comes from the world clock, so the
record is byte-identical on every replay. A name already in the trash gets .2, .3
and so on, so nothing is overwritten.
| Command | Behaviour |
|---|---|
trash / trash-put [-v] [-f] PATH… | move each path in and write its record; -f ignores what is not there |
trash-list [--json] | DELETED-AT ORIGINAL-PATH per entry, or the same as JSON |
trash-restore [-f] [--all] QUERY | put it back where it came from. QUERY is an original path, the name under files/, a basename, or a glob; more than one match is refused with the candidates listed rather than guessed at, and an occupied destination needs -f |
trash-empty | throw the whole trash away for good |
gio trash [--list|--empty|--restore] … | the same trash, under the GNOME spelling |
The desktops' Move to Trash and Restore go through the same records, and the Trash folder in each file manager shows each entry's original path.
Stat fidelity#
%u/%g report hardware.uid/hardware.gid (1000/1000 by default), because there is
no numeric user database; %U/%G report the stored owner and group names, which are
real. Directory sizes are reported as one 4 KiB allocation unit rather than the stored
child count. Device: is a constant, and so is stat -f's filesystem type
(ext2/ext3) and name length (255); its block counts are hardware.disk_bytes against
usage summed from the VFS, exactly as df computes them.
which and builtins#
Everything in the table above runs in-process; the VFS holds no real binaries. which
first searches PATH in the VFS — so packages installed by apt/pip resolve to
their real installed path — and otherwise reports a nominal /usr/bin/NAME for any
command this shell implements. That keeps "is this available?" a truthful question.
which exits 1 only when no operand resolves. A command the table marks refused
by name (nice, jobs, vmstat and the rest) is deliberately not in that roster:
which reports it missing, because it is, and running it explains why rather than
printing command not found.
Fixed hardware facts#
Computer::hardware holds every constant the probing commands report:
| Field | Default | Read by |
|---|---|---|
cpus | 4 | nproc |
memory_bytes | 8 GiB | free, ps %MEM, top |
disk_bytes | 64 GiB | df |
device | /dev/vda1 | df |
interface / ipv4 / prefix / mac / gateway | eth0 / 10.0.2.15 / 24 / 52:54:00:12:34:56 / 10.0.2.1 | ip |
boot_tick | 0 | uptime |
uid / gid | 1000 / 1000 | stat |
Known gaps#
Deliberately not implemented, and refused rather than faked:
trap— it would need a signal-delivery model the process table does not have, and a handler that never fires is worse than a command that says it is missing.eval— re-entrant parsing of text built at runtime, for very little gain in a world where nothing arrives from outside the simulation.sh -ccovers the honest cases.select,export -f, arrays,declare/typeset,${VAR/…/…}and${VAR#…}. Functions are not exported, so a command substitution,sh -cor a script starts with an empty function table, as it would withoutexport -f.**in globs: a pattern matches one path component at a time."$@"expands to one word joined by spaces rather than one word per parameter; use it unquoted to forward parameters that contain no spaces.- Arithmetic beyond
+ - * / %and parentheses: no**, no comparisons, no++. - A regex backreference inside a pattern (
sed 's/\(a\)\1/x/',grep '\(a\)\1'). The engine is a finite automaton with no backtracking, so it is refused by name rather than quietly matching something else. Backreferences in asedreplacement (\1–\9) work. awk's|&co-processes and gawk's extension library (gensub,asort,PROCINFO,RT,BEGINFILE/ENDFILE). Aprint | "cmd"pipe buffers its text and runs the command when the pipe closes, because this world has no second process to schedule;close()is therefore load-bearing where gawk would also accept a flush.tail -f: nothing in this world changes a file except a command in this same shell, so a follow would never wake. It is refused by name.- Locale collation:
sortand every comparison use C/byte order, and[a-z]in a bracket expression means the ASCII range. - Real process scheduling: only
sleepoccupies simulated time, so no CPU accounting exists.psreportsTIME/%CPU/Cas zero andnice,vmstatand job control are refused by name rather than faked. Memory is modelled, from a published table; see Process table above for exactly what is measured and what is not. - A group database and an allocator. Group membership is the convention described under File metadata; block accounting assumes a 4 KiB unit.
- A terminal to prompt at, so
-ioncp,mvandrmanswers no: an existing destination is left alone andrm -iremoves nothing. That is what a real prompt does when its input is at end of file, and it is the safe answer. - Access times on read: the filesystem behaves as if mounted
noatime. - Binary bytes cannot travel through a pipe or a redirect: the shell's streams are
UTF-8 strings, so
printf '\211PNG'writes the UTF-8 encoding of U+0089, not the byte0x89. Commands that read bytes (file,xxd,od,hexdump,cmp,strings,md5sumand friends) read them straight from the VFS and are exact.