ComputerWorld

Driving a machine

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 editing Computer::hardware before the episode runs.

Exit codes#

Every command returns a truthful status in CommandResult::exit_code. Classify by the code; do not regex-match stderr.

CodeMeaning
0Success.
1A modelled negative or an operational error: no match, false test, missing file, permission denied.
2Outside the simulated surface, or malformed: an unsupported flag, an unsupported find predicate, a sed script this world does not implement, a shell syntax error.
126The command exists but cannot be executed (not executable, unsupported interpreter, package stub with no implementation).
127No 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:

ShapeExample
cmd: subject: reasongrep: nope: No such file or directory
cmd: action 'operand': reasonrm: cannot remove 'x': Is a directory
cmd: sentencecp: -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 errorReason
not foundNo such file or directory
already existsFile exists
not a directoryNot a directory
is a directoryIs a directory
directory not emptyDirectory not empty (Is a directory from rm without -r)
permission deniedPermission denied
symlink loopToo many levels of symbolic links
invalid pathInvalid 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#

ConstructStatus
; \n && || |modelled
> >> <modelled
2> 2>> 1> 1>>modelled
2>&1 1>&2 >&2modelled; descriptors are resolved left to right, so >f 2>&1 and 2>&1 >f differ as in bash
&> &>>modelled
/dev/null as a redirect targetmodelled: bytes are discarded and no file is created
<<HEREDOC, <<-, quoted delimitersmodelled
'single' "double" \escape, backticksmodelled
$VAR ${VAR} ${VAR:-default} $? $0..$n $# ${#} ${#VAR} $@ $*modelled
$(command) `command` $((arithmetic))modelled, nesting bounded at 32
* ? [...] globbingmodelled, 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 expansionmodelled, 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 assignmentsmodelled
trailing &only sleep N & truly backgrounds; elsewhere & acts as a separator
if … then … elif … else … fimodelled; the condition list's last status decides
for NAME in WORDS; do … done, for NAME; do … donemodelled; the second form walks $1…$#
while / until … do … donemodelled
case WORD in PAT|PAT) … ;; esacmodelled; 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
( … ) subshellmodelled; environment, working directory and function table are restored afterwards
{ … ; } groupmodelled; 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
( ) unquotedmetacharacters, 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:

LimitValueMessage
Source length64 KiBcommand exceeds 64 KiB limit
Nesting depth ($( ), sh -c, scripts, source, subshells, function calls)32execution nesting exceeds 32
Simple commands run10 000command budget exhausted after 10000 commands
Loop iterations, summed over every loop10 000loops 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#

