Skip to content
fixpoint-linuxPublic

About

fixpoint-linux in the browser: fx-init + the content-addressed store + dhake + fxsh, booted under v86. ~3.75 MiB.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

13 Commits

Folders and files

Repository files navigation

fx-demo — the real fixpoint-linux system in a browser terminal

A static page that boots a real 32-bit Linux kernel under v86 (an x86 PC emulator compiled to wasm), with the actual fixpoint-linux system running on it:

  • fx-init as PID 1 — exec'd out of the content-addressed store via the kernel's rdinit=, the M4 initramfs path: no disk image, no bootloader.
  • the content-addressed store at /fx/store.
  • dhake materializing the rootfs — /etc and /bin exist in the guest only because dhake's buildfile ran and copied/symlinked them out of the store.
  • the boot verdict: fx-init's own fx-init: boot-ok v3 on the console. (The interactive terminal is DEFERRED, deliberately — and the verdict does not hang on a missing binary: the store is activated from m3/config-good.dhall, which declares NO console service, because a declared service whose binary is absent is an EXEC failure and fx-init fails the boot on that BY DESIGN (init.zig: "a console service that fails to SPAWN/EXEC still fails the boot honestly"; the exit it ignores is a user typing exit). The guest ships no fx-core payload and no busybox — harness/check-shell-free.mjs asserts the image stays shell-free — so there is no fx> prompt to type at. fxsh comes back as a PACKAGE installed through the system dhall config, so the store can attribute it with provides/src_of, instead of this image copying binaries out of a host bindir beside the store's knowledge.)

The kernel, the store, fx-init and dhake are genuine builds of the fixpoint-linux components — the emulator is emulating a PC, not simulating fixpoint. Total download: 2,786,539 bytes (2.66 MiB) — including the guest's 64 MiB disk, which is an empty ext2 and goes over the wire gzipped at 66,508 bytes.

sd 0:0:0:0: [sda] Attached SCSI disk
Run /fx/store/d41ef87c…-fx-init/fx-init as init process
fx-init: store from kernel command line fx.store=/fx/store
fx-init: boot start store /fx/store
EXT4-fs (sda): mounted filesystem bca8c481-2fd0-41bc-9001-b0a466163a9a r/w without journal. Quota mode: disabled.
…                                    (the ~30 MB seed: the disk was empty, so fx-init
                                      copies the ramfs store onto it — NATIVELY, no
                                      busybox: the image carries no shell at all)
fx-init: disk store mounted (current v3)
fx-init: pivot_root EINVAL (initramfs root is the namespace root) — switch_root fallback (MS_MOVE + chroot) applied
fx-init: pivoted to tmpfs root (magic 0x1021994)
fx-init: fifo control up (/run/fx/control.in + /run/fx/control.out)
fx-init: no UNIX control socket (/run/fx/control.sock: socket failed: Function not implemented — this kernel has no socket layer: CONFIG_NET is not set) — the fifo pair (/run/fx/control.in + control.out) carries the protocol for in-guest fxctl
fx> cat /etc/hostname
fixboxfx> cksum /bin/fxctl
1487296706 211824 /bin/fxctl
fx> cksum /bin/init
1977226950 845268 /bin/init
fx> cat /etc/passwd
root:x:0:0::/home/root:/bin/sh
fx> fx-init: boot-ok v3
seq 1 3 | head -n 2
1
2
fx> ls /bin
{"name":"dhake","size":402376,"mode":493}
{"name":"fakesvc","size":1110484,"mode":493}
{"name":"fx-activate","size":974404,"mode":493}
{"name":"fxctl","size":211824,"mode":493}
{"name":"init","size":845268,"mode":493}
fx> ls /tmp
{"name":"fx-ls-hGoJip","size":140,"mode":448}
{"name":"fx-ls-bKKOjo","size":200,"mode":448}
fx> cat /fx/disk/store/.bootlog
3 in-progress 1791885924 none
3 ok 1791885939 none
fx>

The two sd 0:0:0:0: [sda] / EXT4-fs (sda) / disk store mounted lines are this image's change: the page now boots the same three artifacts as the native souche lane — one i386 kernel, one initrd, one ATA disk — and fx-init brings the disk store up before it pivots. cat /fx/disk/store/.bootlog is fx-init's own append-only ledger on the disk, read back through the guest's console; the persistence assertion in harness/assert.mjs reboots the machine in place (same disk buffer, no page reload) and requires boot 1's exact bytes to still be a prefix of boot 2's.

