The web is not POSIX-compatible (i)

Week 5

Stop shimming one interface at a time

Week 3 ended at a wall. Week 4 ended at an interface.

The general answer to both is to stop answering calls individually and give the program a kernel-shaped surface to compile against — one target, one ABI, and the host implements it once for every guest.

This is the best-value option in the course. It is also the one with the subtlest failures, which is why it gets two weeks.

What a kernel-shaped surface owes a guest

Descriptors, and operations on them. A directory tree. Time. Randomness. Arguments and environment. A way to exit.

That list is small enough to be honest about, and it is deliberately smaller than POSIX. The question is not whether it matches. It is where it stops matching, and whether your program notices.

The standard surface: forty-six calls

Four of them are sockets: accept, receive, send, shut down.

Read that list again. You can use a socket somebody handed you. You cannot open one.

That is not an oversight, it is the capability model: a program gets what the host granted it at startup, and nothing it can name itself.

There is no working directory

No chdir. No getcwd.

A program that believes it has a current directory is talking to its own C library, which is keeping a string on its behalf and resolving against it.

Files are opened relative to a directory handle granted at startup. A program can only reach what the host decided to hand it, and an absolute path is not a thing the interface accepts.

What else is simply absent

MissingConsequence
fork/exec/waitno processes, so no shell
pipesnothing to connect two programs with
threadsone of everything
dup2you cannot rewire a descriptor
mode bitsnothing is executable, because nothing executes

Every row is the same loss restated: the program cannot start another execution context, or wire itself to one. Code that does its own work never notices. Code whose design is that arrangement — a shell, a build system, a test runner, anything that fans work out and collects it back — does not lose a feature here. It loses its architecture.

The extended surface

Roughly ninety-five more calls, as a strict superset: processes, threads, futexes, sockets you can open, a working directory, pipes, dup2, epoll, a terminal, dynamic loading.

Somewhere in that list a shell becomes possible, and once a shell is possible the entire Unix toolbox arrives at once.

That is the buy. Now the cost.

What it costs to have your own kernel ABI

You are no longer a consumer of a standard. You are a maintainer of an interface, with guests compiled against it, and every gap you fill is a gap you fill forever.

There is no conformance suite waiting for you. There is no second implementation to disagree with you.

Which brings us to the thing this week exists for.

A call can be present, and still be wrong.

What that looks like in practice

In one sysroot, the function that replaces a running program with a different one compiles to two instructions: a call to the host, then unreachable.

The import was declared as returning nothing. So the host’s “this failed, here is the error” was turned into “the process is exiting, with that number”.

A failed exec cannot report failure. It can only succeed, or end the program.

Why that destroys a shell specifically

Looking up a command by name is a loop in userspace. The C library tries each directory on the search path in turn until one works.

With a call that cannot fail, the first miss ends the process. The loop never reaches its second candidate.

Three measurements, and the shape falls out

InvocationResult
env /bin/printenvrc 0 — absolute path, one attempt, works
env printenvno output, rc 126 — the search died on the first miss
./script.shrc 45 — an errno value leaking as an exit code

That third row is the one that gives the game away. Forty-five is not an exit status anyone chose. It is Noexec, wearing a costume.

And one program that was immune

A shell that resolves the search path itself and then issues exactly one absolute exec never sees the bug.

So on a system where the shell works fine, every program that calls execvp is quietly broken, and the thing most likely to be in your smoke test is the thing least able to detect it.

How you would actually find it

Not by reading the ABI. By writing probes whose failure is discriminating.

A script starting #!/bin/sh tells you almost nothing: a shell falling back after ENOEXEC looks identical to real interpreter dispatch.

A script starting #!/bin/echo shebang-dispatched can only produce output one way.

Design probes that distinguish between the explanations, not probes that succeed.

Delivery, briefly

A container format with a magic number and a CBOR index, whose contents are content-addressed blobs shared between packages.

And multicall binaries — one executable that inspects argv[0] and becomes whichever tool it was invoked as. The Unix answer to “sixty programs, one download”, forty years old and newly relevant when the download is the expensive part.

In the session

You trace a real program and sort what it asks the kernel for into in preview1, in WASIX, and in neither — then over the top of that, the column this week added: present but unverified.

For each entry in that last one, write the probe that would settle it.

Then the question that carries the hour: which call would you most like to delete from the program’s requirements, and what would losing it cost the program?