Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ All notable changes to EigenScript are documented here.
being recomputed by render and click separately.

### Fixed
- **`file_exists` / `ls` / `mkdir` / `getcwd` / `exe_path` are now
trace-recorded (#585).** These fs builtins predated the tape and ran live
under `EIGS_REPLAY`: a program branching on `file_exists of p` replayed
against the current filesystem, not the recorded one, so a tape recorded on
one machine silently diverged on another (the closed-world hole #471 closed
for `args`). Each is now wrapped with the `TAKE`/`RECORD` pair, so replay
serves the recorded answer and never touches the live fs. `mkdir` is
Recorded rather than #148-non-replayable — its success bit is pinnable by
the tape — and under replay the `TAKE` short-circuits before `mkdir(2)`, so
the recorded bit is served and the directory is not created a second time
(replay does not re-run the write side effect). Replay tests in
`test_replay.sh` mutate the fs between record and replay to prove the value
comes from the tape; verified JIT on and off.
- **`prev of` now binds unary-or-tighter like every other `of` (#634).**
`prev of x + 1` parsed as `prev of (x + 1)` — the operand parser used
`parse_expression` where every other `of` uses `parse_unary` — and
Expand Down
10 changes: 5 additions & 5 deletions docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ Boolean keywords that check the most recently observed value:
| Name | Signature | Description |
|------|-----------|-------------|
| `load_file` | `load_file of "path.eigs"` | Load and execute EigenScript file. A missing/unreadable path raises a catchable `io` error (matching `import`); a parse/compile failure in the file raises `parse`. |
| `file_exists` | `file_exists of "path"` | 1 if file exists, 0 otherwise |
| `file_exists` | `file_exists of "path"` | 1 if file exists, 0 otherwise. Trace-recorded, so replay is deterministic (#585) |
| `is_dir` | `is_dir of "path"` | 1 if the path names a directory, 0 for a plain file / missing path (#576 — replaces the `file_exists of "path/."` probe). Trace-recorded, so replay is deterministic |
| `read_text` | `read_text of "path"` | Read file contents as string ("" on failure, 10 MB cap) |
| `read_line` | `read_line of null` | Blocking line read from **stdin**: next line without its trailing newline (`\r\n` stripped as one unit), `null` at EOF; an empty line is `""`. Works on pipes — the stream-safe primitive `read_text of "/dev/stdin"` can't be (fseek fails on unseekable fds, #558). Trace-recorded, so replay is deterministic |
Expand All @@ -285,10 +285,10 @@ Boolean keywords that check the most recently observed value:
| `env_get` | `env_get of "VAR_NAME"` | Get environment variable (empty string if unset) |
| `random_hex` | `random_hex of n` | Generate n random hex characters from /dev/urandom |
| `try_parse` | `try_parse of code_string` | 1 if string is valid EigenScript syntax, 0 otherwise |
| `mkdir` | `mkdir of "path"` | Create directory (and parents). 1 on success, 0 on failure |
| `ls` | `ls of "path"` | List directory contents as list of strings |
| `getcwd` | `getcwd of null` | Current working directory as string |
| `exe_path` | `exe_path of null` | Absolute path of the running interpreter binary. Lets a script re-invoke the same interpreter (e.g. `exec_capture of [exe_path of null, file]`) without assuming `eigenscript` is on PATH |
| `mkdir` | `mkdir of "path"` | Create directory (and parents). 1 on success, 0 on failure. Trace-recorded: replay serves the recorded bit and does not re-create the directory (#585) |
| `ls` | `ls of "path"` | List directory contents as list of strings. Trace-recorded, so replay is deterministic (#585) |
| `getcwd` | `getcwd of null` | Current working directory as string. Trace-recorded, so replay is deterministic (#585) |
| `exe_path` | `exe_path of null` | Absolute path of the running interpreter binary. Lets a script re-invoke the same interpreter (e.g. `exec_capture of [exe_path of null, file]`) without assuming `eigenscript` is on PATH. Trace-recorded, so replay is deterministic (#585) |
| `chdir` | `chdir of "path"` | Change working directory. 1 on success, 0 on failure |
| `mktemp` | `mktemp of null` | Create temporary file, return its path |
| `rm` | `rm of "path"` | Remove a file. 1 on success, 0 on failure |
Expand Down
9 changes: 8 additions & 1 deletion docs/TRACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,14 @@ perspective lands on the tape as an `N` record:
- **Random:** `random`, `random_int`, `random_normal`, `random_hex`
- **Time:** `monotonic_ns`, `monotonic_ms`
- **Environment / files:** `env_get`, `read_text`, `read_bytes`,
`read_bytes_buf`, `read_line` (stdin, #558), `is_dir` (#576).
`read_bytes_buf`, `read_line` (stdin, #558), `is_dir` (#576),
`file_exists`, `ls`, `getcwd`, `exe_path`, `mkdir` (#585).
`mkdir` is a *write* whose return (a success bit) is filesystem-dependent:
it is Recorded rather than #148-non-replayable because that bit **is**
pinnable by the tape (unlike a subprocess fd). Under `EIGS_REPLAY` the
`TAKE` short-circuits before the `mkdir(2)` calls, so the recorded bit is
served and the directory is **not** created a second time — replay does not
re-run the side effect, the same rule as the subprocess/audio boundary.
`read_bytes_buf`'s over-cap **raise** (#601) also rides the tape: the
observed file size is recorded as a `VAL_NUM` `N` record (unambiguous —
success records a `VAL_BUFFER`, open-failure records null) and the
Expand Down
49 changes: 36 additions & 13 deletions src/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -2119,8 +2119,15 @@ Value* builtin_path_ext(Value *arg) {

/* mkdir of "path" → 1 on success, 0 on failure. Creates parents. */
#if !EIGENSCRIPT_FREESTANDING
/* #585: mkdir's return (a success bit) is filesystem-dependent, so it is a
* taped nondeterminism source. Under EIGS_REPLAY the TAKE short-circuits
* before any mkdir(2), so the recorded bit is served and the filesystem is
* NOT mutated a second time — the same "don't re-run side effects on replay"
* rule as the proc-star / audio-capture boundary. Its return IS pinnable by
* the tape (unlike a proc fd), so it is Recorded, not #148-non-replayable. */
Value* builtin_mkdir(Value *arg) {
if (!arg || arg->type != VAL_STR) return make_num(0);
if (!arg || arg->type != VAL_STR) TRACE_NONDET_RET("mkdir", make_num(0));
TRACE_NONDET_TAKE("mkdir");
/* Simple recursive mkdir */
char *path = xstrdup(arg->data.str);
int len = strlen(path);
Expand All @@ -2134,35 +2141,43 @@ Value* builtin_mkdir(Value *arg) {
}
free(path);
struct stat st;
return make_num(stat(arg->data.str, &st) == 0 && S_ISDIR(st.st_mode) ? 1 : 0);
TRACE_NONDET_RECORD("mkdir",
make_num(stat(arg->data.str, &st) == 0 && S_ISDIR(st.st_mode) ? 1 : 0));
}
#endif /* !EIGENSCRIPT_FREESTANDING */

/* ls of "path" → list of filenames in directory, or [] on failure.
* Matches `ls -1` default behavior: hidden entries (starting with '.') are excluded. */
#if !EIGENSCRIPT_FREESTANDING
Value* builtin_ls(Value *arg) {
if (!arg || arg->type != VAL_STR) return make_list(0);
if (!arg || arg->type != VAL_STR) TRACE_NONDET_RET("ls", make_list(0));
/* #585: builds its return (a list) via readdir, so under EIGS_REPLAY the
* TAKE short-circuits before opendir — the recorded listing is served and
* the live directory is never read. */
TRACE_NONDET_TAKE("ls");
Value *list = make_list(0);
DIR *d = opendir(arg->data.str);
if (!d) return list;
if (!d) TRACE_NONDET_RECORD("ls", list);
struct dirent *entry;
while ((entry = readdir(d))) {
if (entry->d_name[0] == '.') continue;
list_append_owned(list, make_str(entry->d_name));
}
closedir(d);
return list;
TRACE_NONDET_RECORD("ls", list);
}
#endif /* !EIGENSCRIPT_FREESTANDING */

/* getcwd of null → current working directory as string */
#if !EIGENSCRIPT_FREESTANDING
Value* builtin_getcwd(Value *arg) {
(void)arg;
/* #585: the cwd is process environment, nondeterministic across
* invocations and machines — taped so replay serves the recorded path. */
TRACE_NONDET_TAKE("getcwd");
char buf[4096];
if (getcwd(buf, sizeof(buf))) return make_str(buf);
return make_str("");
if (getcwd(buf, sizeof(buf))) TRACE_NONDET_RECORD("getcwd", make_str(buf));
TRACE_NONDET_RECORD("getcwd", make_str(""));
}
#endif /* !EIGENSCRIPT_FREESTANDING */

Expand All @@ -2174,14 +2189,18 @@ Value* builtin_getcwd(Value *arg) {
#if !EIGENSCRIPT_FREESTANDING
Value* builtin_exe_path(Value *arg) {
(void)arg;
/* #585: the interpreter path is machine-dependent — taped so replay
* serves the recorded path without touching /proc/self/exe. */
TRACE_NONDET_TAKE("exe_path");
char buf[4096];
ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
if (n > 0 && n < (ssize_t)sizeof(buf)) {
buf[n] = '\0';
return make_str(buf);
TRACE_NONDET_RECORD("exe_path", make_str(buf));
}
if (g_argv && g_argc > 0 && g_argv[0]) return make_str(g_argv[0]);
return make_str("eigenscript");
if (g_argv && g_argc > 0 && g_argv[0])
TRACE_NONDET_RECORD("exe_path", make_str(g_argv[0]));
TRACE_NONDET_RECORD("exe_path", make_str("eigenscript"));
}
#endif /* !EIGENSCRIPT_FREESTANDING */

Expand Down Expand Up @@ -2844,10 +2863,14 @@ Value* builtin_load_file(Value *arg) {

#if !EIGENSCRIPT_FREESTANDING
Value* builtin_file_exists(Value *arg) {
if (!arg || arg->type != VAL_STR) return make_num(0);
if (!arg || arg->type != VAL_STR) TRACE_NONDET_RET("file_exists", make_num(0));
/* #585: fs-dependent read — taped so replay serves the recorded answer.
* TAKE short-circuits before the fopen probe under EIGS_REPLAY. */
TRACE_NONDET_TAKE("file_exists");
FILE *f = fopen(arg->data.str, "r");
if (f) { fclose(f); return make_num(1); }
return make_num(0);
int ex = (f != NULL);
if (f) fclose(f);
TRACE_NONDET_RECORD("file_exists", make_num(ex));
}
#endif /* !EIGENSCRIPT_FREESTANDING */

Expand Down
61 changes: 61 additions & 0 deletions tests/test_replay.sh
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,67 @@ else
fail "is_dir replay" "rec='$REC_ID' rep='$REP_ID'"
fi

# ---- #585: file_exists / ls / mkdir / getcwd are taped nondeterminism ----
# Each records with the filesystem in one state, mutates it, then replays;
# the recorded answer must win (a live probe would now disagree). mkdir also
# asserts the write side effect does NOT re-run on replay.

# file_exists: record present, delete, replay must still say 1.
mkdir -p "$TMPDIR/fe585"
touch "$TMPDIR/fe585/probe"
cat > "$TMPDIR/p_fe.eigs" <<EOF
print of (file_exists of "$TMPDIR/fe585/probe")
EOF
FE_REC=$(EIGS_TRACE="$TMPDIR/fe.tape" "$EIGS" "$TMPDIR/p_fe.eigs" 2>&1)
rm -f "$TMPDIR/fe585/probe"
FE_REP=$(EIGS_REPLAY="$TMPDIR/fe.tape" "$EIGS" "$TMPDIR/p_fe.eigs" 2>&1)
if [ "$FE_REC" = "1" ] && [ "$FE_REP" = "1" ]; then
ok "file_exists replay: recorded 1 wins after the file is deleted (#585)"
else
fail "file_exists replay (#585)" "rec='$FE_REC' rep='$FE_REP'"
fi

# ls: record two entries, delete them, replay must serve the recorded count.
mkdir -p "$TMPDIR/ls585"
touch "$TMPDIR/ls585/a" "$TMPDIR/ls585/b"
cat > "$TMPDIR/p_ls.eigs" <<EOF
print of (len of (ls of "$TMPDIR/ls585"))
EOF
LS_REC=$(EIGS_TRACE="$TMPDIR/ls.tape" "$EIGS" "$TMPDIR/p_ls.eigs" 2>&1)
rm -f "$TMPDIR/ls585/a" "$TMPDIR/ls585/b"
LS_REP=$(EIGS_REPLAY="$TMPDIR/ls.tape" "$EIGS" "$TMPDIR/p_ls.eigs" 2>&1)
if [ "$LS_REC" = "2" ] && [ "$LS_REP" = "2" ]; then
ok "ls replay: recorded listing wins after the entries are deleted (#585)"
else
fail "ls replay (#585)" "rec='$LS_REC' rep='$LS_REP'"
fi

# mkdir: record success, remove the dir, replay must serve 1 AND not re-create it.
cat > "$TMPDIR/p_mk.eigs" <<EOF
print of (mkdir of "$TMPDIR/mk585/sub")
EOF
MK_REC=$(EIGS_TRACE="$TMPDIR/mk.tape" "$EIGS" "$TMPDIR/p_mk.eigs" 2>&1)
rm -rf "$TMPDIR/mk585"
MK_REP=$(EIGS_REPLAY="$TMPDIR/mk.tape" "$EIGS" "$TMPDIR/p_mk.eigs" 2>&1)
if [ "$MK_REC" = "1" ] && [ "$MK_REP" = "1" ] && [ ! -d "$TMPDIR/mk585/sub" ]; then
ok "mkdir replay: serves recorded bit, does NOT re-create the directory (#585)"
else
fail "mkdir replay (#585)" "rec='$MK_REC' rep='$MK_REP' recreated=$([ -d "$TMPDIR/mk585/sub" ] && echo yes || echo no)"
fi

# getcwd: record in one directory, replay from another, recorded path must win.
cat > "$TMPDIR/p_cwd.eigs" <<'EOF'
print of (getcwd of null)
EOF
mkdir -p "$TMPDIR/cwd585"
CWD_REC=$(cd "$TMPDIR/cwd585" && EIGS_TRACE="$TMPDIR/cwd.tape" "$EIGS" "$TMPDIR/p_cwd.eigs" 2>&1)
CWD_REP=$(cd / && EIGS_REPLAY="$TMPDIR/cwd.tape" "$EIGS" "$TMPDIR/p_cwd.eigs" 2>&1)
if [ -n "$CWD_REC" ] && [ "$CWD_REC" = "$CWD_REP" ]; then
ok "getcwd replay: recorded cwd wins when replayed from a different directory (#585)"
else
fail "getcwd replay (#585)" "rec='$CWD_REC' rep='$CWD_REP'"
fi

# ---- #579: audio capture is a taped nondeterminism source ----
# Gated: needs a gfx build AND a working capture device (the dummy SDL
# driver provides a silent one; the CI dev image has no libSDL2, so this
Expand Down
Loading