Skip to content

feat(rt): add os/rename and os/delete-tree - #698

Open
mparrett wants to merge 2 commits into
mainfrom
wt/os-fs-primitives
Open

feat(rt): add os/rename and os/delete-tree#698
mparrett wants to merge 2 commits into
mainfrom
wt/os-fs-primitives

Conversation

@mparrett

@mparrett mparrett commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Two filesystem primitives that had no equivalent in the runtime.

os/rename wraps rename(2). Nothing did, so publishing a file meant spit and a window in which a reader could observe it half-written. Staging to a temporary name and renaming into place closes that window, and rename is the only call that gives it.

os/delete-tree is the recursive form of delete-file, which removes a single entry and fails on a non-empty directory. test/os_unzip_test.lg in this repo unwinds its fixture with five delete-file calls in dependency order, which is the shape this replaces.

A correction to what I wrote on #688: a recursive delete does already exist, as syscall/rm-rf, and I missed it. It is the same RemoveAll call. The argument for a second spelling is placement rather than capability — syscall is the container-setup namespace, sitting beside clone, pivot-root, chroot and seccomp, which is not where someone doing ordinary filesystem work looks, and os is now where rename and unzip live. If you'd rather have one, the alternative is to leave syscall/rm-rf as the only spelling and drop this half of the PR; os/rename has no equivalent anywhere and stands either way.

Both came out of surveying let-go against Grenadine's host contract (#688). Of the slots still unfilled, these were the two that were plain missing functions rather than design questions; @abogoyavlensky confirmed they weren't working on them.

Behavior worth agreeing on

os/rename fails across filesystems rather than falling back to copy-then-delete. The fallback is the thing a caller reaches for this instead of, so substituting it silently would remove the only property separating the call from spit. An EXDEV error is the honest answer.

os/delete-tree succeeds when the path is already absent. The post-state the caller asked for is the one that holds. This diverges from delete-file, which throws, so it's pinned by a test in both suites rather than left to be rediscovered.

Symlinks are unlinked, never followed. A link inside the tree pointing outside it does not take the target down. By the same rule a path that is itself a symlink to a directory loses only the link, and the directory keeps its contents — worth documenting because "removes path and everything beneath it" does not suggest it. Both are now tested.

An empty path and the filesystem root are refused. os.RemoveAll("") is a silent no-op in Go, which hides the unset variable that produced it. The root is the same mistake with a worse outcome: (str nil) is "" in lg, so a caller building "$root/$name" with root unset gets "/name", one level below the root and past an empty-string check. The root test is Dir(p) == p after Clean, which holds exactly at a volume root on unix and Windows alike.

Placement

Both go in the os namespace rather than core, following os/unzip from #688. Core is the always-loaded surface and there's active work to shrink it; these are host effects and belong beside the other host effects.

The TinyGo os namespace is a deliberate three-function subset (exit, getenv, args) and gains neither, matching how os/unzip was added.

Verification

  • pkg/rt/os_fs_test.go — 13 tests covering the effect on disk, replacing an existing destination, moving a populated directory, symlink containment in both directions, absent-source, empty-path and root errors, and the argument guards.
  • test/os_fs_test.lg — the same surface from lg, checking what each call returns and that siblings survive.
  • go test -short ./... — 21 packages, no failures.
  • Builds clean for linux/amd64, js/wasm, wasip1/wasm, plan9/amd64, and darwin/arm64. windows/amd64 fails in pkg/rt/term.go on unix.SIGWINCH, which reproduces on main at aeac42d4 and is untouched by this change.

No generated artifacts change: the os namespace is registered from Go, so there's no generated.sums regen the way #690 needed one.

@nnunley

nnunley commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Both guards are the interesting part. RemoveAll("") being a silent no-op in Go is exactly
the shape that hides an unset variable, and turning it into an error is right; refusing the
root via filepath.Dir(cleaned) == cleaned gets the Windows volume roots for free, which a
separator match would not. The symlink tests cover both directions — a link inside the tree
not taking its target down, and a link as the argument losing only the link.

On the syscall/rm-rf overlap you raised yourself: I would keep both. syscall is the
container-setup namespace and nobody doing ordinary filesystem work looks there, and this is
the spelling that carries the root and empty-path guards.

@mparrett
mparrett force-pushed the wt/os-fs-primitives branch 2 times, most recently from 87bd0a1 to 6a3f1cc Compare August 11, 2026 05:26
@mparrett
mparrett force-pushed the wt/os-fs-primitives branch from 6a3f1cc to c9c83bb Compare August 11, 2026 17:45
@mparrett
mparrett requested a review from nooga August 11, 2026 22:00
@mparrett
mparrett force-pushed the wt/os-fs-primitives branch 2 times, most recently from 1b73601 to d6e5a0b Compare August 14, 2026 23:10
mparrett and others added 2 commits August 16, 2026 15:40
Grenadine's host contract needs an atomic move and a recursive delete, and
neither had a home. `delete-file` removes a single entry and fails on a
non-empty directory, so callers open-code a walk; nothing at all wrapped
rename(2), so publishing a file meant spit and a window where a reader
could see it half-written.

os/rename returns the destination and fails across filesystems rather than
falling back to copy-then-delete. That fallback is the thing callers reach
for this instead of, so substituting it silently would drop the only
property separating it from spit.

os/delete-tree removes a path and everything under it, and succeeds when
the path is already absent — the post-state asked for is the one that
holds. That diverges from delete-file, which throws, so it is pinned by a
test in both suites. An empty path is refused: os.RemoveAll treats "" as a
silent no-op, which hides the unset variable that produced it.

Both land in the os namespace rather than core, following os/unzip (#688).
Core is the always-loaded surface and there is active work to shrink it;
these are host effects and belong beside the other ones.

The TinyGo os namespace is a deliberate three-function subset and gains
neither, matching how os/unzip was added.

Requested in #688; abogoyavlensky confirmed they were not working on these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three real findings from review.

syscall/rm-rf already exists on every platform and is the same RemoveAll
call, so the claim that delete-file was the only removal primitive was
wrong. Say so in the doc comment and give the actual reason for a second
spelling: syscall is the container-setup namespace, sitting beside clone,
pivot-root and seccomp, which is not where a caller doing ordinary
filesystem work looks.

The empty-path guard protected the least damaging case while permitting the
worst. (str nil) is "" in lg, so a caller building "$root/$name" with root
unset gets "/name" — one level below the root, straight past the guard.
Refuse the filesystem root too, via Dir(p) == p, which holds exactly at a
volume root on both unix and Windows.

TestOsDeleteTreeLeavesSiblingsAlone asserted a property no implementation
could violate: two unrelated directories, one deleted, the other checked.
Replace it with the containment property that can actually break — a
symlink inside the tree pointing out of it must be unlinked, not followed —
plus its surprising corollary, that a path which is itself a symlink to a
directory loses only the link. Both are now documented on the native, since
"removes path and everything beneath it" does not suggest the second.

Also: match the file's error-message grammar, drop a test comment claiming
to observe a torn intermediate state the test cannot see, and cover the
zero-arg case for rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants