Skip to content

Executables

How the kernel finds, loads, and runs programs. The relationship between files, functions, and the executable bit.

The Tension

fishbowl has two kinds of executables, mirroring real Unix:

  1. Native functionsBinFunction closures. Our platform is JavaScript, so our “machine code” is a function reference, not ELF bytes. These can’t be meaningfully serialized to bytes any more than you can serialize compiled x86 to source.
  2. Scripts — shell scripts, hypothetical python scripts, whatever. Text files stored as bytes in any fileserver. They need an interpreter.

The current implementation (BinFS) conflates these by wrapping native functions in a virtual filesystem. BinFS stores function references in a Map<string, BinFunction> and pretends they’re files — readdir() lists them, stat() reports them, read() serializes their source. A special resolve() method provides a fast path for the kernel to get the function back out.

This works, but it’s an inversion of the real Unix model. In real Unix, the filesystem doesn’t know what an executable is. It just stores bytes. The kernel interprets those bytes at exec time (ELF magic? shebang? error). BinFS puts the intelligence in the filesystem instead of the kernel.

What’s a File?

In our system, a “file” is whatever a Fileserver says it is:

  • MemoryFS filesUint8Array stored in memory. Ephemeral. No disk.
  • ProcFS files — projections of kernel state. Read-only views of live data.
  • DevFS files — streams (stdin, stdout, stderr). Reads/writes go to streams, not storage.

Files are the Fileserver protocol’s abstraction — named data accessible through open/read/write/stat/readdir. Nothing more.

A BinFunction is not byte content. But it can be a property of a file node — like how an ELF binary is bytes but also “something the kernel knows how to load.” In real Unix, a file’s bytes happen to be executable machine code that the kernel interprets. In fishbowl, a file node can carry a function reference that the kernel calls directly. The file still has byte content (for cat), a name (for ls), permissions (for chmod) — it just also has a bonus field that the kernel knows about.

This is not the same as BinFS, which pretended functions were files. Here, functions are attached to files. The file is real. The function is an extra property the kernel inspects at exec time.

What’s an Executable?

In real Unix, the delta between a regular file and an executable is one bit: the executable permission. chmod +x script.sh turns a text file into a program. The kernel checks the bit at exec time, then reads the file to figure out what kind of program it is (ELF header? shebang? error).