CommandSupportedUnsupportedBehaviour
true / : / falsemodelled; : is the null command, so a loop body can do nothing
echo-nmodelled
printfthe 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
pwdall flagsmodelled
whoamiall flagsmodelled
hostnameall flagsmodelled (the computer id)
uname-a -r -m and the restmodelled (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 conversionmodelled from the tick; see Clock below
cdall flagsmodelled
env / printenvNAMEall flags, and env NAME=V CMD (each would need a model this world does not have)modelled
export / unsetNAME=VALUE / NAMEall flags, including export -fmodelled
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/--jsonmodelled; 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 namemodelled; 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 epochmodelled; 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-Zmodelled; 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 --parentsmodelled; 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 -Smodelled; 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-systemmodelled; 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-emptymodelled; empty directories only. -p walks up the components the operand names and stops, loudly, at the first non-empty parent
chmodoctal 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 -imodelled; 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 / chgrpOWNER, OWNER:GROUP, OWNER:, :GROUP, -R / --recursive, -v, -h / --no-dereference-c --reference, --from, numeric idsmodelled; only root hands a node to another user, and the owner may set its group
umask[-S] [-p] [MASK], octal or symbolicmodelled; 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 --backupmodelled; the ownership flags are refused rather than half-honoured
readlink / realpath-f -e -m -s/-q, long forms--relative-to, -zmodelled; 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 trashsee The trash belowtrash-rm, an age operand on trash-emptymodelled 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 conversionmodelled; see Stat fidelity below
findsee find belowevery other predicate, refused by namemodelled
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 -mmodelled; 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 / nawkthe 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-processesmodelled; 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 -pmodelled; -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
trSET1 [SET2], ranges a-z, [:alpha:] and the other classes, [c*n], [c*], escapes; -d -s -c -t, --delete --squeeze-repeats --complement --truncate-set1operands beyond twomodelled, 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 charmodelled; 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 namemodelled; 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-zmodelled; 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=METHODmodelled over adjacent lines only, as coreutils does
paste-s, -d LIST-zmodelled; the delimiter list cycles
join-1 -2 -j -t -a N -v N -o LIST -e TEXT -i --check-order --nocheck-order--header, -zmodelled; --nocheck-order names the default, --check-order really checks and fails with status 1
comm-1 -2 -3, --output-delimiter--total, -zmodelled; 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 comparisonmodelled 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-errormodelled; /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, -hmodelled
revall flagsmodelled
fold-w N, -N, -s, -b, long formsmodelled
expand / unexpand-t LIST / --tabs, expand -i, unexpand -a-t with a multibyte tab charactermodelled; each command refuses the other's flag
shuf-n N, -e, -i LO-HI, -r, long forms-z, --random-sourcemodelled; 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 formsmore than 1 000 000 values, refusedmodelled
yes[STRING…]short flags are operands, as in coreutils; a long flag is refusedmodelled 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
basenameNAME [SUFFIX], -a, -s SUFFIX, long forms-zmodelled
dirnameNAME…-zmodelled
split-l N, -b SIZE (with b K M G), -a N, -d, long forms-n CHUNKS, -C, --filter, --additional-suffixmodelled; 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, -fmodelled over the stored bytes
base64-d, -i, -w N, long forms--base64urlmodelled; 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-missingmodelled; the digests are the real ones, computed from the stored bytes
cmp-s / --silent / --quiet, -l / --verbose-i, -n, --bytesmodelled; the differ: line goes to stdout, as in coreutils, and the status is 1
xxddefault, -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, -vevery other -t format, refused by namemodelled; repeated lines collapse to * unless -v
hexdumpdefault (two-byte octal), -C -c -b -x -d -o, -n N, -s N, -v-e format stringsmodelled
file-b, -i / --mime, -L; -h accepted and inert because not dereferencing is the default-z, -f, --magic-filemodelled; see file below
du-s -a -h -k -b -m -c -d N, --max-depth=, --summarize --all --human-readable --bytes --total--exclude, -x, -Lmodelled 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 -Amodelled; 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 / zcatFILE…, -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 / unzipzip [-r] [-q] ARCHIVE FILE…; unzip [-l] [-o] [-q] [-d DIR] ARCHIVE [FILE…]encryption, -u -m -9, split archivesmodelled; 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 formsevery remote spec (host:path, user@host:path, rsync://), -z -u --exclude -r without -amodelled 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 -Bmixed: capacity and device name are fixed, usage is summed from the VFS; one filesystem mounted at /
which-a, --all-smodelled over PATH; see which and builtins below
nproc--all--ignorefixed (hardware.cpus, default 4)
uptime-p -s, --pretty --since-h -Vmodelled from the tick and hardware.boot_tick; the load average and user count are fixed at 0.00 and 1
clear / clsall arguments, refused by namemodelled as a screen action: no output, CommandResult::clear is set
ipaddr | 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 actionfixed (hardware.ipv4, prefix, mac, gateway, interface)
sudo-u USER, --, -v -k -K (succeed and do nothing); -n -E accepted and inertpassword prompts, a sudoers policy, -i/-s login shellsmodelled 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 -kmodelled 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, -omodelled; 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 namemodelled; 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
localNAME[=VALUE]…-r -i -amodelled; 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
getoptsOPTSTRING NAME [ARG…], clusters (-ab), glued and separate option arguments, a leading : for silent mode--long optionsmodelled; OPTIND and OPTARG are ordinary shell variables, so resetting OPTIND=1 restarts the scan
ps / Get-Processaux, -e / -A, -f, -u USER, -p PID, -o COLUMNS, --sort=[+-]COLUMN, --json-l --forest, -o rss=LABEL header renaming, every other BSD operandmodelled; 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 COLUMNinteractive mode, -n other than 1, every other optionmodelled 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 formsregular 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 pidsmodelled 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 --simodelled: 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 inertevery other option; the DEVICE and SIZE/OFF columnsmodelled over the process table's real descriptors and listeners. Status 1 when nothing matches
apps--jsonmodelled: 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 URLevery option; gio takes only open and trash, and every other subcommand is refused by namemodelled: 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 / renicerefused by name: no scheduler is simulated, so a priority would change nothing
jobs / bg / fg / disown / waitrefused 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 / sarrefused by name: no paging, block-device or interrupt counters are simulated. free reports memory and ps/top report the process table
sleepfractional seconds, trailing &modelled against simulated time; never blocks the host
systemctl / servicestart stop restart statusevery flag (--user, --now, --no-pager … all imply machinery this world lacks), enable disable daemon-reloadmodelled against the process table and the service adapter
apt / apt-get / brew / winget / pip / npminstall, remove/uninstall, listupdate upgrade searchmodelled 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 namemodelled against the network adapter
gitinit, 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 DIRevery other subcommand and every global option, refused by name with status 2modelled, 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 inertevery other option, refused by name with status 2; an interactive promptmodelled: 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 -xmodelled; 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 / pythonFILE [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 inertpip inside the interpreter, C extensionsmodelled by an in-process CPython 3.12 interpreter; see Language runtimes below
node / nodejsFILE [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 addonsmodelled by an in-process ES2023 interpreter with Node 24.21 semantics; see Language runtimes below
PowerShell aliasesWrite-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-Paththe rest of PowerShellmodelled; only available when the computer's dialect is powershell
anything elsestatus 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.

GroupSupported
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:

MagicReported as
empty fileempty
\x89PNG\r\n\x1a\nPNG image data, W x H, D-bit/color KIND, non-interlaced, with , APNG appended when an acTL chunk is present
\xff\xd8\xffJPEG image data, JFIF standard
GIF87a / GIF89aGIF image data
RIFF….WAVERIFF (little-endian) data, WAVE audio
SQLite format 3\0SQLite 3.x database
%PDF-PDF document, version N.N
PK\x03\x04Zip archive data, or Microsoft Excel 2007+ / Word / PowerPoint / OpenDocument … when the member names say so
#!NAME script, ASCII text executable
\x7fELFELF binary (this world runs no native executables) — nothing here writes one
valid UTF-8, no control charactersASCII text, Unicode text, UTF-8 text, JSON text data or CSV text, with , with no line terminators when the last line is unterminated
anything elsedata

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).

CommandEffect
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-commandBehaviour
.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 TABLEa 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 FILESQL text out and back in
.open ?--new? FILE, .save FILE, .output ?FILE?, .once FILEswitch databases, copy one, redirect output to a file
.bail, .echo, .changes, .print, .databases, .helpas 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.chdir moves only the program, never the shell.
  • Time is the simulated clock. time.time(), Date.now() and new Date() start at the world tick; timers, time.sleep, setTimeout and setInterval advance a virtual clock and never block the host. Executing code takes virtual time as well (one millisecond per 100 000 node instructions), so a busy-wait on Date.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 for node) stops with a TimeoutError and status 124; the budget cannot be caught. Deep recursion is Python's RecursionError or Node's RangeError: 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: and data: URLs), urllib.parse, http.client (HTTPConnection, HTTPSConnection), http.HTTPStatus, ssl (contexts that carry settings), and socket (getaddrinfo, gethostbyname and friends against the world's DNS; create_connection, connect, sendall, recv, makefile).
  • Node: http/https (request, get, Agent, IncomingMessage, createServer), global fetch (redirects, AbortSignal), net (Socket, connect, createServer) and dns (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/decompressobj with flush modes, dictionaries, unused_data/unconsumed_tail, crc32, adler32), gzip (compress, decompress, open, GzipFile) and struct.
  • Node: zlibdeflate/inflate/gzip/gunzip/unzip/deflateRaw/ inflateRaw and brotli, in sync, callback and stream forms, with crc32, constants and 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 brotli crate (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 bz2 and lzma are 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 and dateStyle/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.getCanonicalLocales and Intl.supportedValuesOf; toLocaleString, toLocaleDateString, toLocaleTimeString and localeCompare go through them. Intl.Segmenter and Intl.DurationFormat are not implemented, the only calendar is gregory and the only numbering systems are the ones those locales use (latn, and arab where Arabic asks for it).
  • Python has locale (setlocale, getlocale, localeconv, nl_langinfo, format_string, currency, str, atof, atoi, delocalize, normalize), and time.strftime/datetime.strftime follow LC_TIME: %a, %A, %b, %B, %p, %c, %x, %X and %r are the locale's, with glibc's -, _, 0 and ^ flags. A locale outside the fourteen raises locale.Error, as CPython does for one that is not installed.
  • Time zones: zoneinfo.ZoneInfo and Intl's timeZone option know the 70-odd IANA zones in crates/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's fold and 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:

EntryStarted byTTY
init, pid 1the machine?
service <id>a placed service, or systemctl start?
a command lineevery command the shell runs, including sleep N &pts/0
an applicationan 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 nameHeaderAliasesSource
pidPIDthe process id
ppidPPIDits parent; an orphan is reparented to 1
pgidPGIDpgrpprocess group
userUSERruserthe owner's name
uidUID0 for root, otherwise hardware.uid
commCOMMANDucommthe program name alone
argsCOMMANDcmd, commandthe whole command line; a zombie adds <defunct>
statSTATR running, S sleeping, T stopped, Z zombie
stateSsthe same letter, one column wide
ttyTTYtt, tnamepts/0 or ?
timeTIMEcputimeCPU time; see below
etimeELAPSEDwall time since it started, [D-]HH:MM:SS
etimesELAPSEDthe same, in seconds
rssRSSrsz, rssizemodelled resident memory in KiB; see below
vszVSZvsizerss plus one fixed 64 MiB mapping, in KiB
pmem%MEM%memrss over hardware.memory_bytes
pcpu%CPU%cpuCPU share; see below
cCSystem V's CPU utilisation; see below
startSTARTstime, lstart, bsdstartHH: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:

ProgramRSS
init2 MiB
a service24 MiB
node48 MiB
python328 MiB
git8 MiB
sqlite36 MiB
sh, bash, sudo3 MiB
any other command2 MiB

and the application table (crates/environment/src/lib.rs) is:

ApplicationRSS
browser320 MiB
freecad, kicad240 MiB
kdenlive, imovie, clipchamp, videoeditor210 MiB
code180 MiB
gimp, pixelmator, sketchbook, pinta, paint140 MiB
docs, spreadsheet, excel, database120 MiB
photos, preview, music, maps90 MiB
files45 MiB
editor30 MiB
calculator, clock, weather, notes, contacts24 MiB
terminal12 MiB
any other application60 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:

FieldMeaning
namethe entry as ls would print it
pathits absolute path
kindfile, directory, symlink, or unknown when it could not be read
sizebytes; a directory reports one 4 KiB allocation unit
modefour octal digits, e.g. "0755"
owner / groupnames, as %U/%G give them
linkshard links, as stat counts them
inodethe node's identity in this filesystem
atime / mtime / ctimeworld-clock stamps, YYYY-MM-DD hh:mm:ss
targetpresent 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:

AttributeMoved by
inodecreation; a hard link shares it
owner / groupcreation (the creator, and their login group, or the parent's group under a setgid directory), chown, chgrp
mode, including setuid/setgid/stickycreation through the umask, chmod, mkdir -m, install -m
link countln, 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 targetln -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.

CommandBehaviour
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] QUERYput 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-emptythrow 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:

FieldDefaultRead by
cpus4nproc
memory_bytes8 GiBfree, ps %MEM, top
disk_bytes64 GiBdf
device/dev/vda1df
interface / ipv4 / prefix / mac / gatewayeth0 / 10.0.2.15 / 24 / 52:54:00:12:34:56 / 10.0.2.1ip
boot_tick0uptime
uid / gid1000 / 1000stat

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 -c covers the honest cases.
  • select, export -f, arrays, declare/typeset, ${VAR/…/…} and ${VAR#…}. Functions are not exported, so a command substitution, sh -c or a script starts with an empty function table, as it would without export -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 a sed replacement (\1\9) work.
  • awk's |& co-processes and gawk's extension library (gensub, asort, PROCINFO, RT, BEGINFILE/ENDFILE). A print | "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: sort and every comparison use C/byte order, and [a-z] in a bracket expression means the ASCII range.
  • Real process scheduling: only sleep occupies simulated time, so no CPU accounting exists. ps reports TIME/%CPU/C as zero and nice, vmstat and 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 -i on cp, mv and rm answers no: an existing destination is left alone and rm -i removes 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 byte 0x89. Commands that read bytes (file, xxd, od, hexdump, cmp, strings, md5sum and friends) read them straight from the VFS and are exact.