lib/os.bx
The machine the program is running on.
use "lib/os.bx";
Arguments, the environment, the clock, the process itself, and running a command.
This header has now been wrong about the same thing twice, which is worth more than either correction. It first ended “anything returning a pointer is absent”, false from v0.0.196 when getenv and getcwd began returning CPointer. It was rewritten to say “what is still absent is a struct behind a pointer — uname(2) fills one, nanosleep takes one — because c_bytes_at reads C’s memory and nothing in Burxt writes it.” That is false too, since c_bytes_to. Something in Burxt writes C’s memory now, and lib/net.bx builds a sockaddr_in with it.
So os_platform shelling out to uname and os_sleep calling usleep are no longer forced by the language. They are merely what is written, and each of those two is now a small job rather than a wall. A stale limitation is worse than a stale DONE, because nobody re-tests the thing that “doesn’t work” — this file has proved that twice and gets one more sentence for it: the reason a workaround exists stops being true before the workaround does.
What is in it
| Name | Kind | What it answers |
|---|---|---|
Capture |
class | Everything one command left behind: what it printed, what it complained about, and how it ended. |
os_arg_count |
function | The arguments the program was started with. Index 0 is the program’s own path, as it is everywhere else. |
os_arg |
function | — |
os_args |
function | Every argument after the program’s own name. |
os_now |
function | Seconds since 1970. Whole seconds, because that is what time answers — a finer clock needs clock_gettime, which fill |
os_run |
function | Run a command through the shell and answer its exit code. |
os_exit_code |
function | A wait status turned into the number a shell would report. |
os_capture_status |
function | Run a command and answer what it printed on standard output and standard error separately, with its exit code. Roadm |
os_capture |
function | Run a command and answer what it printed, standard output and standard error **merged in the order the process wrote the |
os_read_byte |
function | One byte of standard input, or -1 at the end. The whole of the input, a byte at a time, is what a program can do today — |
os_read_line |
function | One line of standard input, without its newline, or None at end of input. |
os_read_all |
function | — |
os_env |
function | An environment variable, or a stated absence. |
os_env_or |
function | The same question with a stated fallback, for the common case where a missing setting has a sensible default. Separate f |
os_set_env |
function | Set an environment variable for this process and every child it starts afterwards. |
os_pid |
function | The process’s own id. |
os_cwd |
function | The directory the process is running in, or a stated absence. |
os_platform |
function | Which operating system this is: "linux", "macos", or uname -s lowercased. |
os_sleep |
function | Wait, and let the rest of the machine get on with it. Roadmap §D1r. |
os_trim_ascii |
function | Spaces, tabs, carriage returns and newlines off both ends. Local rather than lib/string.bx’s string_trim, for the re |
os_is_space |
function | — |
os_fork |
function | Splits this process in two. Answers 0 in the child and the child’s pid in the parent. |
os_wait_for_child |
function | Waits for any child to finish and answers its pid, or -1 when there are none left. |
os_flush |
function | Empties every output buffer. Answers whether it worked. |
os_env_missing |
function | A null CPointer, for the C calls that want one. |
os_rlimit_as |
function | The resource NUMBERS differ between Linux and the BSDs, and only RLIMIT_CPU agrees. |
os_rlimit_nofile |
function | — |
os_rlimit_nproc |
function | — |
os_set_limit |
function | struct rlimit { rlim_t rlim_cur; rlim_t rlim_max; } — two 64-bit values, sixteen bytes, handed over by pointer. Writab |
os_limit_cpu |
function | CPU seconds. The one limit whose resource number is the same everywhere. |
os_limit_memory |
function | Address space, in bytes. Past it, malloc answers null rather than the kernel killing anything. |
os_limit_files |
function | Open file descriptors, and child processes. Both bound what a runaway can take from the machine rather than from itself |
os_limit_processes |
function | — |
os_die_after |
function | Wall-clock, which is the one RLIMIT_CPU cannot do. SIGALRM’s default action ends the process, so this is a hard ce |
Types
Capture
class Capture { output: String, errors: String, code: Int }
Everything one command left behind: what it printed, what it complained about, and how it ended.
Three fields rather than three functions, because running the command three times to ask three questions would run it three times — and a command with an effect is not a question you may ask twice.
Functions
os_arg_count
function os_arg_count() -> Int touches input
The arguments the program was started with. Index 0 is the program’s own path, as it is everywhere else.
os_arg
function os_arg(index: Int) -> String touches input
os_args
function os_args() -> [String] touches input
Every argument after the program’s own name.
os_now
function os_now() -> Int touches clock
Seconds since 1970. Whole seconds, because that is what time answers — a finer clock needs clock_gettime, which fills a class through a pointer, and that is exactly what Burxt will not let C do yet.
os_run
function os_run(command: String) -> Int touches commands
Run a command through the shell and answer its exit code.
os_exit_code
pure function os_exit_code(status: Int) -> Int
A wait status turned into the number a shell would report.
This used to be divide_floor(status, 256) and that lost a whole category of failure. A command killed by a signal has an exit status of zero in its high byte, so a program terminated by SIGSEGV or SIGKILL — or by the timeout that was supposed to bound it — reported success. The low seven bits are the signal, and sh reports 128 + signal for exactly this reason, so that is what this answers: 137 for SIGKILL, 139 for a segfault.
-1 when the command did not run at all, which is what system answers when the fork failed. It is the one value no exited process can produce.
os_capture_status
function os_capture_status(command: String) -> Capture touches commands, files, input
Run a command and answer what it printed on standard output and standard error separately, with its exit code. Roadmap §D1q.
os_capture below merges the two with 2>&1, and merging is destructive: a caller that wants the output of git rev-parse cannot tell the hash from the warning that came with it, and a caller checking whether anything went wrong has nothing to check. Both streams get their own file here, in a directory nobody else can enter.
What is lost by separating them is the interleaving. 2>&1 puts both streams in one file in the order the process wrote them; two files cannot say which line came first. That is the trade, and os_capture is still the right call when the order is the thing you want to see.
A command that could not be run at all — no private scratch directory — answers empty strings and -1, the same code os_run uses for a command that never started.
The command is wrapped in ( ... ), and os_capture had been silently wrong for want of it. A redirection binds to ONE command, so sh -c "echo a; echo b > f" writes only b to the file and prints a to the terminal the program is running in. Every captured command containing a ;, a && or a || — which is most of the interesting ones — had all but its last piece escape the capture and land on the caller’s own output. Found by giving this function a two-statement command in its first test, which is the case nobody had written.
os_capture
function os_capture(command: String) -> String touches commands, files, input
Run a command and answer what it printed, standard output and standard error merged in the order the process wrote them. The output travels through a file because a pipe would mean popen, which answers a pointer whose lifetime Burxt would have to reason about.
Kept beside os_capture_status rather than replaced by it: interleaving is a real thing to want, and it is the half two separate files cannot reconstruct. What this cannot tell you is whether the command succeeded — os_capture_status is the one to reach for then.
§B3: the scratch path used to be the constant /tmp/burxt-os-capture. Two copies of a program overwrote each other’s output, and anyone on the machine could leave a symlink on that name for the redirect to truncate. It is now a fresh 0700 directory per call.
The ( ... ) is not decoration — see os_capture_status above for the capture this function was losing without it.
os_read_byte
function os_read_byte() -> Int touches input
One byte of standard input, or -1 at the end. The whole of the input, a byte at a time, is what a program can do today — fgets needs a buffer it does not own.
os_read_line
function os_read_line() -> Option<String> touches input
One line of standard input, without its newline, or None at end of input.
The distinction from os_read_all is the whole reason this exists: os_read_all blocks until EOF, which never arrives for a SERVER. A protocol that frames its messages one per line — MCP over stdio, and most of the others — needs to answer the first request before the client has sent the second, so reading to EOF is not a slow version of this, it is a deadlock.
A bare \r before the newline is dropped, so a CRLF client and an LF client are read identically. Nothing else is stripped: a line is its bytes.
None at end of input rather than an empty String, because an empty LINE is a real thing a client can send and the two must be distinguishable. That is the same reason string_parse_int exists beside string_to_int.
os_read_all
function os_read_all() -> String touches input
os_env
function os_env(name: String) -> Option<String> touches input
An environment variable, or a stated absence.
Option<String> and not String, because unset and empty are different facts. FOO= sets FOO to the empty string; not mentioning FOO at all is a different thing, and a library that answered “” for both would make “is this configured” unanswerable. getenv distinguishes them by returning NULL, and this is where that distinction is preserved rather than flattened.
touches input because the value came from outside the program. Whether reading the environment deserves an effect of its own is an open question — see spec/FAR-HORIZON-ROADMAP.md M2 — but input is honest today: it is a value the process was started with.
os_env_or
function os_env_or(name: String, fallback: String) -> String touches input
The same question with a stated fallback, for the common case where a missing setting has a sensible default. Separate from os_env rather than a parameter with a default, because Burxt has no default arguments — and because the two really are different questions.
os_set_env
function os_set_env(name: String, value: String) -> Bool touches input
Set an environment variable for this process and every child it starts afterwards.
Overwrites, always. setenv’s third argument chooses, and a library that exposed the choice would make os_set_env a question rather than an instruction; a caller who wants “only if unset” has os_env right above and can say so in a line a reviewer can read.
false when the name is unusable — empty, or containing =, which is setenv’s own refusal — or when the allocation for the copy failed. It does not change the environment of the shell that started the program: no process can do that, and it is worth saying because it is the first thing people expect this to do.
touches input and not an effect of its own. The environment is process state that Burxt has no effect for, and input is the one that already names it — os_env reads through the same effect, and reading and writing the same place should not be filed apart. Whether process state deserves its own effect is spec/FAR-HORIZON-ROADMAP.md M2, the same open question os_env points at.
os_pid
function os_pid() -> Int touches input
The process’s own id.
Small, and reused by the kernel once the process is gone, so it is a fine way to keep two concurrent programs from choosing the same scratch name and not a secret. lib/files.bx puts it in a temp directory’s name for the first reason and relies on mkdir for the second — see §B3 there, which is the row this function closed.
os_cwd
function os_cwd() -> Option<String> touches files
The directory the process is running in, or a stated absence.
None when the path does not fit in OS_PATH_MAX, or when the directory has been deleted out from under the process — a real thing that happens to long-running programs, and the reason this answers Option rather than "".
The buffer is this function’s: getcwd fills memory the caller owns, so nothing here has to free something C allocated, and c_string_at copies the bytes out before the free.
os_platform
function os_platform() -> String touches commands, files, input
Which operating system this is: "linux", "macos", or uname -s lowercased.
It shells out, one uname per call, and a caller asking in a loop should ask once. There is no cheaper answer available: nothing in the language exposes the target it was compiled for, and uname(2) fills a struct through a pointer, which is the wall Burxt still has.
Two decisions worth stating. darwin comes back as "macos", because that is the name the roadmap, CI and every caller use, and a library that made everyone remember the kernel’s name would be pedantry with a cost. Everything else comes back as what the machine said, in lower case, rather than "unknown" — a FreeBSD box answering "freebsd" is legible, and answering "unknown" throws away the one fact that was available.
Empty when uname is not on the PATH, which is a stated failure rather than a guess.
os_sleep
function os_sleep(milliseconds: Int) -> Bool touches clock
Wait, and let the rest of the machine get on with it. Roadmap §D1r.
Every retry loop, every poll of a file that another process is writing, and every backoff in this language spun the CPU flat out before this existed, because there was no way to yield.
usleep and not nanosleep, and the reason is the C boundary rather than taste. nanosleep(const struct timespec *, struct timespec *) takes a two-field struct BY POINTER, and Burxt can hold a pointer but cannot build a struct behind one: c_bytes_at reads C’s memory and nothing writes it. poll(NULL, 0, ms) is the other usual trick and needs a null pointer literal, which the language has no way to spell. sleep(3) takes whole seconds, which is not what a poll loop needs. That leaves usleep, which is obsolescent in POSIX.1-2008 and present everywhere in practice — and if it ever is not, this is the one function that changes.
usleep’s argument must be under one million, so a longer wait is sliced into 900 ms pieces. That is the loop below, and it is the whole of it.
A signal cuts the wait short and answers false, having slept less than asked. It is not retried, because “sleep at least this long” and “return when something happened” are different intents and only the caller knows which one it had.
os_trim_ascii
function os_trim_ascii(text: String) -> String
Spaces, tabs, carriage returns and newlines off both ends. Local rather than lib/string.bx’s string_trim, for the reason the whole of this file is: use "lib/os.bx" should pull in the operating system and not two thousand lines of string handling.
os_is_space
function os_is_space(b: Int) -> Bool
os_fork
function os_fork() -> Int touches commands, input
Splits this process in two. Answers 0 in the child and the child’s pid in the parent.
It flushes first, and that is not tidiness — it is a correctness bug removed rather than documented. print goes through C’s stdio, which is fully buffered when stdout is a pipe or a file rather than a terminal. fork copies the process including that buffer, so anything printed and not yet flushed is printed again by every child. A pre-forked server that announced “listening on 18080” once printed it four times with three workers — on a terminal it looked perfect, because a terminal is line-buffered, and it only misbehaved when the output was redirected. That is the shape of defect this language exists to refuse, so os_fork empties the buffer before it splits and the caller never has to know.
fflush(NULL) flushes every open stream, which is what a fork wants — stdout and stderr both. The null pointer comes from os_env_missing, because the language has no literal for one.
The child must not fall out of the bottom of the caller’s loop. A child that keeps looping forks again, and a program that forks in a loop it never leaves is a fork bomb. Every use of this looks like if os_fork() == 0 { ...work...; return; }.
os_wait_for_child
function os_wait_for_child() -> Int touches commands, input
Waits for any child to finish and answers its pid, or -1 when there are none left.
The exit STATUS is discarded, and that is a limit with a name rather than an oversight: waitpid reports it by filling an int the caller supplies, and reading it back means c_bytes_at on four bytes plus the WIFEXITED/WEXITSTATUS bit-twiddling that C hides in macros. Reachable now that c_bytes_to exists — every piece is here — and not yet written. os_wait_for_child_status is what it would be called.
os_flush
function os_flush() -> Bool touches input
Empties every output buffer. Answers whether it worked.
print is buffered, so output written just before a crash, a fork or an exec can be lost or duplicated. os_fork calls this for you; a program that hands its stdout to something else mid-run wants it directly.
os_env_missing
function os_env_missing() -> CPointer touches input
A null CPointer, for the C calls that want one.
getenv of a name nothing sets answers NULL — POSIX guarantees it, and os_env above already depends on exactly that to tell “unset” from “empty”. It reads as a trick and it is the only spelling the language has: CPointer has no literal, deliberately, because a literal address is the one thing the pointer wall exists to refuse.
The header above is the reason this is worth a paragraph. The absence of a null pointer was written down as a fact and used to justify choosing usleep over nanosleep. It was never a fact about the language, only about the syntax, and nobody tried the four-line workaround for long enough that it became load-bearing.
os_rlimit_as
function os_rlimit_as() -> Int touches input
The resource NUMBERS differ between Linux and the BSDs, and only RLIMIT_CPU agrees.
Linux macOS/BSD
CPU 0 0 <- the only one that matches
FSIZE 1 1
DATA 2 2
STACK 3 3
CORE 4 4
AS 9 5
NPROC 6 7
NOFILE 7 8
Measured from bits/resource.h on this machine and from the BSD header, because this is the third time in one week that a small C struct or constant has turned out to differ by platform and the first two were reasoned about wrongly. lib/net.bx’s sockaddr_in is the same shape: a layout that is obvious, identical-looking, and not the same.
So the numbering is asked of the kernel rather than assumed. RLIM_NLIMITS is 16 on Linux and 9 on the BSDs, so resource 9 is valid on Linux and out of range everywhere else — getrlimit(9, ...) succeeding is the kernel saying “Linux numbering” in its own words. That is a positive answer rather than an inference from a failure, which is the distinction that cost a CI runner two hours when net_uses_bsd_sockaddr asked the question the other way round.
os_rlimit_nofile
function os_rlimit_nofile() -> Int touches input
os_rlimit_nproc
function os_rlimit_nproc() -> Int touches input
os_set_limit
function os_set_limit(resource: Int, value: Int) -> Bool touches commands
struct rlimit { rlim_t rlim_cur; rlim_t rlim_max; } — two 64-bit values, sixteen bytes, handed over by pointer. Writable since c_bytes_to; before it, none of this file existed.
Both fields are set to the same value, which is deliberate: raising a limit later needs privilege a sandboxed child does not have and should not be given. A limit you can undo is not a limit.
os_limit_cpu
function os_limit_cpu(seconds: Int) -> Bool touches commands
CPU seconds. The one limit whose resource number is the same everywhere.
The kernel sends SIGXCPU at the soft limit and SIGKILL at the hard one; both are set here, so a program that ignores the first does not get to ignore the second. It counts CPU time, not wall-clock — a child that sleeps for an hour spends no CPU and this will not stop it. That is what os_die_after is for, and the two are not alternatives.
os_limit_memory
function os_limit_memory(bytes: Int) -> Bool touches commands, input
Address space, in bytes. Past it, malloc answers null rather than the kernel killing anything.
This is the one that bounds a Burxt program’s arena, which reserves its region up front — so a limit below the reservation means the program fails to start rather than failing partway, and that is the better failure.
os_limit_files
function os_limit_files(count: Int) -> Bool touches commands, input
Open file descriptors, and child processes. Both bound what a runaway can take from the machine rather than from itself — a program that cannot fork cannot fork-bomb.
os_limit_processes
function os_limit_processes(count: Int) -> Bool touches commands, input
os_die_after
function os_die_after(seconds: Int) -> Int touches clock
Wall-clock, which is the one RLIMIT_CPU cannot do. SIGALRM’s default action ends the process, so this is a hard ceiling on elapsed time whether the program is computing, sleeping, or blocked on a socket that will never answer.
The timer does NOT survive fork, so a child that needs one must set its own. That is not a wart to work around — it is what lets a pre-forked server give each worker its own deadline.
tests/pass/net_loopback.bx calls this on itself for exactly that reason, after blocking a CI runner for a full hour twice.