fluffos/testsuite
Yucong Sun 1099b48236
Speed up diagnostic rendering, and let the caller own the compile arena (#1343)
Three related changes, plus the use-after-free the third one exposed.

1. read_source_line() walked the file with fgetc() to reach a target line,
and render_diagnostic() calls it once per level of a macro-expansion chain
-- so rendering one diagnostic cost O(levels x filesize) in one-byte stdio
calls. Profiling the LPC testsuite measured 45,430,932 fgetc() calls
reached from render_diagnostic, 12.1% of the entire run, for only 153
rendered diagnostics. It now reads through an 8K stack buffer and finds
line breaks with memchr.

Deliberately no heap buffer and no arena for that scan: it runs DURING a
compile (report_compile_diagnostic, from yyerror/yywarn) and well AFTER
one -- lpcshell renders stored diagnostics once the arena has been reset,
and the compiler GTests call it with no compile in flight at all. A stack
buffer is correct in all three contexts.

Output is unchanged, and checked rather than assumed: on the case that
exercises this hardest (compiler/deep_macro_nesting.lpc, 64 expansion
notes) all 396 lines of rendered diagnostics are byte-identical, and the
file's runtime drops from 1713 ms to 828 ms.

2. The compiler reset the scratchpad at the END of every compile -- freeing
its own output before the caller had read it, which is exactly why anything
a consumer reads afterwards could not live on the arena. Inverted:
compile_file()/compile_file_fd() take a ScratchArena*, allocate every
transient there, and leave it exactly as found. A caller that supplies none
gets a shared default arena, which IS the compiler's to recycle, so that
one is reset on the way IN -- the previous compile's transients stay
readable until the next compile starts. Arena state moves from file-static
globals into ScratchArena::Impl behind a plain RAII handle, and
scratchpad.{h,cc} moves to base/internal/ since ownership now sits outside
the compiler.

The default arena is process-lifetime deliberately. A fresh arena per
compile reads tidier but discards the retained chunk cache every time (that
cache is what drives a long-lived driver to zero chunk mallocs in the
steady state) and leaves scratch_stats()/scratchpad_status() describing an
arena that never took part in a compile. bench_compile caught it: "0
retained chunks, 0 resets" after 2000 compiles where master reports 1 and
2034 -- the "chunk mallocs delta MUST be 0" invariant had gone vacuous
rather than failing.

Because a second arena can no longer borrow the static base block, chunk 0
is sometimes a heap chunk, and base_is_static tells teardown whether to
free it. Every path that drops chunk 0 now keeps that flag honest via
release_base_claim(); without it the tiny-chunk test knob leaked its base
chunk on both calls (476 B and 1 MB, LeakSanitizer-confirmed).

3. lpcshell reaches the compiler through load_object_from_source(), which
now threads an optional ScratchArena* -- a single narrow entry point, so
the 24 general load_object() callers are untouched. lpcshell's Session owns
one, reset at the TOP of each Eval() rather than the end, since by then the
previous evaluation's diagnostics have been printed.

That unblocked the last piece: Diagnostic's variable-length fields are now
ScratchString/ScratchVector, as are the pending_* containers staging into
them. Two boundaries stay heap on purpose -- the lexer's provenance
accessors outlive any compile, and compiler_next_load_reason is set before
compile_file runs -- so both are copied onto the arena at capture.

That conversion carries a lifetime contract, learned twice the hard way: a
stale ScratchString is harmless to destroy, but a stale ScratchVector is
not (its destructor walks its own arena buffer to destroy elements), and
releasing must drop the BUFFER, not just clear() -- a cleared container
keeps capacity, so the next push_back wrote into memory the arena had since
handed to the lexer's Flex buffers, surfacing as a segfault in
yypop_buffer_state nowhere near the diagnostics.

Yucong Sun then found and fixed the remaining hole in that contract: an
arena could die while compiler_diags still referenced it, a
heap-use-after-free that the guard above did not cover. His fix adds the
missing teardown coupling plus regression coverage (test_compiler.cc, an
lpcshell .lpcs case) and the CMake wiring for it.

Validated on RelWithDebInfo and Debug+ASan/UBSan/LSan: 339/339 GTest and
the LPC testsuite clean on each, bench_compile steady-state matching
master, and an lpcshell run rendering a full clang-style error with snippet
and caret AFTER the compile returned -- the case this whole change exists
to make legal.



Claude-Session: https://claude.ai/code/session_01MN9sz4nvGgBZw9oZiei3XR

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-12 11:48:29 -07:00
..
.vim lpc auto completion supports in vim (#820) 2021-07-03 13:00:39 -04:00
clone Default arguments: fix helper-name collisions and fill on all direct-call paths (#1298) 2026-07-20 05:47:03 -07:00
command lpc-syntax: source-following LPC formatter with printWidth wrapping and full testsuite reformat (#1270) 2026-07-17 11:55:21 -07:00
data fix: compress.cc stack overflow and unbounded decompression growth 2026-07-14 13:37:57 -05:00
etc lpc-syntax: source-following LPC formatter with printWidth wrapping and full testsuite reformat (#1270) 2026-07-17 11:55:21 -07:00
include docs: rewrite FFI reference around buffer features, add worked libc examples (#1275) 2026-07-15 22:09:25 -07:00
inherit Fix object-refcount over-decrement bugs behind #1327's corruption crashes 2026-07-27 16:06:44 -07:00
log Migrate buildsystem to CMake (#431) 2019-01-07 20:29:34 -08:00
lpcshell Speed up diagnostic rendering, and let the caller own the compile arena (#1343) 2026-08-12 11:48:29 -07:00
single Make sizeof()/indexing O(1) for pure-ASCII strings (#1344) 2026-08-12 10:49:22 -07:00
std compiler: coerce return <expr>; to the function's declared return type (#1334) 2026-07-27 22:29:15 -07:00
u Migrate buildsystem to CMake (#431) 2019-01-07 20:29:34 -08:00
.gitattributes lpc-syntax: source-following LPC formatter with printWidth wrapping and full testsuite reformat (#1270) 2026-07-17 11:55:21 -07:00
.gitignore Grammar modernization: top-down restructure, named references, split rule files 2026-07-09 20:48:48 -04:00
2000.txt Adding string find/split (strsrch/explode) benchmark 2021-07-11 09:29:16 -04:00
ar_test.txt Every efun has a test: ~130 new files close the whole spec inventory 2026-07-09 20:48:48 -04:00
crlf_test_file.crlf Fix CRLF tests for read_file and read_bytes 2020-01-15 12:27:10 -08:00
crlf_test_file.lf Fix CRLF tests for read_file and read_bytes 2020-01-15 12:27:10 -08:00
format.sh fix(lpc-syntax): refuse files with broken quoting instead of shredding them 2026-07-23 20:28:21 -07:00
README.md lpc-syntax: source-following LPC formatter with printWidth wrapping and full testsuite reformat (#1270) 2026-07-17 11:55:21 -07:00
speed.py Add speed benchmark for static/dynamic invoke 2022-04-01 20:06:01 -04:00
speed_str.py Adding string find/split (strsrch/explode) benchmark 2021-07-11 09:29:16 -04:00
telnet_test.expect Migrate buildsystem to CMake (#431) 2019-01-07 20:29:34 -08:00
test.o creating o2json and json2o utility (#1030) 2023-12-17 23:05:38 -08:00

FluffOS Testsuite

This directory is a minimal mudlib (a descendant of the classic Lil bootstrap mudlib) plus the driver's LPC regression suite. The driver boots it directly; every efun/compiler/VM behavior change is expected to come with a test here.

Source file extension

All LPC sources in this tree use the .lpc extension. The driver resolves source files by these rules (implemented in load_object(), src/vm/internal/simulate.cc, and pinned by single/tests/efuns/dual_extension.lpc):

  • An explicit extension is exact: load_object("/foo.c") probes only foo.c, load_object("/foo.lpc") probes only foo.lpc — the other spelling is never looked up.
  • Extension-less names prefer .lpc and fall back to .c (load_object("/foo") loads foo.lpc if present, else foo.c).
  • Object identity is extension-blind: object names never carry an extension, and find_object() strips either spelling, so "/foo", "/foo.c", and "/foo.lpc" all find the same loaded object. The program name (prog->filename, what inherit_list() and diagnostics report) carries the real extension of the file that was compiled.
  • The registry wins over the filesystem: load_object() returns an already-loaded object for any spelling of its name — the exactness rule only applies when the load actually hits the disk.
  • When an exact probe misses, the load falls through to the master's compile_object() virtual-object hook, which receives the stripped name; if it declines, load_object() returns 0 (no error is thrown).
  • children(), save_object()/restore_object(), replace_program() and function_exists() treat both spellings equivalently (name stripping / suffix handling covers .lpc and .c alike).

A few .c files exist on purpose: the clone/dual_*.c fixtures for dual_extension.lpc, and /tmp_eval_file.c written at runtime by the eval/codefor commands (live proof that genuinely .c-named sources still compile).

Running the suite

The suite is a first-class ctest test and a set of CMake targets:

Invocation What it does
ctest -R testsuite (or ctest -L testsuite) Runs the whole LPC suite through ctest, alongside ctest -LE testsuite for the GTest binaries. This is what CI runs.
driver-autotest (CMake target) Same run, invoked directly; exits nonzero on any failure.
driver-testsuite (CMake target) Boots the driver against this mudlib for interactive poking (log in and type tests).

The runner (command/tests.lpc) prints a gtest-style protocol:

[ RUN      ] /single/tests/efuns/dual_extension.lpc
[       OK ] /single/tests/efuns/dual_extension.lpc (24 ms)
[==========] 3401 checks from 297 file(s) ran. (7470 ms total)
[  PASSED  ] 297 file(s).
Checks succeeded.

Failures do not stop the run: a failed check is printed with its expected/actual diff and trace, recorded, and the run continues — one run reports every failure (gtest semantics), then the recap lists each [ FAILED ] file and the driver exits nonzero. Checks succeeded. plus exit 0 is the machine-readable pass signal.

Run one file, or a glob over test paths:

./build/bin/driver testsuite/etc/config.test '-ftest:single/tests/efuns/dual_extension.lpc'
./build/bin/driver testsuite/etc/config.test '-ftest:efuns/dual*'

Without an argument the runner walks /single/tests/ recursively in randomized order (run it 23× when touching the lexer/parser), so tests must not depend on each other having run.

How the runner treats each directory

command/tests.lpc walks /single/tests/:

  • single/tests/**/*.lpc — regular tests: each file is loaded and its do_tests() is called; any uncaught error or failed assertion fails the suite.
  • .../fail/*.lpc — files that must fail to compile/load; the runner asserts catch(load_object(...)) throws.
  • .../crasher/*.lpc — regression cases that only need to not crash the driver; errors are ignored.
  • In DEBUGMALLOC builds the runner calls check_memory() after every file and fails on any leaked allocation, so tests must clean up (destruct clones, remove temp files).

Writing a test

Create single/tests/<area>/<name>.lpc with a do_tests() entry point. etc/config.test auto-includes <globals.h> into every object, which provides the assertion macros from include/tests.h:

void do_tests() {
    ASSERT(intp(1));                    // truthiness
    ASSERT2(sizeof(x) == 3, "reason");  // with message
    ASSERT_EQ("expected", actual);      // equality with diff output
    ASSERT_NE(a, b);
}

Failed assertions print file:line, Check failed (with expected/actual and a trace for ASSERT_EQ/ASSERT_NE), are recorded, and the run continues; any recorded failure makes the final recap fail the run with a nonzero exit (that is how CI detects failure). Helper fixtures that should not be executed as tests live outside /single/tests/ — conventionally in /clone (e.g. inh0inh2, dual_*) or as #includes under /include. A fixture whose test depends on it being unloaded (e.g. a fail/ test pinning that a name does not resolve) must be private to that one test: the object registry is extension-blind and survives across files, so any other test loading the fixture first would change the outcome under the randomized order.

Efun tests are named after the efun (single/tests/efuns/<efun>.lpc) and should aim to cover every branch of the C++ implementation, including error paths (catch(...)).

Formatting

The corpus is kept formatted with the grammar-driven LPC formatter in tools/lpc-syntax/ (see its README). From the repo root:

testsuite/format.sh            # format testsuite/**/*.lpc,*.c in place
testsuite/format.sh --check    # exit 1 if anything is unformatted (CI)

Dependency-free (node ≥ 18 only). The script excludes the two deliberately-malformed UTF-8 fixtures under single/tests/compiler/fail/ (raw byte fixtures — see the exclusion note in the script and AGENTS.md §7), and refuses to write any file whose formatted output isn't token-sequence-equivalent to the input, literal-content byte-identical, and idempotent. After a corpus reformat, run the driver suite before committing.

Layout

Path What
etc/config.test Driver config the suite boots with (mudlib dir, include dirs, global include, limits).
single/master.lpc Master object: flag() test entry, compile_object() virtual-object hook (/test/virtual), get_include_path() cases, error handling.
single/simul_efun.lpc Simul-efuns available everywhere in the suite.
single/tests/ The test tree (efuns/, compiler/, operators/, applies/, std/, plus fail/ and crasher/ subdirs).
command/ Interactive commands; tests.lpc is the suite runner, speed.lpc the benchmark entry (-fspeed).
clone/, inherit/, std/, u/ Fixture objects, inheritance helpers, minimal std lib, user dirs.
include/ Headers; tests.h (assertions), globals.h (auto-included).
data/, log/ Runtime state and logs written by the suite.