The same model works for fishbowl, with one addition for native functions:

  • A file with +x and an exec field — the kernel calls the function directly. This is how native commands work. The exec field is our equivalent of ELF bytes that the kernel knows how to load.
  • A file with +x and no exec field — the kernel reads the content. A shebang line (#!/bin/sh) tells it which interpreter to use. No shebang → ENOEXEC.

One resolution path. No fallback tiers. No separate lookup table.

Terminal window
# Script — lives in the filesystem as content
echo '#!/bin/sh
echo hello world' > /usr/local/bin/greet
chmod +x /usr/local/bin/greet
greet # kernel reads file, finds shebang, spawns sh
# Native function — lives in the filesystem as an exec node
echo # kernel finds file node at /bin/echo, sees exec field, calls it

Both coexist on the same PATH. The kernel dispatches based on what it finds on the file node.

The Real Unix Exec Model

In real Unix, the kernel’s exec path:

  1. Resolve the path (via PATH for bare names)
  2. Check permissions — is the file executable?
  3. Read the file header:
    • ELF magic bytes → native binary, load into memory and jump
    • #! shebang → read interpreter path, re-exec with interpreter
    • Neither → error (ENOEXEC)

The filesystem is uninvolved in this decision. It stores bytes. The kernel interprets them.

fishbowl Exec Model

How the Builder Registers Bins

When the builder processes Extension.bins, it writes file nodes into memoryFS at /bin/<name>. Each node is a real file with:

  • exec: theFunction — the native function reference
  • mode: 0o755 — executable permissions
  • content: Uint8Array — metadata comment (name, source extension) for cat
Builder:
1. Collects bins from all .use() calls
2. For each bin, creates a file node in memoryFS at /bin/<name>
- exec: the BinFunction reference
- mode: 0o755 (executable)
- content: metadata comment (for cat /bin/echo)
3. Done. No separate table.

These are real files with a bonus field. ls /bin lists them. stat /bin/echo reports them. cat /bin/echo reads their content. The kernel checks their exec field at exec time.

PATH Resolution

When the kernel resolves a command, there is one path — walk the filesystem:

exec("greet", argv):
1. For each directory in $PATH:
a. Stat the file at <dir>/<name>
→ not found? Continue to next PATH entry
b. Check permissions — is the file +x? (mode & 0o111)
→ not executable? Continue to next PATH entry
c. Check exec field on the file node
→ has exec? Call the function. Done.
d. Read file content, check for shebang (#!)
→ has shebang? Parse interpreter path, re-exec with interpreter
→ no shebang? Continue to next PATH entry
2. Nothing found → command not found

One resolution path. The filesystem is the single source of truth. No exec table, no two-tier lookup, no fallback.

Decision: no exec table — the filesystem IS the source of truth. In real Unix, a file’s bytes are the executable. In fishbowl, a file node’s exec field is the executable. Both are properties of the file, checked by the kernel at exec time. There is no separate registry, no dual-awareness problem, no stubs. The exec field is our platform’s equivalent of “these bytes are ELF machine code” — a kernel-level concern attached to a filesystem entity.

Decision: exec field is a kernel concern, not exposed through Stat. The exec field lives on the MemoryFS node, not on the Stat protocol type. User-facing tools see mode: 0o755 and file content. The kernel sees the exec field because it has access to the underlying node. This keeps the Fileserver protocol clean — Stat doesn’t need to know about function references.

File Permissions

The executable bit is the gate for all execution — both native functions and scripts. A file without +x is just data. A file with +x is a candidate for execution.

This requires the Fileserver protocol to support permissions:

interface Stat {
name: string
type: NodeType
size: number
mtime: number
mode?: number // permission bits (rwxrwxrwx), default: 0o644
uid?: number // owner, default: 0
gid?: number // group, default: 0
ctime?: number // metadata change time, default: mtime
}

The mode field follows Unix conventions. 0o755 means owner rwx, group rx, others rx — a typical executable. 0o644 means owner rw, group r, others r — a typical data file. The kernel checks mode & 0o111 (any execute bit set) during PATH resolution. When mode is absent (fileservers that don’t track permissions), the kernel defaults to 0o644 for files — not executable. See the inodes spec for the full Stat interface and INode data model.

Decision: mode on Stat, Unix-convention permission bits. Not just an executable: boolean — the full mode field gives us chmod 755, chmod 644, ls -l permission display, and future user/group permissions. We don’t need to enforce user/group semantics in v1, but the data model should support them. Default mode for new files is 0o644 (not executable). Default mode for new directories is 0o755.

Script Execution

Scripts are regular files with the executable bit set and no exec field. The kernel reads the first line to check for a shebang:

#!/bin/sh
echo "I am a shell script"

The kernel reads #!/bin/sh, resolves sh through the normal exec path (which finds it in /bin/sh as a node with an exec field), and spawns it with the script file path as an argument. This is exactly how real Unix handles shebangs.

This means any interpreter can be used — shell, a hypothetical python interpreter, anything available through the exec path. The shebang is the universal dispatch mechanism.

Decision: shebang recursion limit of 4. If a shebang points to another script with a shebang, the kernel follows the chain up to 4 levels deep (matching Linux’s BINPRM_BUF_SIZE behavior). Beyond that → ENOEXEC. This prevents infinite loops from circular shebangs.

Files Without Shebangs

What happens when the kernel encounters an executable file (+x) with no exec field and no shebang?

  • In real Unix: ENOEXEC. The shell may retry with /bin/sh as a fallback (POSIX behavior), but the kernel itself rejects it.
  • In fishbowl: same — ENOEXEC. No silent eval of raw JS source.

Decision: no implicit eval of filesystem content. An executable file must have a shebang (or an exec field). The kernel does not guess. This eliminates a security surface (arbitrary eval from filesystem content) and matches POSIX behavior. If a developer wants a JS-source executable, they write #!/usr/bin/env node-style shebangs pointing to a JS interpreter — or more practically in our system, they register native functions via the Extension API.

Write Semantics for /bin

What happens when a user writes to a path where a native function node exists?

Terminal window
echo '#!/bin/sh
echo "custom echo"' > /bin/echo
chmod +x /bin/echo

The write replaces the node entirely. The old node — including its exec field — is gone. The new node has content (the script) and mode (set by chmod), but no exec field. On next exec, the kernel finds the file, sees +x, finds no exec field, reads content, finds the shebang, dispatches to the interpreter.

The native echo is gone. Replaced by a script. Clean, predictable, exactly how Unix works — overwriting /bin/echo replaces the binary.

Terminal window
# Remove a native command entirely
rm /bin/echo # node is gone, exec field and all
echo hello # command not found
# Replace a native command with a script
echo '#!/bin/sh
echo "custom: $*"' > /bin/echo
chmod +x /bin/echo
echo hello # → custom: hello

Decision: overwriting a file replaces the entire node, including exec. A file node’s exec field is not sticky — it’s part of the node, not the path. When the node is replaced (by write or rm + create), the exec field is gone unless the new writer explicitly sets it. This is natural: in real Unix, cp script.sh /bin/echo replaces the ELF binary with a script. The old binary is gone. Same here.

Consumers of the Exec Model

Shell builtins that introspect command resolution need no special awareness — they just use filesystem operations:

BuiltinBehavior
whichWalk PATH, stat files, check +x. Report first match path.
typeSame resolution. Report the kind based on what’s found: native function (has exec) vs script (has shebang).
hashFuture — cache resolved paths to skip PATH walking. Bash does this.
Tab completionScan directories in PATH, list executable files. Pure filesystem operation.

No dual awareness needed. No “check exec table then check filesystem.” Just check the filesystem.

What This Replaces

BinFS as a separate fileserver type goes away. Its responsibilities all live in the filesystem:

BinFS responsibilityNew home
Store function referencesexec field on file nodes in memoryFS
resolve(name) → BinFunctionKernel checks exec field on the file node during PATH resolution
readdir() for ls /binReal directory entries in memoryFS
stat() for which/typeReal file nodes in memoryFS
read() for cat /bin/echoReal file content (metadata) on the node
write() for dynamic bin creationWrite script + chmod +x, or pkg install writes exec node

No stubs. No projection. No separate table. The filesystem is the single source of truth.

Dynamic Bin Creation

Two paths for creating new commands at runtime:

Scripts (primary)

Write a script file with a shebang and set +x. No special mechanism needed — this is how Unix works.

cat > /usr/local/bin/greet << 'EOF'
#!/bin/sh
echo "hello, $1"
EOF
chmod +x /usr/local/bin/greet
greet world # → hello, world

Package Installation

pkg install can write exec nodes into memoryFS (via the Extension API) — file nodes with exec fields and mode: 0o755. This is how new native commands enter the system at runtime — through the same mechanism the builder uses at boot. The builder writes exec nodes; package install writes exec nodes. Same operation.

Summary

The exec model unifies what BinFS split across two abstractions:

  1. Storage — native functions live as exec fields on file nodes in memoryFS. Scripts live as byte content on file nodes in memoryFS. Both are properties of real files in real directories.
  2. Resolution — PATH resolution walks directories, stats files, checks +x, checks the exec field, checks for shebangs. One path, one source of truth.
  3. Inspectionls, which, type, tab completion, globs — all pure filesystem operations. No dual awareness, no special-casing for native functions.

The executable bit (chmod +x) is the universal gate. The exec field is the native function indicator. The shebang is the script dispatch mechanism. These three concepts, all on file nodes in the filesystem, cover the full exec model with no separate tables, no stubs, and no projection layers.