diff --git a/bend2/base.bend b/bend2/base.bend index d7d118e54..bf4342a03 100644 --- a/bend2/base.bend +++ b/bend2/base.bend @@ -123,6 +123,7 @@ law Chan: type IO.OP<-R: Type> is Type: Emit{value: R} Halt{code: U32, message: String} + Exit{code: U32} type App<-S: Type> is Type: App{view: S -> Pair(S, Image), tick: List -> S -> IO(Maybe)} @@ -195,6 +196,9 @@ def Process.run(program: String, args: List, input: String, def IO.die(-A: Type, code: U32, msg: String) -> IO(A): R => k => Halt{code, msg} +def IO.exit(code: U32) -> IO(Unit): + R => k => Exit{code} + def IO.pass(-A: Type, r: Result<&1, &1, U32 & String, A>) -> IO(A): match r: case Done{value}: diff --git a/bend2/comp.ts b/bend2/comp.ts index ff0ca30fb..fa3f69f89 100644 --- a/bend2/comp.ts +++ b/bend2/comp.ts @@ -1162,8 +1162,7 @@ function lits_cond(w: string, j: number, n: number): string { return j >= 32 ? `${w} == ${n}` : `(${w} & ${2 ** j - 1}) == ${n}`; } -// An IO.OP match keeps its default arm: a request is none of its arms, so -// it fail-stops, and a user's default arm ("_") takes only Emit and Halt. +// An IO.OP default accepts its constructors, never a foreign request. function mat_ctrs(x: HTerm, adt: Of<"ADT">): [Name, HTerm][] { const { arms, end } = mat_arms(x); const io = adt.k === "IO.OP"; @@ -1174,7 +1173,7 @@ function mat_ctrs(x: HTerm, adt: Of<"ADT">): [Name, HTerm][] { } function mat_ops(eq: (k: Name) => string): string { - return ["Emit", "Halt"].map(eq).join(" || "); + return ["Emit", "Halt", "Exit"].map(eq).join(" || "); } // Fun @@ -5889,9 +5888,11 @@ static void io_step(Env e, IoWork* a) { } Term fs[256]; u32 n = cid_arity(c); - spare_free(e, cls_fit(n), ctr_take(e, req, n, fs)); - if (c == CID(Halt)) { - io_errs(e, fs[1]); + if (term_tag(req) == TAG_PAK) fs[0] = term_loc(req); + else spare_free(e, cls_fit(n), ctr_take(e, req, n, fs)); + if (c == CID(Halt) || c == CID(Exit)) { + if (c == CID(Halt)) io_errs(e, fs[1]); + if ((u32)fs[0] > 255) err_fail("exit code must be in 0..255"); exit((int)(u32)fs[0]); } if (io_eff_rows[c] == NULL) { @@ -6383,8 +6384,12 @@ function io_run(m) { io.live -= 1; break; } - if (op.$ === "Halt") { - io_errs(op.message); + if (op.$ === "Halt" || op.$ === "Exit") { + if (op.$ === "Halt") io_errs(op.message); + if (op.code > 255) { + io_errs("bend: exit code must be in 0..255"); + return 1; + } return op.code; } const run = $0eff[op.$]; diff --git a/bend2/docs/BendRT/main.typ b/bend2/docs/BendRT/main.typ index 548d7a558..a64d9acc7 100644 --- a/bend2/docs/BendRT/main.typ +++ b/bend2/docs/BendRT/main.typ @@ -505,14 +505,16 @@ device), and the harness checks that all three print the same bytes. = Effects `IO(A)` is a definition of the base library, not a primitive: a -continuation over one datatype `IO.OP` with two constructors, `Emit` -and `Halt`. A _foreign fill_, a def whose body is `import` lines naming +continuation over one datatype `IO.OP` with three constructors, `Emit`, +`Halt` and `Exit`. A _foreign fill_, a def whose body is `import` lines naming a `.c` and a `.js` file, compiles to one segment that packs its arguments and its continuation into a request. The event loop runs on one thread: it evaluates `main` to a request through the pure machine, runs the effect's C handler, applies the continuation to the answer, -and evaluates again; `Emit` ends the program and `Halt` exits with its -code. Effects never run inside an evaluator, and the machine performs +and evaluates again; `Emit` ends the program, `Halt` prints its message +and exits with its code, and `Exit` exits with its code silently. A code +outside 0..255 exits with 1 and an error. Effects never run inside an +evaluator, and the machine performs no IO. A fallible operation answers a `Result` carrying errno, and a handle threads back outside the `Result`, so even a failure cannot lose it. diff --git a/tests/io/exit_status.bend b/tests/io/exit_status.bend new file mode 100644 index 000000000..8717ec621 --- /dev/null +++ b/tests/io/exit_status.bend @@ -0,0 +1,11 @@ +import Base + +def Host.exit_status() -> IO(U32): + import "./exit_status.js" + +def main() -> IO(Unit): + do IO: + status : U32 <- Host.exit_status() + IO.print(U32.show(status)) + +#|0 diff --git a/tests/io/exit_status.js b/tests/io/exit_status.js new file mode 100644 index 000000000..73f3c15d6 --- /dev/null +++ b/tests/io/exit_status.js @@ -0,0 +1,49 @@ +function exit_status() { + const fs = require("node:fs"); + const path = require("node:path"); + const child = require("node:child_process"); + const dir = fs.mkdtempSync(path.join(process.cwd(), ".bend-exit-status-")); + try { + const bun = typeof Bun !== "undefined"; + const command = bun ? process.execPath : "bun"; + const prefix = bun && Bun.main.startsWith("/$bunfs/") + ? [] : [path.resolve("bend2/main.ts")]; + const cases = [ + ["IO.exit(0)", 0, ""], + ["IO.exit(255)", 255, ""], + ["IO.exit(256)", 1, null], + ["IO.exit(4294967295)", 1, null], + ['IO.die(Unit, 7, "")', 7, "\n"], + ['IO.die(Unit, 256, "reported")', 1, null, "reported\n"], + ]; + for (let i = 0; i < cases.length; i += 1) { + const [expression, status, stderr, start] = cases[i]; + const file = path.join(dir, "main.bend"); + fs.writeFileSync(file, `import Base + +def main() -> IO(Unit): + do IO: + IO.print("before") + ${expression} + IO.print("after") +`); + const ran = child.spawnSync(command, [...prefix, file], { + encoding: "utf8", + env: { ...process.env, HOME: dir, BEND_ORIGIN: "http://127.0.0.1:9" }, + }); + if (ran.error) { + throw ran.error; + } + if (ran.status !== status || ran.stdout !== "before\n" + || (stderr !== null && ran.stderr !== stderr) + || (start !== undefined && !ran.stderr.startsWith(start))) { + return i + 1; + } + } + return 0; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +io_eff(CID(Host.exit_status), exit_status); diff --git a/tests/io/quiet_exit.bend b/tests/io/quiet_exit.bend new file mode 100644 index 000000000..5c87ceba4 --- /dev/null +++ b/tests/io/quiet_exit.bend @@ -0,0 +1,15 @@ +import Base + +def classify(op: IO.OP) -> U32: + match op: + case Emit{v}: v + case _: 91 + +def main() -> IO(Unit): + do IO: + IO.print(U32.show(classify(Exit{7}))) + IO.exit(23) + IO.print("unreachable") + +#|91 +#|exit 23 diff --git a/tests/io/request_out_of_band.bend b/tests/io/request_out_of_band.bend index cc4346c66..0802a6642 100644 --- a/tests/io/request_out_of_band.bend +++ b/tests/io/request_out_of_band.bend @@ -34,6 +34,8 @@ def intercept.at(op): do IO: u : Unit <- IO.print(m) return c + case Exit{c}: + IO.pure(U32, c) law intercept: IO(U32) @@ -49,6 +51,7 @@ def decode(op): match op: case Emit{v}: U32.show(v) case Halt{c, m}: m + case Exit{c}: U32.show(c) law main: IO(U32) diff --git a/tests/io/request_wildcard.bend b/tests/io/request_wildcard.bend index fba8b50e1..ab262d8bd 100644 --- a/tests/io/request_wildcard.bend +++ b/tests/io/request_wildcard.bend @@ -1,5 +1,5 @@ # io (io_request_wildcard): an IO.OP match's own default arm takes only -# what an IO.OP is (Emit, Halt), never a request. decode's case _ takes a +# what an IO.OP is (Emit, Halt, Exit), never a request. decode's case _ takes a # purely built Halt and prints "other"; intercept's case _ meets the # request IO.print("SECRET") applies to its continuation, and every lane # refuses it instead of running the user's arm on it (which printed