From 0b2a333c45be0ce335dac365b1eef89472a2d74c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 22:22:04 +0000 Subject: [PATCH] feat(bun): tryWriteTextFile and tryRenamePath, total write and rename writeTextFile throws on any host error (permission, missing directory, disk full), and the Bun host has no rename. So a program can neither report a failed write as an ordinary result nor publish several outputs all or none. kyX needs both: a review of kyx#3 found that a failed write could escape as a stack trace, and that `--into both` could publish one output before the second failed. tryWriteTextFile(path, content) and tryRenamePath(from, to) return true on success and false on any host error, and never throw. A caller writes every output to a temporary path with the first, and only when all succeed renames each into place with the second. Both are declared in stdlib/Deno.affine beside pathExists, as the builtins<->stdlib consistency test requires. Tests: host_profile asserts success, a write into a missing directory (false), a rename replacing an existing target, and a rename of a missing source (false). tools/run_codegen_bun_tests.sh fails without the builtins and passes with them; dune build @test/runtest is green (570 tests). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZnvMV5Ghe7LUf3oc6N --- lib/codegen_deno.ml | 7 +++++++ stdlib/Deno.affine | 8 ++++++++ tests/codegen-bun/host_profile.affine | 4 ++++ tests/codegen-bun/host_profile.harness.mjs | 10 ++++++++++ 4 files changed, 29 insertions(+) diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 09239957..61178909 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -1331,6 +1331,13 @@ let () = is the check to make before them (added for kyX). *) b "pathExists" (fun a -> Printf.sprintf "__as_fs().existsSync(%s)" (arg 0 a)); + (* Total write and rename: true on success, false on any host error + (permission, missing directory, disk full), never a throw. They let a + program stage outputs and publish them all or none (added for kyX). *) + b "tryWriteTextFile" + (fun a -> Printf.sprintf "((p, c) => { try { __as_fs().writeFileSync(p, c, \"utf8\"); return true; } catch (_e) { return false; } })(%s, %s)" (arg 0 a) (arg 1 a)); + b "tryRenamePath" + (fun a -> Printf.sprintf "((f, t) => { try { __as_fs().renameSync(f, t); return true; } catch (_e) { return false; } })(%s, %s)" (arg 0 a) (arg 1 a)); b "args" (fun _ -> "(globalThis.process?.argv?.slice(2) ?? [])"); b "exit" (fun a -> Printf.sprintf "__as_process().exit(%s)" (arg 0 a)); diff --git a/stdlib/Deno.affine b/stdlib/Deno.affine index 28693fdc..184842f1 100644 --- a/stdlib/Deno.affine +++ b/stdlib/Deno.affine @@ -82,6 +82,14 @@ pub extern fn statIsDirectory(path: String) -> Bool; /// Never throws: the total check to make before the stat predicates above. pub extern fn pathExists(path: String) -> Bool; +/// Write `content` to `path` as UTF-8. True on success, false on any host +/// error (permission, missing directory, disk full); never throws. +pub extern fn tryWriteTextFile(path: String, content: String) -> Bool; + +/// Rename `from` to `to` (replacing `to` when it exists). True on success, +/// false on any host error; never throws. +pub extern fn tryRenamePath(from: String, to: String) -> Bool; + /// Recursive walk under `root` — every file path beneath it, depth-first. /// Mirrors `std/fs/walk` for the common case (no glob filter; callers /// filter by extension). Throws on a missing root via `Deno.readDirSync`. diff --git a/tests/codegen-bun/host_profile.affine b/tests/codegen-bun/host_profile.affine index c9d3ad60..527903d4 100644 --- a/tests/codegen-bun/host_profile.affine +++ b/tests/codegen-bun/host_profile.affine @@ -14,6 +14,8 @@ extern fn statSize(path: String) -> Int; extern fn statIsFile(path: String) -> Bool; extern fn statIsDirectory(path: String) -> Bool; extern fn pathExists(path: String) -> Bool; +extern fn tryWriteTextFile(path: String, content: String) -> Bool; +extern fn tryRenamePath(from: String, to: String) -> Bool; extern fn bytesLength(value: Bytes) -> Int; extern fn bytesByteAt(value: Bytes, offset: Int) -> Int; extern fn args() -> [String]; @@ -29,6 +31,8 @@ pub fn file_size(path: String) -> Int { statSize(path) } pub fn is_file(path: String) -> Bool { statIsFile(path) } pub fn is_directory(path: String) -> Bool { statIsDirectory(path) } pub fn exists(path: String) -> Bool { pathExists(path) } +pub fn try_write(path: String, content: String) -> Bool { tryWriteTextFile(path, content) } +pub fn try_rename(from: String, to: String) -> Bool { tryRenamePath(from, to) } pub fn argument_count() -> Int { len(args()) } pub fn environment_value(name: String) -> String { diff --git a/tests/codegen-bun/host_profile.harness.mjs b/tests/codegen-bun/host_profile.harness.mjs index c31aa5e1..943816bd 100644 --- a/tests/codegen-bun/host_profile.harness.mjs +++ b/tests/codegen-bun/host_profile.harness.mjs @@ -49,6 +49,16 @@ try { // pathExists is the total check: false for the removed file, never a throw. if (subject.exists(file) !== false) throw new Error("pathExists on a missing path is not false"); if (subject.exists(nested) !== true) throw new Error("pathExists on a directory is not true"); + // tryWriteTextFile / tryRenamePath are total: false on a host error, never a throw. + const staged = join(nested, "staged.tmp"); + const published = join(nested, "published.txt"); + if (subject.try_write(staged, "v1") !== true) throw new Error("tryWriteTextFile failed on a writable path"); + if (subject.try_write(join(root, "no-such-dir", "x"), "v") !== false) throw new Error("tryWriteTextFile into a missing directory is not false"); + if (subject.try_write(published, "old") !== true) throw new Error("tryWriteTextFile could not create the target"); + if (subject.try_rename(staged, published) !== true) throw new Error("tryRenamePath failed"); + if (subject.read_text(published) !== "v1") throw new Error("tryRenamePath did not replace the target"); + if (subject.exists(staged) !== false) throw new Error("tryRenamePath left the source behind"); + if (subject.try_rename(staged, published) !== false) throw new Error("tryRenamePath of a missing source is not false"); } finally { rmSync(root, { recursive: true, force: true }); }