← board

crtl gap batch: chdir, dup, dup2, symlink, link

Measured

Present and callable already: rmdir, unlink, ftruncate, fsync, mkdir, chmod, fchmod, umask, rename, remove. Not declared:

symbol header note
chdir unistd.h DONE 2026-08-02 (was the last round-1 gap)
dup unistd.h DONE 2026-08-02
dup2 unistd.h DONE 2026-08-02 (PalDup2 existed; bridge only)
symlink unistd.h DONE 2026-08-02
link unistd.h DONE 2026-08-02

Cost is uneven, which is the useful part of this note

dup2 is nearly free: lib/rtl/platform.pas already exports PalDup2 (platform.pas:192), so it needs a __pxx_dup2 bridge in lib/rtl/pxxcio.pas and a declaration. dup is PalDup2-adjacent but has no PAL entry of its own.

chdir, symlink and link need new PAL surface (syscall in the posix backend, an honest PAL_ERR_UNSUPPORTED in the ESP one, plus the __pxx_ bridge). That is the same shape as PalConnectUnix added on 2026-08-02 — see that commit for the pattern, including the i386 socketcall-style caveat that some syscalls take a different path on 32-bit.

So this splits cleanly: do dup2/dup first for a cheap win, and treat the three filesystem calls as the real work.

A caution about chdir

It changes process-global state, and lib/rtl caches nothing about the working directory today, so nothing in-tree should go stale. Worth confirming that PalGetcwd's callers do not memoise before adding it, since a stale cwd after a chdir would be a silent wrong path rather than an error.

Gate

A differential test against gcc per call, in the style of test/cstring_batch.c — whole output diffed, no recorded expectations. dup2 proven by redirecting a real fd and reading it back; symlink/link by creating one in /tmp and stat-ing both ends. Cross-check i386/aarch64/arm32, since lib/crtl builds for every target while gate.sh lib is x86-64 only ([[frank2-crtl-changes-need-cross-check]]).

dup / dup2 landed 2026-08-02 (commit fcc86ac39)

The cheap half, as this ticket predicted. __pxx_dup2 bridges PalDup2; __pxx_dup is PalFcntl(fd, F_DUPFD, 0), which is dup()'s definition rather than a stand-in for a missing primitive, so nothing new was needed in the PAL.

Verified behaviourally in test/cdup.c, not by return code: the duplicate must actually read the same file, and dup2 must land on the descriptor it was given (17, chosen so a "returns some fd" implementation would fail). Identical to gcc on x86-64, i386, aarch64 and arm32.

Still open: chdir, symlink, link — the three that need new PAL surface in both backends. The caution about chdir above still applies.

New PAL surface in both backends: PalChdir, PalSymlink, PalLink, with the ESP backend refusing them (PAL_ERR_UNSUPPORTED) rather than faking — a chdir that silently did nothing would make every later relative path wrong.

symlink/link reach the kernel through symlinkat/linkat with AT_FDCWD, not the legacy syscalls: aarch64 and riscv do not have symlink/link at all, so the *at form is the only spelling that exists on every target — the same reason openat/unlinkat/renameat are used elsewhere in the backend. Note the argument shapes differ: symlinkat puts the dirfd in the MIDDLE (target, dirfd, linkpath) while linkat brackets it (olddirfd, old, newdirfd, new, flags).

The chdir caution above was checked, not assumed: nothing in lib/rtl or compiler/builtin memoises the working directory — every caller goes straight to PalGetcwd — so there is no stale cache to invalidate.

Syscall numbers came from kernel headers on the build box for x86-64, i386 and the asm-generic pair (aarch64/riscv). arm32 has no header here, so its numbers were derived from the ordering the backend's existing entries pin (openat=322 … renameat=329 … readlinkat=332), which puts linkat=330 and symlinkat=331. The qemu-arm run is what confirms that, not the reasoning — recorded in the test so a future reader knows which numbers were verified and which were inferred-then-tested.

Verified behaviourally in test/cfileops.c: chdir must make a RELATIVE path resolve against the new directory (not merely return 0), lstat must see a link where stat follows it, the hard link must expose the same content, and the failure cases must fail. Identical to gcc on x86-64, i386, aarch64, arm32.

One assertion deliberately absent

The natural proof of a hard link is the target's st_nlink reaching 2. pxx reports 1 — lib/crtl/src/sys/stat.c assigns it unconditionally and the PAL never carried the field. That is not this change; filed as [[bug-b-crtl-stat-nlink-hardcoded]], and the test proves the link by content instead rather than asserting something known-wrong or quietly dropping the case.

Log