loom
Luce LuciaOS

Running programs

There are two ways to start a Luce program under loom, and they differ in exactly one thing: whether a compiler has to be involved.

FormWhat happens
loom run FILE.lc [ARGS]One dlopen, one symbol lookup, one call
loom luce FILE.luc [ARGS]Compile if there is not already a current artifact, then the above
loom FILE.lc [ARGS]Shorthand for run
loom FILE.luc [ARGS]Shorthand for luce

Arguments after the path are the program's, never loom's. They arrive as main's args: list(string) parameter — handed to the program rather than asked for by it, which is why the host gate does not cover them and why the list is owned by main's scope and freed when it returns.

A .lc is machine code

luce build writes a tagged shared library and calls it .lc. It is not bytecode, not serialized IR, and not a script. Nothing about running it is a translation step:

Shell
$ time loom run hello.lc
hello, loom
loom run hello.lc  0.00s user 0.00s system  0.002 total

Two milliseconds, on the machine this was measured on, almost all of it the operating system's process setup. loom deliberately links no LLVM: libLLVM is 164 MB, the dynamic loader binds all of it before main, and that cost every single invocation — including the ones that compile nothing at all. So the compiler links it and the environment does not.

Every artifact carries a tag

An artifact records four things besides the program: the machine it was built for, the host ABI version it was built against, the identity of the code generator that produced it, and a content hash of the program itself. loom reads that tag before it calls anything, and a file that does not match is refused by name:

loom saysMeaning
it is not a compiled Luce artifactThe file is something else
its tag is a layout this loader cannot readThe tag format itself moved
it was built against a different host ABIThe published LuceHost table changed under it
it was built for a different machineAnother architecture or another operating system
it was built by a different code generatorA rebuilt or different compiler produced it
the program it was built from has changedThe source moved; the cache entry is stale

There is no fallback and nothing to fall back to. Refusing by name is the whole of the answer: a .luc needs a compiler exactly as a .c does, and an artifact that cannot be trusted is not run slower, it is not run.

Shell
$ loom run bad.lc
Standard error, exit 1
loom: cannot load bad.lc: it is not a compiled Luce program this machine can run

The platform loader says only "no", never why, so loom makes the one distinction worth making itself: a file that is not there and a file that is not a program are different mistakes, and only the second is about the program.

Standard error, exit 1
loom: cannot read nope.lc: no such file

Compiling a .luc, and where the artifact goes

loom luce FILE.luc compiles the file — and the sibling modules it imports, resolved beside it — and runs the result. The artifact is written beside the source, as FILE.lc: the same name luce build FILE.luc would write, so a build can ship one and loom simply finds it.

Shell
$ time loom luce hello.luc
hello, loom
loom luce hello.luc  0.05s user 0.03s system  0.140 total
$ time loom luce hello.luc
hello, loom
loom luce hello.luc  0.00s user 0.00s system  0.003 total

The first run compiled and linked; the second found the artifact and called it. A warm run invokes nothing external. A cold one runs luce once, which runs the linker once.

The cache is keyed on content, not on time

The artifact carries a hash of the program it was built from, so a source file whose bytes changed gets a rebuild whatever the clock says about either file — and a source file that was touched but not changed does not. There is no timestamp comparison anywhere in this, and so no way for a restored backup, a copied tree or a clock skew to hand you the wrong program.

What loom hands the compiler is not the source file. It is the serialized module loom's own front end just produced from that source — the compiler's internal hand-over form — so the artifact is keyed to the exact program that is about to run rather than to whatever a second reading of the same text would have produced. That file is written beside the artifact and removed again, on the failing path as much as the succeeding one: it is a hand-over, never a deliverable, and one left behind in your directory after a failed build is litter you did not make.

When there is nowhere to write beside the program

A read-only directory, or a program with no path at all — the editor loom carries inside itself — has nothing to sit beside. The artifact then goes to a directory named luce-artifacts inside TMPDIR, keyed on the hash because there is no name to key on.

That directory is created rwx------ and is used only if what is really there is a directory with no group or other permissions at all; a symbolic link where it should be is refused rather than followed. The reason is not tidiness. A cached artifact is a file loom dlopens, and its name is the program's content hash — a name anyone holding the same program can compute. On a shared /tmp, that file can be planted before loom ever looks, and the sticky bit does not help: it stops one user replacing another's file and stops nobody from creating it first. A loom that recompiles every run is slow; a loom that runs somebody else's code is not loom.

Nothing is ever swept from that directory, deliberately. The entries are content-keyed, so a stale one is never loaded — it is refused by its tag and rebuilt over the same name — and a sweep would need an age policy, a lock, and a way to know that no other loom is about to open the file it is deleting. Reaping a temp directory is the operating system's job. rm -rf on it is always safe and costs one recompile.

When a program stops

A run ends in one of five ways, and loom says which in words and in the number a shell reads afterwards. The screen is restored before anything is reported, so a full-screen program that trapped leaves its message on the ordinary terminal rather than inside a frame that is being torn down.

A trap is a bug

A debug artifact — the default — carries source locations, so a trap reports file:line:column and a call trace, innermost first.

boom.luc
func main():
    let numbers = [1, 2, 3]
    print(string(numbers[7]))
Standard error, exit 1
loom: trap: index out of bounds [index_bounds]
    at main (boom.luc:3:5)

The code in brackets is stable and is the thing to match on. A trace prints at most twelve innermost frames and counts the rest, so a runaway recursion shows its shape rather than its length. --release strips the position tables; the function names still print, and the program behaves identically — safety is the language, never a build mode.

An uncaught error is news

A trap and a recoverable error that nobody handled are different sentences about a program, and they get different words and different numbers, so a script can tell them apart without parsing standard error. An error prints where it was raised and no stack: the stack is a trap's diagnosis, and an error's news is what the world said.

news.luc
import std.files

func main() -> !:
    let text = try files.read("missing.txt")
    print(text)
Standard error, exit 3
loom: error: cannot read missing.txt [io_failed]
    raised in files.read (std/files.luc:26:5)

A program may choose its own number

exit(status) is the fourth way a run can end, beside finishing, trapping and raising: nothing is wrong and nothing failed, the program chose to stop and chose the number. loom carries it out — on POSIX, as the low eight bits of its own exit code.

stop.luc
func main():
    print("checked and closing")
    exit(7)
Standard output, exit 7
checked and closing

The exit table

One table, because a program's behaviour must not depend on who started it — and that includes the number a shell reads afterwards. loom run and a standalone --emit=exe binary answer from the same source.

StatusMeaning
0The program finished
1It trapped — and also loom's own refusals: no such file, not a program this machine can run, a compile that failed
3It ended on an uncaught error
70Out of memory: the machine ran out, not the program
71The run could not be carried out, or its output could not be delivered
anything elseThe program called exit with it

The last two are not about the program at all, which is why they take sysexits numbers well clear of anything a program means. 71 covers a case that is easy to lose: output that could not be written did not happen. A closed or full pipe loses the tail of a program's transcript, and a runner that returned 0 would be claiming it arrived — so the failure is said once and counted in the status.

When loom cannot compile

loom carries no code generator, so a cold loom luce needs the luce binary. It is looked for beside loom's own executable first and on PATH after — and only on a cold run, because looking for it is work a warm run must not do. A loom that cannot find one says so, rather than doing something slower and calling it success:

Standard error, exit 1
loom: cannot compile sums.luc: the `luce` compiler is not beside /opt/luce and not on PATH

A compile that failed prints the compiler's own diagnostics, and a program the back end has no lowering for is refused rather than retried somewhere else — the next directory would say the same thing.

What loom reads from the environment

VariableEffect
PATHWhere luce is looked for, after the directory loom's own binary sits in
TMPDIRWhere an artifact goes when it cannot go beside its program; /tmp when unset
NO_COLORSet to any value and the shell prints no escape sequences
LOOM_EDITORA .luc file to use in place of the built-in editor

Those are read once, at start, because they are process policy rather than anything a program or a command can change. Everything else the compiler needs (LUCE_CC to name a C compiler driver, LUCE_LIB the directory holding libluce_rt.a) it reads from the environment it inherits, so loom neither parses nor forwards it.