Run it

git clone https://github.com/fixpoint-linux/fx-demo && cd fx-demo
python3 harness/serve.py 8080 web        # open http://127.0.0.1:8080/

Everything the page fetches is committed, so this works offline and needs no build. A served page is required (not file://) because a module script and a wasm fetch cannot be loaded from the filesystem.

Prove it yourself

python3 harness/serve.py 8080 web &
npm install playwright
npx playwright install chromium-headless-shell   # the browser playwright drives
node harness/assert.mjs http://127.0.0.1:8080/

(If you already have Playwright and a browser, skip the two installs and set PLAYWRIGHT=/path/to/node_modules/playwright/index.mjs instead.)

Exit 0 and every assertion below is the claim; proof.png, proof-serial.txt (boot 1) and proof-serial-boot2.txt (the in-place reboot) are written to the current directory. The pre-disk run quoted in evidence/ was made this way, against a fresh git clone of the pushed repo, not from a working tree — evidence/assert-output.txt is its verbatim transcript, including the scratch dir, the sha256sum -c SHA256SUMS result and the port used. (That clone was at 34684ca, the commit that introduced that image's web/ bytes; evidence/tmp-fix-before-after.txt is the same assertion run against the image before it, where the two /tmp assertions fail.) evidence/disk-2026-10-13/ is the DISK image's own run: assert-output.txt (the gate), page-boot1-serial.txt

  • page-boot2-serial.txt (the page's 8250 capture, boot 1 and the in-place reboot), persist-output.txt + persist-boot{1,2}.txt (the headless node twin of the same assertion) and control-ramfs-output.txt (the fail-before: the pre-disk bytes, booted WITH the disk attached, print the pinned ramfs line and mount nothing).
ASSERT fx-init-boot-ok-in-page: true (fx-init: boot-ok v3)
ASSERT pivoted-to-tmpfs: true
ASSERT rdinit-from-store: true
ASSERT dhake-materialized-/etc (rendered 'fixbox'): true
ASSERT dhake-/bin/fxctl-symlink-reads-through (in-guest "1487296706 211824 /bin/fxctl"): true
ASSERT dhake-/bin/init-symlink-reads-through (in-guest "1977226950 845268 /bin/init"): true
ASSERT dhake-materialized-/etc/passwd: true
ASSERT typed-pipeline-in-terminal: true  output-1: true  output-2: true  3-filtered: true
ASSERT post-pivot-/tmp-writable (ls /bin runs, no Mkdtemp): true
ASSERT post-pivot-/tmp-lists-a-mkdtemp-scratch-dir (ls /tmp): true
ASSERT disk-store-mounted-in-page: true (fx-init: disk store mounted (current v3))
ASSERT no-ramfs-fallback-line: true
ASSERT disk-ledger-read-through-the-guest-console: true 2 record(s)
ASSERT reboot-in-place-available: true
ASSERT b2-disk-store-mounted: true (fx-init: disk store mounted (current v3))
ASSERT b2-not-re-seeded (the discriminator: the store was already on the disk): true
ASSERT b2-ledger-grew-across-the-reboot: true 2 -> 4 record(s)
ASSERT b2-ledger-starts-with-boot-1's-exact-bytes: true
TOTAL FETCHED BY THE BROWSER (bytes): 5525275

The two cksum rows are not typed into the gate: harness/guest-image.mjs reads the store's fx-init/fxctl entries out of the shipped initrd.xz and computes their crc32+size, so the assertion is against this image's bytes (the pre-disk image's rows were 3239201204 826524 and 1217607440 210768). harness/check-cmdline.mjs runs first and refuses to start the browser at all if the rdinit= hash in web/guest-cmdline.mjs is not the store the archive carries.

docs/BOOT.md explains why each of those is evidence rather than decoration. The last two are the regression test for the /tmp fix — evidence/ tmp-fix-before-after.txt is the same assertion run against the previous image, where both are false and the guest answers ls /bin with error: Mkdtemp.

The numbers (MEASURED, not estimated)

5,525,275 bytes is not a sum of file sizes: it is the sum of the content-length of every response the browser actually received in the passing run, with the server applying Content-Encoding: gzip.

file raw sent
initrd.xz — the store + fx-init (NO fx-core, NO busybox) 831,332 831,332 (xz: incompressible)
bzImage (i386, linux-6.12.19 + ATA/ext4 fragment) 1,253,888 1,253,888 (extension-less: the server does not gzip it)
v86.wasm 2,007,347 380,456
libv86.js 359,825 93,787
xterm.mjs 344,970 88,463
disk.img — 64 MiB of empty ext2, the guest's ATA disk 67,108,864 66,616 (gzip; serve.py gzips .img for exactly this)
seabios.bin 131,072 65,607
xterm.css 7,112 2,502
index.html 5,221 2,477
guest-cmdline.mjs 2,420 1,242
5,525,275

Same page served with identity encoding: 74,790,787 bytes — and 67,108,864 of those bytes are the disk, which is 99.9% zeros because it is an empty ext2 the guest formats nothing on and fills itself (the seeding is the guest's, at boot). Disk included, the whole page is still ~7.7 MB of assets that are not the disk. For scale, a qemu-wasm route to the same guest is ~40 MB raw / ~10–13 MB compressed.

Caveat, stated not hidden: 169 bytes of that total is a single response chromium makes that the page's server does not serve (a UUID path; the server's own request log lists only the page's assets). Excluding it, the page's own assets total 5,525,106 bytes. vgabios.bin and v86-fallback.wasm are never fetched — the console is serial-only, and the fallback wasm is only requested if the primary fails to load.

The 64 MiB disk is the one number here that a reader should push back on: it is a repo and CI cost (and a 64 MiB commit), paid so that the browser lane boots the same disk image the native souche lane boots rather than a browser-shaped substitute. If that trade is ever judged wrong, the honest alternatives are a smaller store, or shipping the disk gzipped as a named artifact — not a disk the browser lane builds for itself.

Artifacts: committed, not release assets

Decision: the built artifacts and the third-party runtime assets are all committed to this repo and pinned by sha256 in SHA256SUMS. v86 and xterm.js are vendored, not fetched from a CDN.

Why:

  • The whole point of the repo is that git clone + serve + assert works. A release-asset route puts a network fetch and a hash check in the hot path of the demo, and a CDN route puts a third party in it. Vendored + committed means the clean-clone proof has no external dependency at all.
  • It costs 74.8 MB — of which 67.1 MB is the guest's disk image. This is the one place the original argument (6.6 MB, and therefore small) stopped applying, and it is stated rather than glossed: the disk is a 64 MiB, ~99.9% zero-filled ext2 that the guest fills in at boot, so it is a repo/CI cost paid for the property that matters — the browser lane boots the same three artifacts the native lane boots, byte for byte, from a clone with no fetch. The disk is also why serve.py gzips .img: on the wire it is 66,616 B. The fx-init project's convention of publishing its kernel as a hash-pinned release asset is still the honest alternative if this repo's size ever matters more than the clone-and-serve property; what is not acceptable is a browser lane that builds its own disk.
  • The pins are real anyway (SHA256SUMS, and sha256sum -c is part of the verification below), so anyone who prefers a release-asset mirror can build one from this repo without ambiguity about which bytes.

Revisions the shipped guest was built from

Component repos are not submodules here — the shipped artifacts are the anchors. web/bzImage and web/initrd.xz are pinned by sha256, and the tree below is the exact revision (all clean, committed trees this time — the earlier guest had to be captured from uncommitted working-tree patches in three of the six repos) observed at packaging time.

component revision tree at packaging shipped payload
fx-core 669187010053b8ea37f179756b06dad73043e150 clean REMOVED from the guest. The 63 static i386 binaries (fxsh + the fx-* stages) were the image's only un-provenanced payload — copied from a host bindir with no provides and no src_of, invisible to the store's own audit. The console shell returns as a package through the system dhall config.
fx-init 675e801cdb7441cc0abf35a8fcee10a7c6bfd468 clean fx-init (PID 1), fxctl, fx-activate
fxstore f7962b681c6405d539baf6f41e93bf26e5739f55 clean hash input only (never executes)
dhake 12dc4fbc477c089fbade2ad20372e2f71a624db0 clean dhake.com (materializes the rootfs)
dhall-c 565d728d147426e747a6e74f0ed09c53331053b1 clean hash input only
datalog-dafsa 59f7d855bd4d113e59798a0de92b9a2ad386661c clean hash input only

The store paths are content hashes of the source trees. The rdinit= hash in web/guest-cmdline.mjs (d41ef87c…-fx-init) is the hash of the fx-init tree this image was built from — a working tree that carried the disk-probe change, not the committed 675e801; the dhake store dir names the tree fx-init vendors (fx-init/vendor/dhake, eb16bcb) even though the shipped dhake.com is built from the sibling at 12dc4fb — the store names its inputs, it does not vouch for the binary you put in the directory. Re-running the provisioning against a different fx-init tree yields a different name, which is the store doing its job; image/build-store.sh prints the one to use.

The disk image (this image)

web/bzImage, web/initrd.xz and web/disk.img are the shared image — the same three artifacts the native souche lane boots under its own emulator, so that a difference between the lanes is a difference in the guest, not in the bytes. Built from the working trees at packaging time:

  • bzImage — kbuild/build-v86guest.sh (podman debian:stable): the pre-disk lean fragment plus ATA/ata_piix/sd/ext4/the crypto chain. The pre-disk kernel's fragment (build-lean.sh) is kept in-tree and still builds: it is what harness/control-ramfs.sh boots as the fail-before.
  • initrd.xz — image/build-store.sh + image/mkinitramfs.sh. NO busybox and NO fx-core: fx-init's disk migration (rm -rf / cp -a / mv / sync) is native in init.zig now (rm_rf/copy_tree/rename/syncfs_path), and harness/check-shell-free.mjs asserts the image stays shell-free — the pinned busybox the image used to carry had sh, ash AND hush (MEASURED), a live shell in a guest whose containment claim is "no sh, no bash, no busybox".
  • disk.img — PRE-FORMATTED at build time: busybox mkfs.ext2 -F on 64 MiB of zeros, on the host, with no privileges (MEASURED; a file-backed mkfs needs none — only MOUNT does). The guest FORMATS NOTHING: fx-init refuses a blank disk loudly instead. The first boot then seeds it by copying the ramfs store onto it natively.
  • fx-init is not a committed revision here: the store was activated from a working tree that carried the uncommitted disk-probe change. MEASURED in the shipped bytes: /dev/sda1 is in web/initrd.xz's fx-init — i.e. the binary really is the disk-probe tree (zig/src/init.zig's blob was b7bf3c07… when slice 3 built it, and still is), and its own control-channel messages are the newer pair quoted in the transcript (fifo control up (…) + no UNIX control socket (…)) where the pre-disk image printed the single older warning. The pins are the artifacts' sha256 in SHA256SUMS, which is the anchor this repo has always used for the store hash either way.

What is verified reproducible (all MEASURED this packaging):

  • every binary still in the shipped image is byte-identical to a fresh clean-tree build — fx-init, fx-activate, fxctl, dhake.com and fakesvc (each image copy vs the build of the commit in the table above); the 63 fx-core binaries are gone, so their 63 matches are gone with them.
  • image/mkinitramfs.sh produces a byte-identical initrd.xz from the same store — two consecutive runs give the same sha256 (that took the --reproducible delta, see docs/BUILD.md);
  • the store dirs are stable across repeated activate_paths runs against quiescent trees (two runs, identical closure).

Layout

web/                the page and everything it fetches (committed, no CDN)
  index.html          the page: v86 wiring; imports the CMDLINE
  guest-cmdline.mjs   THE command line (the rdinit hash lives here, once)
  bzImage             i386 linux-6.12.19 + ATA/ext4 (sha256 f0278a8f…)
  initrd.xz           the store + userland (sha256 d6eb1351…)
  disk.img            the guest's ATA disk: empty ext2, 64 MiB (sha256 a2f0f6da…)
  libv86.js v86.wasm seabios.bin         (v86 0.5.470, Apache/BSD 2-clause)
  xterm.mjs xterm.css                    (@xterm/xterm 6.0.0, MIT)
kbuild/             the kernel build recipe (podman, debian:stable)
  build-v86guest.sh config-i386-v86guest.txt   what the page ships (i386 + ATA)
  build-lean.sh config-i386-lean.txt     the pre-disk kernel (the control)
  build.sh      config-i386.txt          the earlier untrimmed variant
image/              the guest image
  build-store.sh      provision + activate a store from the i386 userland
  mkinitramfs.sh      assemble initrd.xz (fx-init's M4 builder, adapted)
harness/            boot / drive / assert
  serve.py            gzip-reporting static server (the download number's instrument)
  guest-image.mjs     reads facts out of initrd.xz (store dir, entry crc32+size)
  check-cmdline.mjs   the rdinit hash vs the archive — refuses a stale boot
  boot.mjs drive.mjs  v86 in node, serial to stdout / scripted sessions
  persist.mjs         the reboot-in-place persistence proof (node, headless)
  control-ramfs.sh    the fail-before: pre-disk bytes + the disk -> ramfs line
  assert.mjs          the Playwright proof
docs/               BUILD.md (rebuild), BOOT.md (the boot chain and its evidence)
evidence/           the clean-clone run: assert-output.txt, proof.png,
                    proof-serial.txt (the guest's 8250 capture), proof-png-vision-read.txt
                    (an independent vision-model read of the screenshot), image-contents.txt,
                    tmp-fix-before-after.txt (the /tmp assertion vs the PREVIOUS image)
licenses/           v86 (BSD-2-Clause) and xterm.js (MIT) license + package metadata
SHA256SUMS          every committed artifact, verifiable with `sha256sum -c`

Fixed since the previous image (the three items it listed as OPEN)

The previous guest shipped components built before these commits, so it reported all three of these as open. Each is now fixed upstream and in this image; the fix is quoted in the run above, and evidence/tmp-fix-before-after.txt is the assertion run against the previous image next to this one's.

  • Post-pivot there was no /tmp — FIXED (fx-init 675e801). The old pivot_root_to_tmpfs created /proc /sys /dev /run /fx /fx/disk /lib64 /usr /oldroot on the new tmpfs root and /tmp was not among them, so the tmpfs root — which replaces the initramfs layer that had it — had no /tmp at all. Nine fx-* stages mkdtemp a hardcoded /tmp/<stage>-XXXXXX template, so the guest answered ls /bin with error: Mkdtemp and cksum /tmp with cannot open '/tmp' + error: OpenFailed. init.zig now creates /newroot/tmp with mkdir(0o1777) and chmod(0o1777) (the umask masks mkdir's mode) — deliberately not folded into the shared dir list, which is 0755; the mode is the point. MEASURED in this guest: ls /bin now returns its five rows and ls /tmp lists the fx-ls-XXXXXX scratch dirs of the preceding commands (i.e. the directory is not merely present but used). That is exactly the pair of assertions added to harness/assert.mjs — this bug had no fireable regression test in its own repo (fx-init's console lane drives only seq | head, which needs no /tmp), so the demo's assertion is where one now lives.
  • The control-socket warning was uninformative — FIXED (fx-init 675e801). The behaviour it reports is unchanged and is still open (next section): this kernel has no socket layer. What is fixed is the line. It was fx-init: warning: control socket failed; it is now fx-init: warning: no control channel: /run/fx/control.sock (socket failed: Function not implemented — this kernel has no socket layer: CONFIG_NET is not set) and no virtio control port — fxctl cannot connect in this guest, naming the path, the failing step, the errno, the structural cause and the consequence. setup_ctrl now records the failing step and its errno (ctrlFail) instead of returning a bare -1. SUPERSEDED by the disk image: that line is what the pre-disk image's fx-init printed, and evidence/proof-serial.txt:75 is where it is pinned. The shipped fx-init is newer and prints fx-init: fifo control up (/run/fx/control.in + /run/fx/control.out) followed by fx-init: no UNIX control socket (…) — the fifo pair (…) carries the protocol for in-guest fxctl. Same defect, same errno, a newer tree that also grew the fifo transport; the disk image did not change it and this line is not asserted by assert.mjs in either form.
  • fxsh's unknown-stage error was unhelpful — FIXED (fx-core 6691870). The refusal itself is correct and stays (below); the message now says why: fx-shell: unknown stage '<name>' (not one of the 31 pipeline stages): a stage runs only when its command declares a typed signature, so the line can be typechecked before anything runs — fxsh does NOT execute arbitrary PATH binaries. The stage name has no 'fx-' prefix (the binary fx-ls is the stage ls). The error value is unchanged (error.UnknownCommand).

Open items (short, honest)

These are known and unfixed as of the shipped image. They are listed because a demo that hides them is not a demo of the real thing.

  • The control plane's UNIX socket cannot exist in this guest — the pinned kernel has no socket layer (still open; a kernel change, not a code one). fx-init serves the control protocol on a UNIX socket (/run/fx/control.sock, and the tests/fxinit_boot.sh lane drives it), but CONFIG_UNIX depends on CONFIG_NET, the configs are # CONFIG_NET is not set (kbuild/config-i386-v86guest.txt, as the older config-i386-lean.txt was), and with NET off the kernel stubs the whole socket layer, so socket(2) returns ENOSYS. The --enable UNIX the build script used to pass was dropped silently by olddefconfig — corrected in kbuild/build*.sh and documented in docs/BUILD.md. MEASURED in this guest: the errno is reported verbatim by fx-init (the line quoted above). It does not affect the boot verdict — the boot path never uses the control socket — and the shipped image adds a second transport that needs no sockets: the fifo pair (/run/fx/control.in + /run/fx/control.out), which fx-init's own line says is up. Not asserted: that in-guest fxctl (activate/rollback/ shutdown) actually completes a request over that fifo — nothing in this repo drives it, and assert.mjs does not claim it. The real fix for the socket route is a kernel rebuild with CONFIG_NET=y (i.e. a different, much larger kernel than the one this demo ships).
  • fx-init's control plane has no virtio-serial under v86 either. Separate from the above: with no virtio-console device in this guest there is no vport for the other control transport fx-init supports (the one tests/qemu_ctrl.sh drives), so neither channel exists here and the demo drives the console over the emulated 8250 instead.
  • fxsh is not a PATH shell, by design. It dispatches exactly its 31 fx-* pipeline stages and refuses everything else; /bin/fxctl is an unknown stage. 32 of the 63 shipped binaries are therefore unreachable from the console. That is the contract — a line runs only if it typechecks — not a defect: a PATH fallback would run commands whose stage-to-stage shapes were never checked. A module such as realpath takes its path operand from the pipeline (text_operand) — feeding it as argv is TooManyArgs — while cksum/cat accept a literal path. This is why the assertions use cksum /bin/fxctl.
  • Guest memory is fixed at 128 MiB and the initrd is placed at the 64 MiB mark by v86's bzImage loader, so the initrd must stay well under 64 MiB. It is 0.83 MB (no busybox, no fx-core payload).
  • The kernel build is not bit-reproducible — make embeds a timestamp. The kernel is pinned by sha256; see docs/BUILD.md.
  • The guest's disk writes live in the emulator's memory, not in the file. Neither v86 (the page, or harness/boot.mjs/persist.mjs) nor the native lane flushes the disk back to web/disk.img — MEASURED: the shipped disk.img is byte-identical after a run that seeded ~30 MB into it. So the persistence this repo proves is the strong, checkable one — across a reboot in place with the same buffer (harness/persist.mjs, and the page's __rebootInPlace()): fx-init's append-only ledger on the disk must still start with the exact bytes boot 1 wrote. What is not claimed is that a page reload preserves anything: a reload re-fetches disk.img and the guest boots the shipped bytes again, exactly as if the disk were fresh. Making a reload persist would mean writing the buffer back (a download/upload the demo deliberately does not do) — named here as the open item it is, not implied away.

Licensing

The fixpoint-linux components (fx-init, fx-core, fxstore, dhake) carry their own licenses in their own repositories. The vendored third-party assets keep theirs, reproduced in licenses/: v86 (BSD-2-Clause, 0.5.470) and xterm.js (@xterm/xterm 6.0.0, MIT). seabios.bin is SeaBIOS (LGPLv3), distributed by the v86 project as the emulator's firmware image; it is verified here against v86's published copy.

About

fixpoint-linux in the browser: fx-init + the content-addressed store + dhake + fxsh, booted under v86. ~3.75 MiB.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages