Changelog
All notable changes to the Reef programming language are documented in this file.
Format follows Keep a Changelog. Versions use Semantic Versioning.
0.11.1 — 2026-10-19
Highlights
Reef 0.11.1 adds one runtime hook for OS entropy, reef_rt_entropy. It uses getrandom on Linux and illumos, arc4random_buf on the BSDs and macOS, and /dev/urandom elsewhere, and it never falls back to the clock. Two new modules build on it. random.random has secure functions that return a Result and a seedable Pcg32 generator for reproducible sequences. encoding.uuid covers RFC 9562 v4, v5, v7, v8, nil and max, and is tested against the RFC's vectors. math.random has fixes that change seeded output: the same seed(n) now gives different random_int_max, random_int_range and random_float results than 0.11.0. On bare metal, math.random now links in a freestanding image and a kernel can override the default reef_panic.
Added — reef_rt_entropy: one OS entropy hook (RB-016)
- The runtime has one entropy primitive,
reef_rt_entropy(buf, len)(reef-runtime/include/reef_entropy.h). Linux and illumos usegetrandom(2), the BSDs and macOSarc4random_buf(3), anything else/dev/urandom(also the fallback when the kernel lacksgetrandom). It fills every byte or returns a code (1 no source, 2 source failed, 3 bad arguments) with the buffer zeroed. It never falls back to the clock.EINTRfrom the GC's SIGURG is retried. - Bare metal:
reef_baremetal.chas a weak default that returns "no source". An OS links its ownreef_rt_entropyto provide one, as it doesreef_putchar. - Reef code reaches it through
sys.platform.runtime.reef_rt_entropy. Prefer therandommodule, which returns aResult. - Link test: a strong
reef_rt_entropyoverrides the weak bare-metal default on amd64 (run and checked by behaviour), aarch64 and riscv64 (checked withnm), with no duplicate-symbol error. If the strong definition is in a static archive linked afterreef_baremetal.o, the linker never pulls it in and the weak default is used silently.reef-baremetal/README.mdgives the safe link lines (object file, or--whole-archive).
Added — encoding.uuid (RFC 9562) (RB-019)
v4()andv7(unix_ms)take their random bits fromrandom.randomand return aResult(Err(Unsupported)with no entropy source, never a predictable UUID).v7puts the 48-bit millisecond time first, so UUIDs from different milliseconds sort by time.v8(bytes)sets the version and variant bits over the caller's 16 bytes (the caller's array is not changed).v5(namespace, name)is SHA-1 name-based viacrypto.sha1, withnamespace_dns/url/oid/x500.nil_uuid()andmax_uuid()(nilis a reserved word).parseaccepts only the canonical 8-4-4-4-12 form (either case);formatwrites lower case.from_bytes/to_bytesuse RFC byte order, the order of the ext2/ext4s_uuidfield. Alsoversion,variant,is_nil,is_max,eq,compare.- Tested against the RFC 9562 Appendix A vectors (v4, v5, v7, v8).
v4,v8andto_bytescompile and link for bare metal.
Added — random.random: secure random numbers and a seedable PCG32 (RB-018)
secure_fill,secure_bytes,secure_u32,secure_u64,secure_below,secure_range(inclusive) andsecure_floatread the OS source throughreef_rt_entropyand return aResult:Err(Unsupported)when the target has no source,Err(IoError)when it fails,Err(InvalidInput)for bad arguments. No silent fallback.Pcg32is a seedable, deterministic generator (PCG-XSH-RR 64/32, bit-identical to pcg-c-basic):pcg32_new(seed, stream),pcg32_from_entropy,pcg32_next_u32/u64,pcg32_below,pcg32_range,pcg32_float,pcg32_bool,pcg32_shuffle. Each generator has its own state, so Active Objects do not share one. Not for secrets.- Ranges are unbiased (rejection sampling) and floats are in [0, 1).
- Bare metal:
reef_baremetal.hhasreef_array_length, so modules that callarr.length()(this one,encoding.uuid) compile freestanding.
Changed — math.random: fixes that change seeded sequences (RB-020)
The same seed(n) now gives different random_int_max,
random_int_range and random_float results than in 0.11.0. Raw
random_int() and random_bool() sequences do not change.
random_float()could return exactly1.0(the divisor was 2^31-1, the largest draw;seed(1584200935)hit it on the first call). It now divides by 2^31, so results are always in[0.0, 1.0).random_int_maxandrandom_int_rangewere biased (random_int() % nfavoured small results: formax = 1500000000, values below 647483648 came up 60% of the time instead of 43%). They now use rejection sampling over the full 32-bit draw.random_int_rangeoverflowed for spans over 2^31-1: it returned values outside the range, andrandom_int_range(INT_MIN, INT_MAX)panicked with integer division by zero. Anyintspan now works.seed_time()seeded from the clock in whole seconds, so programs started in the same second got the same sequence. It now seeds fromreef_rt_entropyand uses the clock only when the target has no entropy source.math.randomis still not for secrets; userandom.random.
Fixed — bare-metal reef_panic can be overridden (RB-022)
- The default
reef_panicinreef-baremetal/src/reef_baremetal.cwas a strong definition, although the README and header comments called it an optional override. A kernel that defined its ownreef_panicand linked that file failed with a duplicate symbol (multiple definitionin GNU ld,duplicate symbolin ld.lld). It is now weak, like the other hooks. The weak-override link test covers it on amd64 (run and checked by behaviour), aarch64 and riscv64 (checked withnm).
Fixed — math.random links on bare metal (RB-017)
reef_baremetaldefinesreef_time(weak, returns 0 = no clock; an OS with an RTC overrides it) andreef_int_to_float(inline). Before, a freestanding image that calledmath.random.seed_time()orrandom_float()failed to link with undefined references to both.- New gate in
run_tests.sh:random_link_probe.reeflinks againstreef_baremetal.con amd64, aarch64 and riscv64, and a strongreef_rt_entropymust override the weak default.
0.11.0 — 2026-10-13
Highlights
Reef 0.11.0 changes how process waits report a child's exit. The new ExitStatus type says whether a child exited with a code or was killed by a signal, and SysError says why a wait failed. The old process_wait and ProcessCapture.exit_code now return the shell-style code: a child killed by a signal gives 128 + signal (kill -9 gives 137) instead of -1, and -1 now means only that the wait failed. The old wait calls are deprecated and will be removed in 0.12. Waits and blocking socket, file and terminal reads now retry when a signal interrupts them, reefc doctor no longer prints shell errors for missing tools, and nested patterns such as Result_Ok(Option_Some(st)) work in match statements.
Added — ExitStatus: waits return how the child ended (RB-013)
sys.processhasExitStatus = Exited(code) | Signaled(signal, core)withsuccess()andshell_code()(the code, or 128 + signal), andSysError = NoChild | InvalidPid(pid) | Errno(n)(NoChildis ECHILD). One-child waits reject pid <= 0 withInvalidPidinstead of callingwaitpid(-1)/waitpid(0), which reaped an unrelated child.- New calls:
process_wait_status,process_try_wait_status(Ok(None)while running),process_wait_any_statusandprocess_wait_any_nohang_status(both give the pid with the status), andexit_status_core_supported().ProcessCapturehasstatus. coreis true when the kernel reports a core file; best effort.WCOREDUMPis decoded in a translation unit without feature-test macros, so its visibility does not depend on other sources' macros. Verified on illumos: core = true for an aborting child.- Documented and tested: the runtime never calls
waitpid(-1)except in the explicit wait-any calls, and never touches SIGCHLD.
Changed
-
process_wait(pid)returnsshell_code(): a child killed by a signal gives 128 + signal (it used to give -1). -1 now always means the wait failed.ProcessCapture.exit_codeisshell_code()too. -
ReefVision
yield_execreturnsErrwhen the wait for the child fails (it returnedOk(1));Okis still the shell code. ReefIDE prints a signal death assignal N(with(core dumped)when reported) in the output pane and status line instead of-1.
Deprecated (removed in 0.12)
process_wait,process_try_wait,process_wait_any,process_wait_any_nohang,WaitAnyResult,process_exit_code,process_exited_normally,process_was_signaled,process_term_signal,ProcessCapture.exit_code.
Fixed — process waits
- Blocking waits (
process_wait,process_wait_any) retryEINTR. The GC's stop-the-world signal interrupted them, and the wait returned -1 for a child that was still running. - A wait that reaps nothing (error, or nothing ready) clears the last-wait record, so the getters no longer describe the previous child.
process_try_waitno longer reports a failed wait (ECHILD) as "still running", which made polling loops spin until their timeout.- Compiler: a trait-impl method on an enum declared in another module
resolves (
Type 'X' has no method 'm'before). - Compiler: nested constructor patterns in match statements work.
Result_Ok(Option_Some(st)) =>bound nothing ("Undefined variable 'st'"), and two arms with the same outer constructor (Out_O(In_Q()),Out_O(In_P(_))) failed in C with a duplicate case label. Arms now nest to any depth with literals, binders and_, first match wins, and exhaustiveness is checked through the nesting. A match expression or awhenguard combined with nested patterns is rejected with a message (not supported yet).
Fixed — blocking I/O under SIGURG (RB-014)
- Socket calls retry
EINTR:tcp_recv,tcp_send,tcp_accept,unix_accept,udp_recvfrom,udp_sendto. The GC suspend signal (SIGURG, noSA_RESTART) and any outside SIGURG made them return -1, dropping a read, a connection or a datagram (zyginit's control socket lost commands under a SIGURG flood). tcp_connect,unix_connectand TLSconnectno longer fail when interrupted; per POSIX the attempt continues, so the runtime waits for it and readsSO_ERRORinstead of giving up.tcp_connect_timeoutwaits withpoll(noFD_SETSIZElimit) and retriesEINTRagainst its deadline.sys.fdfd_open/fd_read/fd_write, the terminal backends'read/write(reefvision, tty) andfscopy / fsync retryEINTR(a pipe, FIFO or terminal read returned -1 and lost the data).fd_closeis deliberately not retried.console.readLine/ streamread_line(reef_fgets) and thereef_freadreads underio.file,io.buffer,io.streamretryEINTR.readLinereturned a spurious blank line (and dropped a part-line) when interrupted;freadcame back short.- Known gap: stdio writes (
reef_fwrite,reef_fflush,print) are not retried. glibc discards the buffered bytes when a flush fails, so a retry would claim success for lost data; writes to a blocked pipe or terminal can still fail onEINTRand report it.
Fixed — reefc doctor / reefc info tool probes (RB-015)
- On a binary install without OCaml,
reefc doctorprinted the shell'scommand not found: ocamland a blankOCaml:line (install16a, Zygaena RC1). OCaml is only needed to build reefc from source; doctor now saysOCaml: not found (only needed to build reefc from source). - All version probes (C compilers, linkers, cmake, ocaml) check that the
tool exists, capture its stderr, and use its output only when it exits
0.
CC=<missing>madereefc infoshow the shell's error as the compiler version; it now says(not found). - The probes use no shell at all: reefc resolves the tool on
PATHitself and execs it with an argv list. ACC/reefc.confvalue with arguments (ccache gcc -m64) is split on whitespace into argv words, Make-style (no quoting or expansion).
Fixed — release tooling
publish-spaces.sh --from-forgefetches the release's source and binary tarballs from the forge before thedist/source check, and reads the release from the forge API's{"data": ...}envelope.pack-foreign-os.shputs the Homebrew directories onPATHbeforeopam env, so on a non-interactive ssh session the darwin pack uses the opam switch'socaml/dunerather than Homebrew's. The promote step's release-id parse also unwraps thedataenvelope.make test-examplesruns fromreef-compiler/, so example tests that use relative template paths find them.
0.10.7 — 2026-10-08
Fixed — installer runs without bash
install.shanduninstall.share POSIX/bin/sh. Binary tarballs install on OpenBSD, FreeBSD, and NetBSD, which do not ship bash.- The install banner names those systems instead of printing
unknown.
Fixed — examples
birthday_calccallsdate_diff_days(today, birthday). The function returns date1 minus date2, so the previous argument order reported a negative age.file_statswith no arguments prints help and exits 0.
Changed — example programs are command-line tools
reefzipcompress and decompress are subcommands, and--helplists the algorithms and arguments.reefsum -uapplies to every digest.json_pretty,file_stats,mandelbrot,download_progress,download_custom_bar, andhttps_fetchtake flags instead of prompting.https_fetchtakes an optional URL.parallel_hashaccepts-j/--jobsand prints--help.- Renames:
graphics_testisgraphics_demo,https_testishttps_fetch, andtls_testistls_connect. Baremetal compiler fixtures moved out ofexamples/into the test suite.
Changed — install documentation
- Binary install notes add per-OS unpack and first-run steps, the macOS
quarantine clear, and an
SDKROOTpin when the active macOS SDK is a TBD stub thatldcannot read.
Changed — documentation
- The traits reference cites reef-lang#5 as plain text.
0.10.6 — 2026-10-06
Highlights
Reef 0.10.6 is a fix and packaging release with no language changes. Runtime fixes for OpenBSD (including its built-in LibreSSL), NetBSD and macOS mean Reef now builds and runs there, and the release download includes reefide alongside reefc, with man pages and install steps for Linux, macOS, FreeBSD, OpenBSD and NetBSD. reefide's Project Settings no longer crashes on projects with empty source folders.
Fixed — Unix / OpenBSD runtime portability
- Unix port slice: register
mainfor GC stop-the-world scans intest_gc_fork; release heap regions only when fully free; skip RWXmprotecton Apple, NetBSD, and OpenBSD; keepnprocand no-SIGPIPE socket options visible on the BSDs. - OpenBSD system LibreSSL: detect system LibreSSL (CMake reports 2.0.0) and map the two OpenSSL 3 names it lacks; other platforms still require OpenSSL >= 3.0 and reject LibreSSL headers.
- Guard
SO_NOSIGPIPEinreef_net(#ifdef); OpenBSD has noSO_NOSIGPIPE(Linux/OpenBSD keepMSG_NOSIGNALon send).
Fixed — reefide Project Settings null-safe source_dirs
- Null-safe
source_dirs_csv(str.is_empty) and fill unusedsource_dirsslots with""so Project Settings no longer SEGV on sparse/NULL entries.
Changed — Unix packaging audit
- Binary tarball (
scripts/package-binary.sh) shipsbin/reefidenext tobin/reefcand installs man pages undershare/man/man1/. install.sh/uninstall.shmirror reefc PATH symlink handling forreefideand installshare/man/when present.dist-docs/rewritten for Linux, macOS (Darwin), FreeBSD, OpenBSD, and NetBSD install-from-tarball flows matchingreef-lang-VER-OS-ARCH.tar.gz.
Added — foreign OS pack and forge catalogs
pack-foreign-os.shfor foreign OS pack/promote.publish-spacescatalogs forge release assets (--from-forge/--refresh/--stage-extra).
Fixed — pack-foreign-os OpenBSD portability
- Remote pack via
/bin/sh(not bash); load opam env on guests;REEF_PACK_ALLOW_TIP=1packs tip without retagging. - Keep OpenBSD package-binary rewrite under
scripts/; replacersyncwith portablecp; fetch tarball list via heredoc so remote globs reach the guest intact.
0.10.5 — 2026-10-02
Fixed — mouse CSI leak and illumos ProcessCapture
- Stop SGR mouse CSI leaking as pane text in reefvision input.
- Rename
ProcessCapturefields off illumos stdio macros (stdin/stdout/stderr).
0.10.4 — 2026-10-02
Fixed — CI goldens after 0.10.3
- Refresh
test_reefvision_settingschrome.expectedto matchok settingschromeoutput. - Regenerate
baseline_call_arg_slots.expected.cfor new process capture FFI decls (process_run_captureand related).
0.10.3 — 2026-10-02
Added — reefide (in-tree ReefVision TUI IDE)
ide/CLI IDE: edit/save buffers, Build/Run viasys.processcapture, Open/Close Project (reef.toml), session chrome restore under~/.config/reefide/session.toml, auto end-block autocomplete, Project Settings (surgical reef.toml), User Settings (settings.tomlprefs), and a list-first File Tree panel.
Added — process capture, settings layout, terminal UI
sys.process.process_run_capture/ProcessCapture(ROADMAP §8.4 L4).- reefvision
FormGridandsettingschrome(Apply/Cancel + discard). - Terminal frame-integrated scrollbar UI; examples install under
share/reef/examples.
Fixed — VT / PTY / capture
- Darwin
test_gc_fork/usr/bin/trueand ptyTIOCSWINSZENOTTY. - VT COW
cur_stylebeforedo_sgrmutates; honor SGR 39/49 default FG/BG. - Capture pipe read-end leaks closed.
0.10.2 — 2026-09-29
Fixed — Illumos PTY STREAMS line discipline and VT sgr0
- On Illumos, PTY slave setup now
I_PUSHesptem,ldterm, andttcompatso the child gets a working STREAMS line discipline. TERM/LINES/COLUMNSare set in the child only after fork, so the parent process environment is not clobbered.- reefvision VT now consumes xterm charset-designate sequences
(
ESC ( Band kin) so sgr0 (\E(B\E[m) does not paint a literalB.
Added — fs.perm.has_execute_bit
perm.has_execute_bit(path)is true when the path's mode has any ofS_IXUSR|S_IXGRP|S_IXOTH. It does not callaccess(X_OK).perm.is_executableremains theaccess(X_OK)authz hint (true for root even when no execute bits are set).
0.10.1 — 2026-09-28
Changed — host statistics and platform build portability
- Non-Linux CPU-count detection now uses
sysconf. - Darwin builds define the required feature macros, use named
sem_opensemaphores for GC acknowledgement, and avoid RWXmprotectduring GC heap expansion to comply with W^X. - Install paths now set
SYSCONFDIR=$(PREFIX)/etcon Darwin and Linux, and Darwin builds auto-detect Homebrew OpenSSL. - illumos host statistics now report CPU utilization through kstat and load
averages through
getloadavg, with the requiredsys/loadavg.hinclude andreefclinkage againstlibkstat. - Darwin host statistics now report CPU utilization via
host_processor_info, memory viaHOST_VM_INFO64, and processes via libproc.
0.10.0 — 2026-09-24
Fixed — wrong-target asm fn call is a typecheck error (reef-lang#70)
A call to asm fn ... for <arch> compiled under a mismatched --target
used to emit invalid C (call sites, no declaration or definition) while
reefc exited 0. Typecheck now rejects reachable calls when no asm fn
variant exists for the current target architecture. Same-arch and
multi-arch (for amd64 / for arm64 / for riscv64) calls are unchanged.
Fixed — stray trailing ! is a typecheck error (reef-lang#78)
A stray ! after an expression was absorbed as prefix NOT on the next
statement. When that statement was a bool-returning call, --check was
clean and behaviour matched the program without the !. Bare
expression-statements whose top node is logical NOT are now rejected;
! in conditions and assignments is unchanged.
Fixed — reef-os UART MMIO uses volatile helpers (reef-lang#80)
uart_16550_mmio and uart_pl011 accessed registers via addr-as-[T]
array views, which could be GC-rooted as if they were heap arrays.
MMIO now goes through volatile_read* / volatile_store* with
addr as pointer. String iteration s as [uint8] is unchanged.
Changed — str.char_at returns option.Option[char]
core.str.char_at no longer returns '\0' for out-of-bounds indexes.
In-bounds indexes yield Some(c); OOB yields None. Call sites that
already prove the index is in range typically use option.unwrap(...).
Changed — html.entity_to_char returns option.Option[char]
encoding.html.entity_to_char no longer returns '\0' for unknown
entities. Known names yield Some(c); unknown yield None. Named-entity
decode (parse_named_entity / html_decode) follows the Option path so
append_char never sees a NUL stand-in.
Fixed — chars.is_ascii includes DEL (127)
core.chars.is_ascii matched printable ASCII only (c <= '~'), so DEL
('\x7F' / 127) was false while docs claimed 0..127. Now c <= '\x7F'.
Fixture proves 127 true and 128 false.
Fixed — 100_UNSAFE.md safe_strlen returns size_t
Doc example safe_strlen returned int after wrapping strlen; post-#72
strlen is size_t, so the example typechecked false. Return type is
size_t to match the libc FFI table.
Changed — drop dead signed-char host wording (docs / comments)
reefvision.input.byte_of_char and reefvision.cell.utf8_decode_step no
longer carry signed-host branches (char is unsigned 8-bit). Crypto
hash pack comments reword to “mask to 8 bits”; & 255 masks kept.
Fixed — private html.append_char refuses embedded NUL (#75 surface)
Internal encoding.html.append_char panics on c == '\0' before writing
(same message as convert.to_string_char). Public entity_to_char API
unchanged.
Fixed — refuse string-literal out-buffers in runtime writers (reef-lang#71)
Safe Reef could pass a .rodata string literal to a writing runtime
helper (reef_getcwd_safe, reef_readdir_name, reef_tls_recv,
reef_string_copy_range, reef_fgets). getcwd on a literal returned
EFAULT → silent n=0; other writers could SIGSEGV writing .rodata
or the static header. Those callees now refuse REEF_FLAG_STATIC_LITERAL
with a clean panic (same shape as str.set_length / str.sync_length).
Allocate with reef_string_alloc(n) for out-buffers. Docs: 105_FFI.md
now maps string → char* (not const char*) and stops teaching the
literal-as-getcwd-buffer pattern. Raw libc.strcpy and friends remain
unguarded (tier-3 / unsafe policy deferred).
Fixed — libc FFI signature table (reef-lang#72)
Whitelisted libc extern "C" names no longer skip every declaration-level
check. reefc now carries a name→signature table (Reef types, LP64:
size_t/long/double) consulted at --check; mismatches name both
signatures. Codegen still suppresses the emitted extern for table names
(headers, ctype macros, qualifiers). Added macro names isxdigit /
iscntrl / isgraph / isblank. Stdlib strlen externs/wrappers now
return size_t.
Fixed — reefvision byte/display/code-point unit mismatches (reef-lang#74)
- (a)
table: removedpad_or_trunc(byte pad/cut into a column-width cell).cellbuf_fill_rectalready pads;cellbuf_draw_text_inalready clips by display columns — accented/CJK cells no longer store a bare UTF-8 lead byte. - (b)
textarea:caret_col/sel_colare UTF-8 code-point indices. Convert to bytes only atstr.substringedit helpers; convert to display columns only at paint and mouse hit-test. Mid-sequence caret is impossible.col_offset/content_widthstay display columns. - (c)
menubar:apply_hot_labeladvances thehotglyph index only on UTF-8 lead bytes so it matchescellbuf_draw_text_hot's code-point contract.
Fixed — no embedded NUL in Reef strings (reef-lang#75)
A char holding byte 0 turned into a string (via "${c}",
core.convert.to_string_char, or a safe s[i] = '\0' store) used to leave
str_len counting the NUL while libc %s / strlen stopped at it — same
value, two lengths, silent truncate on print. Construction now panics
(panic: embedded NUL in string). REEF_STR_HARNESS also rejects finished
strings whose content has non-zero bytes after an early NUL (mid-fill
zero tails remain legal).
Breaking — ReefVision syntax dirs (reef-lang#79)
reefvision.syntax no longer loads syntax files from fixed system-wide and
per-user directories.
Apps must call add_syntax_dir for extra language directories. Bundled
languages still load from $REEF_HOME/reef-stdlib/reefvision/syntax.d when
REEF_HOME is set.
Breaking — 0.10 numeric core (N1–N13)
Integer and float arithmetic now follow Reef rules, not C's. Wrap and
panic live in the emitted C helpers, not in -fwrapv (that flag is
defense in depth only). char / UTF-8 is not this change.
- N1 wrap:
+ - *, unary-, and bitwise~wrap at the result type's width.127i8 + 1i8is-128stored and inline.~0u8is255stored and inline.-INT_MINisINT_MIN. Compound assignment follows the operator./and%do not wrap (N4). - N2 aliases:
int≡int32(Cint).float≡float64(Cdouble). Same shape asbyte≡uint8.int8/int16/int64andfloat32stay distinct. - N3 mixing: mixed integer
+ - *& | ^and comparisons promote by width. Same-width signed/unsigned is a type error (int + uint32). Unsigned → strictly wider signed is implicit (uint8 + int→int). The earlier Unreleased "all signed/unsigned mixes are errors" rule is superseded by this table. - N4
/%: integer division truncates toward zero; the remainder sign follows the dividend./ 0,% 0,INT_MIN / -1, andINT_MIN % -1panic (noSIGFPE, no wrap). - N5 literals: unsuffixed integer literals adapt to context if the
value fits (
let u: uint8 = 255is legal). With no context:intif it fits in signed 32-bit, elseint64, else a compile error.let a: int = 3000000000is now a compile error (it used to wrap). Suffixes are still range-checked (300u8is an error). Constant expressions fold, then the result is range-checked. - N6 int→float: implicit iff every value of the source type is exact
in that float.
int + 1.0isfloat.int + float32andint64 + 1.0are type errors. - N7
as: wraps at run time. Out-of-range constants are compile errors. Float → int truncates toward zero and panics on NaN, Inf, or out of range. Identityasis a no-op. - N8 shifts: the result wraps at the left operand's type. A count
not in
0 .. W-1is a compile error if constant, otherwise a panic (including negatives). No x86-style mask. Signed>>is arithmetic.(255u8 << 1) >> 1is127. - N9
size_t: same type asuint64.size_t + intis a type error;size_t + 1adapts the literal. libc size parameters (malloc,calloc,memcpy,memmove,memsetcount,strncpy/strncmpn) takesize_t. Anintlength needsn as size_t. - N10 subranges:
0..9andsubrange LO HIare not types. Use a fixed-width integer or a type alias...remains for set ranges, slices, and for-loop ranges. - N11 unsafe:
unsafedoes not allow implicit integer narrowing.let b: uint8 = nandlet c: char = nare type errors in every context; writen as uint8/n as char. Widening does not needunsafe. - N12
**: the exponentiation operator is gone, and so isreef_int_pow. Use1 << n, a loop,math.pow_int, ormath.pow_f. - N13 IEEE: float overflow is
Inf/-Inf; invalid isNaN; no panic on float overflow. N7 still panics float → int that does not fit.
Migration. Respell ** and subrange types. Add as at same-width
signed/unsigned mixes and at inexact int→float. Replace overflowing
unsuffixed int literals with int64 (or a suffix). Wrap narrowing
stores with as. Pass size_t into libc size parameters.
Breaking — 0.10 text (T1–T10)
- T1:
charis unsigned 8-bit on every target.200 as char as intis 200 on amd64 (was -56). - T2:
charanduint8convert implicitly both ways. They stay distinct types. - T3:
char + charwraps aschar.char + intisint.char + uint8is a type error.c = c + 1is legal. - T4/T5:
stringis bytes (UTF-8 by convention).s[i]andfor ch in syield an unsigned byte. No validator. - T8:
'\xNN'is a char literal.'\u{N}'is only for strings. - T6/T7/T10: no
runetype, no UTF-16/32string, and no language-levelutf8.validin 0.10. Code points stayint(input.keys.KeyCode.Char).
Fixed — setof ordinal annotation check + OOR membership (reef-lang#73)
setof lowers to a uint32_t bitset. Unguarded C shifts at SetLit/SetMember
were undefined behavior for element values outside 0..31, and non-ordinal
element types (string, structs, …) were accepted at annotation sites because
only SetLit checked ordinality.
- Typecheck rejects non-ordinal
setof[T]annotations and constant elements outside0..31. - Runtime insertion of an out-of-range value is a defined no-op; membership
returns
false(not a C shift UB). - Docs/examples aligned to the
0..31domain (see030_TYPES.md).
Fixed — all three bare-metal kernels failed to link (reef-lang#69)
--entry <name> was used as BOTH the C entry symbol and the reachability root name, so
--entry none — which all three kernel Makefiles pass, and which means only "emit no C
trampoline" — rooted nothing. Every function body was dropped as unreachable while the
forward declarations were still emitted, so the C compiled clean and failed at link:
ld: limine_boot.o: in function `kmain': undefined reference to `reef_main'
Broken since rev 1329 made reachability-based emission the default, and shipped in
v0.9.3. The documented bare-metal default --entry _start had the same fault, and so did
any hosted build with a custom --entry: reefc hello.reef --entry not_a_fn failed to
link.
The reachability root is now always Reef main, independent of the C entry symbol. A
module with no main is a library translation unit with no entry root, so dropping is
disabled for it rather than deleting every body.
Measured, kernel_limine.reef --target amd64-baremetal:
| flags | definitions of reef_main |
|---|---|
--entry none (before / after) |
0 / 1 |
--entry _start (before / after) |
0 / 1 |
All three kernel Makefiles build, and the x86-64 kernel links to a 16 KB ELF with
reef_main at ffffffff80001650.
New gate bm-link/amd64: emits the kernel's C, compiles it and limine_boot.c
freestanding, and runs the real ld with the kernel's own linker script. Every previous
bare-metal check was -fsyntax-only, which cannot see a missing definition — and with
--entry none that unit does not even reference reef_main, so a plain -c is green too.
RED-proven against the pre-fix compiler with the exact linker error. A "every prototype has
a definition" check would not be sound: prototypes of legitimately dropped dead code are
intentional, and extern "C" declarations such as reef_putchar have no Reef-side
definition by design.
The reef-os compile gate was green vacuously and is fixed in the same commit. It passed
--target amd64-baremetal for every module including the riscv64 and aarch64 ones; with
every body dropped there was nothing for gcc -fsyntax-only to read. Each module now
compiles for its own architecture, with the syntax check using the matching compiler and
target. That immediately surfaced reef-lang#70 (filed, not fixed): a call to an
asm fn ... for <arch> compiled for a different target emits neither declaration nor
definition while its ten call sites survive — invalid C with no Reef diagnostic.
Fixed — signed integer overflow was miscompiling core.hash at gcc -O2
reefc now passes -fwrapv, so signed integer overflow is two's-complement
wrapping instead of C undefined behaviour. The three bare-metal kernel
Makefiles carry the flag too, because they compile the emitted C themselves.
This was not theoretical. core.hash's impl Hash for int computes
(x * 33) + (x >> 16) and clears the sign bit with & 2147483647. The
multiply overflows, so at gcc -O2 and -O3 the optimizer deleted the
caller's own guard as provably-false while the value really was negative:
64 probe values, gcc -O2: 32 print NEGATIVE
the program's own `if h < 0` counter reads 0
clang was clean at every level and gcc clean at -O0/-O1, so the default hosted build never showed it — while all three kernel Makefiles build at gcc -O2, the broken configuration. The function's own comment asserts that its mask "is total: it maps every int, INT_MIN included, into [0, INT_MAX]". It was not.
The bug class is self-concealing: a defensive check written in Reef against an already-overflowed value is itself deleted by the optimizer, so neither the code nor a test asserting on it can see the problem.
Measured free. prime_sieve, mandelbrot and json_pretty build
byte-identical at reefc's default, and within 0.1% at -O2. User --cflags are
appended after the flag, so -fno-wrapv still wins for anyone who wants the
old behaviour.
New gate: overflow/<cc>. examples/test_signed_overflow_defined.reef
pins the 64 two's-complement values, and the gate rebuilds every fixture in
reef-compiler/tests/optimized-overflow-manifest.txt at -O2 under each
installed backend. That level is the point: the existing dual-backend leg
builds at reefc's default, which is the one column where this was invisible.
RED-proven by removing the flag — gcc -O2 and -O3 then differ on 32 of 64
lines while clang stays green. A missing compiler is a SKIP that names the
uncovered backend; zero fixtures running is a FAIL.
This is a miscompile fix, not a decision about Reef's arithmetic. Two
limits are deliberate. A flag only covers what reefc itself compiles, so
--emit-c consumers are unprotected and any guarantee the language makes has
to live in the emitted C text. And a #pragma is not a substitute: clang
silently ignores #pragma GCC optimize("wrapv") and still folds x + 1 > x.
Both belong to the 0.10 numeric-semantics work.
Breaking — mixing signed and unsigned integers is a type error
Superseded in part by N3 above. Same-width signed/unsigned stays an
error; unsigned → strictly wider signed (uint8 + int) is now implicit
and is not covered by this section.
Arithmetic (+ - * / %, and so += and friends) and comparison
(== != < > <= >=) between a signed integer (int, int8–int64)
and an unsigned one (uint8–uint64, size_t) of the same width
is now rejected:
Type Error: Arithmetic mixes signed and unsigned integers ('int' and 'uint32').
Reef does not choose a signedness implicitly: the generated C can compute a
different value from the result type, so 'x + y' was -1 but 'y + x' was
4294967295 for x: int = -1 and y: uint32 = 0. Convert one operand explicitly
with 'as'.
Migration: write the as that says which answer you meant —
x + (y as int) for a signed result, (x as uint32) + y for an unsigned one.
Why it had to be an error rather than a better default. The typechecker
picked a result type by width with the left operand winning a tie, then
codegen emitted the bare C operator and let C's own conversions compute the
value. The two disagreed, silently, since rev 163 (v0.5.20, BUG-051 phase 4).
With x: int = -1 and y: uint32 = 0, all measured on 0.9.3:
| Expression | Result |
|---|---|
let s = x + y |
-1 |
let s = y + x |
4294967295 — operand order changed the value |
x + y < 0, sum stored in a let first |
true |
x + y < 0, written inline |
false — naming a subexpression changed it |
(x + y) / 2 inline, vs stored-then-divided |
2147483647 vs 0 |
It reproduced at every width (int8/uint8 gave -1 or 255; int64/uint64
gave -1 or 18446744073709551615), and == had the same fault: -1 == 4294967295
is true in C.
Same-width mixes stay an error because C has no value-preserving answer
(int + uint32). N3 restores the one value-preserving edge C also
gets right: uint8 + int → int. Width-only mixing (int + int64,
uint8 + uint32) still promotes to the wider type.
Unsuffixed integer literals are exempt — they carry no signedness — so
count + 1 compiles on any integer type. On a width tie the result now takes
the non-literal operand's type, which fixes a second order bug: 1 + u on
a uint32 was typed int, so let r: uint32 = 1 + u failed while
u + 1 compiled. A negative literal is unary minus on a literal, not a bare
one, so u + -1 is rejected, as it should be.
Blast radius: zero in-tree. The stdlib compile gate is 179/179 with the rule
active, and that zero is calibrated — a mixed expression planted in
math.basic failed the gate with this diagnostic, then the module was
restored byte-exact. All 32 gated docs contain no violating block, calibrated
the same way. Code outside this repository that mixes signedness will stop
compiling and needs the as above.
Tests: seven reef-compiler/examples/test_mixed_sign_*_rejected.reef (both
operand orders, small widths, <, ==, +=, a negative literal) and
examples/test_mixed_sign_ok.reef for every shape that must keep compiling —
all RED against 0.9.3 first.
Documentation
charis one byte, not a 32-bit code point.015_BASICSsaid otherwise. 0.10 text makes it unsigned 8-bit on every target (Cunsigned char); see Breaking — 0.10 text above.'é'is a lexical error and string indexing yields bytes.floatandfloat64were documented as distinct Cdoubletypes (the 0.9 reference). N2 makes them one type; see the numeric-core section above.- Exponent float literals (reef-lang#58, shipped in 0.9.3) are now in the
reference, including that
1e3is afloat, never anint.
0.9.3 — 2026-09-11
This is a large stdlib release. Sixty-plus changesets since 0.9.2, and the
bulk of them are one programme: removing in-band failure sentinels (C16),
deleting accessors that wrapped public fields (C2), and collapsing duplicated
bodies onto one implementation (C4) across all 22 stdlib packages. input is
a new package. The detail is below, newest first; the shape of it is:
| Theme | What moved |
|---|---|
Sentinels → Option/Result |
sys.process (fork, spawn, setsid, wait_any…), net.http (HttpUrl.is_valid), graphics.get_pixel, crypto.sha256_file, encoding (2 of 55 candidates — the rest triaged as legitimate), gameui, time.days_in_month, sync.mutex_create, compress.{zlib,gzip,lz4}, reefvision |
| Accessors deleted | ~340 across ui (193), reefvision (98+19), graphics (37), gameui (32), sys.optparse (12), compiler (8) |
| Duplicate bodies collapsed | *_failure_kind (11 → 2), crypto hex (5 → 1), compiler.symbol_table (3 → 1 generic), time calendars, the three KeyCode enums (→ input.keys) |
| Language | underscore digit separators; == on a payload-carrying enum is now rejected; setof[EnumType] compiles for the first time (and is rejected where the enum carries a payload); float literal fixes (exponents, 12-digit truncation, overflow) |
| Compiler diagnostics | interpolation fragments report their real line and column |
| Gates | make test-compiler actually runs the tests; docs-check gates every reference chapter; reef-os and the stdlib get compile gates; download paths get their first coverage; source tarballs are gated for name/content version agreement |
Breaking changes are called out per entry. The largest are sys.process's
ForkResult, net.http's Option-returning URL parse, the single shared
KeyCode, and == on payload-carrying enums becoming a compile error.
Fixed — a source tarball could be named for a version it did not contain
scripts/package-source.sh read the version from the working copy but
staged its content from hg archive, which exports the committed parent
revision and ignores uncommitted changes. Run between bump-version.sh X.Y.Z
and the bump commit — the exact window the release procedure creates — it
produced reef-lang-X.Y.Z-source.tar.gz whose four version files all still
read the previous release. Nothing failed: the tarball built, passed
make test, and sudo make install put a compiler reporting the old version
on the path. Measured 2026-09-11 with a 0.9.3-named tarball carrying 0.9.2
sources.
Two rules now close it:
- the version is read from the staged tree, so the tarball's name cannot describe something other than what is inside it; and
- packaging with modified version files is a hard error, because rule 1 alone would quietly ship the old release under the old name while the operator believed they were cutting the new one.
scripts/test-package-source.sh gates rule 1 — the tarball's directory name
must equal the version in dune-project, lib/version.ml, bin/reefc.ml and
reef.opam — and the assertion was RED-proven against the mismatched tarball
that prompted the fix. It waives rule 2 for itself via
REEF_PACKAGE_ALLOW_DIRTY_VERSION=1, since it runs from the live working copy
and must not turn the suite red for the duration of a bump.
Fixed — setof[EnumType] never compiled (ROADMAP §8.5 item 6)
The set lowering spliced the enum value into bitmask arithmetic at three
places — a set literal's 1u <<, a range's endpoints, and membership's >> —
and an enum value is a struct {tag, data}, so every such program was rejected
by the C compiler:
(((s >> reef_M_Colour_Red()) & 1u) != 0)
Meanwhile docs/language/reference/030_TYPES.md has always listed enum values
as valid set elements and shows setof[Color] in its own type table. The
documentation was promising something that could not be built. All three sites
now take .tag, as the ==/!= arm already did. Pre-existing; found while
probing reef-lang#68 and verified identical against the pre-#68 typechecker.
The codegen fix alone would have been a net harm, which is the part worth
reading. With it, setof[<payload-carrying enum>] compiles — and silently
drops the payload:
Shape_Circle(2.0) in {Shape_Circle(1.0)} // would answer true
That is reef-lang#68's defect in a new place. Before the fix the C compiler was rejecting it by accident; after it, the program would compile and lie. So a payload-carrying enum is now rejected as a set element, on the same rule and for the same reason as #68 — a set stores only the variant tag, so a payload cannot round-trip. Payload-free enums in sets work, and now actually build.
Breaking — reefvision's indexed getters return Option[string] (C16, ROADMAP §8.5 item 6)
Six getters that returned "" for "no such index" now return
option.Option[string]:
| Was | Now | _or twin |
|---|---|---|
listbox.item(idx) |
Option[string] |
item_or(idx, fallback) |
radio.label(idx) |
Option[string] |
label_or(idx, fallback) |
combo.item(idx) |
Option[string] |
item_or(idx, fallback) |
combo.selected_text() |
Option[string] |
selected_text_or(fallback) |
syntax.lang_id(lang) |
Option[string] |
lang_id_or(lang, fallback) |
textarea.selected_text() |
Option[string] |
— |
filereq.selected_path() |
Option[string] |
— |
Migration — match, or take the _or twin where a default is what you meant:
// before
let name = lb.item(lb.selected)
if str.length(name) > 0
...
end if
// after
match lb.item(lb.selected)
case Some(name)
...
case None
end match
// or, where a default really is the intent
let name = lb.item_or(lb.selected, "(none)")
The miss here is genuinely ordinary under 035_ERROR_HANDLING.md's rule:
selected is 0 on an empty list, so the chained shape
self.list.item(self.list.selected) fed a sentinel index into a sentinel
string with nothing in between to notice. filereq.selected_path went with the
group because a file requester returning "" is exactly the shape that gets
handed straight to an open() call.
Two got no _or twin, deliberately. textarea.selected_text has zero callers
that wanted one — building one for nobody is speculation, not symmetry — and
its inner returns still yield "" on purpose: a zero-width selection really
is the empty string, which is a value, not an absence. Only the no-selection
guard became None.
Breaking — == on a payload-carrying enum is a compile error (reef-lang#68)
== / != on an enum that declares any variant with a payload is now
rejected at typecheck:
Type Error: '==' is not defined for enum 'Shape', which has variants that carry
a payload: it would compare only the variant tag and ignore the payload, so
Some(1) == Some(2) would answer true (reef-lang#68). Use 'match' to test the
variant and its payload together.
Codegen emitted (a).tag == (b).tag and never read .data, so the comparison
answered "same variant?" while reading like "same value?". It reached
core.option and core.result:
@option.Option[int].Some(1) == @option.Option[int].Some(2) // was true
@result.Result[int, error.Error].Ok(1) == Ok(999) // was true
Payload-free enums are unaffected and still compare with == — at the time
of the fix that was 63 of the stdlib's 82 enums, and tag-only is correct for
every one of them: with no payload, "same variant?" is the whole question.
That is also why this survived so long.
Three questions hide inside == on an enum. (a) same enum type? was always
static — two structurally identical but distinct enums were already a type
error. (b) same variant? is the tag compare, and is correct. (c) do the
payloads match? was never asked. The rejection fires exactly where (c) would
be needed.
Rejecting rather than comparing structurally is deliberate: Reef's == is
reference identity for structs, arrays and objects, with string the one
structural exception. Comparing payloads structurally would make an enum
holding a struct answer false for two equal-valued structs — trading this
footgun for a subtler one — and would still need a rule for payloads that have
no equality at all (arrays, closures, active objects). Documented in
030_TYPES.md under "Comparing Enums".
Zero call sites in the tree needed changing; the stdlib compiled unchanged.
Pinned by a pair: test_enum_eq_payload_rejected.reef (EXPECT_FAIL) and
test_enum_eq_payload_free_allowed.reef, which is the over-firing guard —
if the check ever widens to all enums, it stops compiling.
Fixed — math's integer functions stopped returning wrapped answers (reef-lang#65)
Five defects, all reachable at ordinary inputs because Reef's int is 32-bit
by design:
abs(INT_MIN)returnedINT_MIN— negative.gcdandlcmboth called it.lcmcomputeda * bbefore dividing by the gcd, solcm(196608, 65536)returned0— which is also whatlcmreturns when an input is 0, making a wrong answer indistinguishable from a real one.square/cube/pow_intwere unguarded;pow_int(2, 62)returned0.random_intnegated where its comment said it masked, and-INT_MINis still negative.rng_stateheld the canonical xorshift32 seed0x92D68CA2as anint, so it truncated to-1831433054and every importer emitted a compiler warning.
abs, square, cube, pow_int, gcd and lcm now return
option.Option[int], None on overflow. gcd runs Euclid on signed values
and takes the absolute value only at the end; lcm divides first
(q = a / g, then a checked multiply). The PRNG is uint32 throughout, and
its output was verified against an independently computed reference xorshift32
sequence — the old .expected had pinned a wrong sequence.
Fixed — interpolation fragments report their real column (reef-lang#66)
Sibling of reef-lang#11. That fix made an error inside ${...} report the
right line; this one makes it report the right column. Fragments now
carry their own source position (RawExprPart gained a location, and
interp_parts widened to carry one per fragment) instead of being re-lexed
from position zero.
The sub-lexer recomputes pos_cnum buffer-relatively, so the fix sets
pos_bol = -column and pos_cnum = 0 to make the arithmetic land on the
original column. The first attempt looked correct against a fixture set where
every bad fragment sat at the string's end — a confounded corpus that reported
a constant offset. Only errors placed inside a fragment exposed the real
rule.
Fixed — make test-compiler ran no tests, on every push (reef-lang#67)
The target reported success without executing the suite, so CI's compiler-test
step had been vacuous. Each of tests/{lexer,parser,typechecker} now resolves
reefc through one shared reef_test_support module that anchors on
Sys.executable_name and is fatal on a miss — it never silently falls back
to an installed reefc, which is the failure mode that made the green
meaningless. The dune (deps %{exe:../../bin/reefc.exe}) edge makes the tests
re-run when the compiler actually changes.
Added
-
Underscore digit separators (reef-lang#59). The spec has documented
1_000_000and0.1_234since v2 (reef-language-spec-v2.md:456,464); the lexer never had them, so1_000_000lexed as the integer1followed by the identifier_000_000and died with "Undefined variable '_000_000'".Separators work in every radix, in typed-suffix forms, and in floats including the exponent:
let million = 1_000_000 let mask = 0xFFFF_FFFFu32 let bits = 0b1010_1010 let perms = 0o7_5_5 let ratio = 0.1_234 let big = 1.5e1_0They are source-level grouping only — stripped in the lexer action, so nothing downstream ever sees one. Measured: two files differing only in separator placement emit byte-identical C.
A separator must sit between two digits; leading, trailing and repeated separators are rejected with a "Misplaced digit separator" error naming the radix. That rule is not a taste call —
encoding.toml'sscan_digit_runalready enforced exactly it for TOML input ("'_'must be surrounded by digits of the same base"), and Reef source accepting what Reef's own parser rejects would have been an odd place to differ._1stays an identifier, because_is a legal identifier start and the digit-run rules therefore begin with a digit.Sibling of reef-lang#58 from the same spec block, fixed separately because separators touch every integer rule — eight typed-suffix forms per radix plus the bare form, 38 rules in all — where the exponent fix touched two.
Changed — net.http's download result is a sum type (ROADMAP §8.2)
Two internal structs became one enum:
type DownloadOutcome = enum
Succeeded
Redirected(string) // the Location to follow, never empty
Cancelled // the progress callback returned false -- NOT an error
Failed(string) // the reason
end DownloadOutcome
DownloadResult carried success + is_redirect + location + cancelled +
error — three booleans for four outcomes, so success && is_redirect && cancelled was representable and meant nothing. Every consumer already
destructured it as a sum type, with an if/elif chain.
DownloadResultVerbose is gone entirely, which measurement decided rather
than taste: bytes_received was write-only (ten writes, zero reads) and
content_length was producer-internal — a loop guard, and a value copied into
HttpDownloadProgress. Neither was outcome data. content_length is a local
now; bytes_received is deleted.
net/http.reef is 78 lines shorter. No public API changed — both types were
internal.
The Redirected payload cannot be empty, and all six producers enforce
that at construction. This has a visible consequence: a 3xx whose Location
header is missing now reports
Err("redirect without a Location header") // was: Err("Download failed")
because the producer is the only place that can tell, and making the variant
honest forces it to decide. The consumers need no str.length(location) > 0
guard as a result — the old one was doing the type's job.
Cancelled remains its own outcome rather than a flavour of Failed, because
http_download_file_callback returns Ok(false) for it: a user who cancels
has not hit an error. Folding the two would have changed that, and
examples/test_http_download_local.reef fails loudly if anyone tries.
Added — coverage for net.http's download paths
examples/test_http_download_local.reef (14 checks) drives
http_download_file, http_download_file_callback and
http_download_progress, which had no behavioural test at all. The only
two programs that reached them — download_progress and download_custom_bar
— are skipped by run_tests.sh:197 as network tests; they fetch
https://www.zlib.net/zlib-1.3.1.tar.gz. So the redirect dispatch, the cancel
path and the error propagation were untested.
The fixture is pure Reef: a forked child serves a scripted sequence over
net.tcp, following test_http_local_server's precedent. No python, no
external daemon, no network. It covers a plain 200 (file written, bytes
verified), a 302 that must be followed to the real body, a 404 (must be
Err, not a silently-empty file), a callback cancel (must be Ok(false),
which is not an error), and http_download_progress's byte accounting.
The child is killed, not waited for, and that is deliberate. Matching the
connection count to the scenarios exactly is fragile: if net.http ever
changes how many connections a redirect or a cancel costs, a counted script
leaves the child blocked in accept and the parent blocked in wait, and the
test hangs instead of failing. A hang reports nothing. The script is
deliberately longer than needed and the parent kills the child when its
scenarios are done.
Gate quality is RED-proven three ways, each against a specific way the
forthcoming DownloadResult reshape could break:
| Injected break | What fires |
|---|---|
drop the str.length(location) > 0 redirect guard |
and did not write the empty 302 body |
fold cancelled into the error arm |
a cancelled download is Ok, NOT Err + and it reports false |
| report success without writing | 404 is an Err, plus the progress checks |
Breaking — sys.optparse and reefvision read fields instead of accessors
30 accessors deleted whose whole body was return self.<a differently-named field>. Where the accessor had the better name, the field was renamed to it
and the accessor removed; where the field was the more accurate half, it
kept its name.
sys.optparse (12). The fields were abbreviated because the method had
taken the good name — a method and the field it returns cannot share one. So
deleting the method freed the name, and call sites lose two characters:
flag.value() -> flag.value // field was .val
flag.present() -> flag.present // field was .was_set
rest.values() -> rest.values // field was .vals
cmd.name() -> cmd.name // field was .cmd_name
cmd.path() -> cmd.path // field was .path_str
cmd.invoked() -> cmd.invoked // field was .invoked_leaf
BoolFlag.default_val is default_value to match. Rest.len() stays — it is
self.values.length(), which is derived.
reefvision (18). .th -> .theme, .cmds -> .commands, .open_flag
-> .is_open, .hangup_flag -> .hangup, .list_open -> .is_open,
.sb_dragging -> .is_sb_dragging, .indeterminate -> .is_indeterminate,
.closable/.maximizable/.minimizable -> .is_*, .count ->
.sample_count, .pid -> .child_pid, .capacity -> .max_lines.
Four kept the field's name and dropped the accessor, because the field said
it better: .item_idx (over selected_item() — it is an index), .show_gutter
(over line_numbers() — a display toggle, and set_line_numbers already owns
that vocabulary as a setter), .fill_cycle_idx and .canvas_fill_override
(the accessors carried a redundant desktop_ prefix on a Desktop method).
text.string_interner.count(i) is now the field i.count (was .size).
text.stringbuilder's two are untouched — explicitly deferred by the core spec.
Two accessors that look identical and are not
menubar.is_open() stays a method: its body is self.open_idx >= 0, which
is derived, and C2 keeps derived predicates. desktop.is_open() and
combo.is_open() were plain field reads and are gone. Same spelling, opposite
answers — the body decides, not the name.
view.hit_bounds() stays a method for a different reason: it is virtual.
The base body is return self.bounds, but TextArea overrides it to add the
frame-column thumb. Deleting it would have removed a dispatch point, not a
wrapper, and every TextArea mouse hit would have silently shrunk by a column.
The §8.6 accessor rule cannot see virtuality; ROADMAP §8.2 now says to grep for
override .* fn <name> before deleting any method-shaped accessor.
examples/test_reefvision_field_names.reef pins it and RED-proves against
deleting the override.
Breaking — graphics drops six field accessors (C2, ROADMAP §8.2)
Deleted, each of which was return self.<the same name> over a public field:
| Was | Read instead |
|---|---|
Surface.get_width() / .get_height() |
s.width / s.height |
Surface.get_format() / .get_pixels() |
s.format / s.pixels |
FramebufferSurface.present_count() |
f.presented |
ttf.ttf_font_valid(f) |
f.valid |
These were shared fn methods on an object hierarchy, not free functions,
so the question to settle first was whether any was a dispatch contract —
MemorySurface and FramebufferSurface both extend Surface, and deleting a
virtual method is not a C2 sweep. Measured: nothing overrides any of the six.
Only kind() and end_frame() are overridden.
The surface_width / _height / _format / _pixels free functions stay.
They read the fields directly now, which means the §8.6 accessor instrument
counts them as renaming accessors — and it should, because the rule cannot
distinguish a redundant wrapper from a public vocabulary. These are the latter:
15 call sites across graphics, gameui and the examples, and they are how
every consumer outside backend/ addresses a Surface. ROADMAP §8.2 records
that so a later re-measure does not read the count as debt.
Three call sites outside graphics moved, all in tests.
examples/test_stdlib_surface_objects.reef gains two assertions while it is
there: that an inherited field (width, declared on Surface) is readable
through a narrowed FramebufferSurface, and that it agrees with
surface_width on the same object.
Breaking — two encoding lookups report absence instead of a sentinel
-
json.json_get_typeandjson_get_type_docreturnoption.Option[int], not-1for a key the document does not have. The-1travelled straight out through the public_docwrapper, where nothing distinguished "no such key" from a type tag. -
toml.toml_array_len_docreturnsoption.Option[int], and gains thetoml_array_len_doc_or(doc, key, default)twin that its own family already had (toml_array_item_doc/_or).Some(0)is an empty inline array;Noneis not an array (missing, a scalar, or an inline table). The function's previous comment reasoned about exactly that distinction in prose — "An EMPTY inline array returns 0 … 'Absent' is still the -1 below" — which is the argument for putting it in the type instead.Of its four
reefvisioncallers three tested the-1(n <= 0,n <= 0,n < 3). The fourth,syntax.reef:736, did not:while ri < nrsimply never enters whennris-1, so it was correct by accident. It now readstoml_array_len_doc_or(doc, key, 0).
encoding's sentinel triage: 55 sites, 2 sentinels
The remaining 53 are recorded in ROADMAP §8.2 as deliberate, not deferred:
- ~25
""from encoders and trimmers, where empty-in-empty-out is the correct value (§8.2b already ruled this shape); - 6
""frombase64_decode_internal, whose failure channel is a documentedstatusout-param — the string is explicitly "only meaningful whenstatus[0] == 0"; - 24
-1from private lexer scanners, a checked internal convention where every caller tests it:int_end < 0,hour < 0 or hour > 23(a-1falls outside the range),run_end != len(a-1can never equallen); MSGPACK_UNKNOWN(), a named constant whose value is-1— the same shape ascrypto's round-constant tables, and the same trap for a grep;html.char_to_entity's"", which all three callers concatenate, and which is a correct no-op for a character needing no entity.
This is the second time a sentinel count collapsed under reading — crypto
went 41 → 1 for the same reason. A count of sentinel-shaped text is not a
count of sentinels; triage before scheduling.
Changed — one *_failure_kind per shape, not eleven (C4, ROADMAP §8.2)
io and fs between them declared sixteen *_failure_kind functions.
Eleven were copies of two bodies. They now live once each in fs.stat:
fn io_failure_kind(path: string): error.ErrorKind // exists ? IoError : NotFound
fn access_failure_kind(path: string): error.ErrorKind // exists ? PermissionDenied : NotFound
Nine copies of the first (io.buffer.open, io.stream.open, io.file.open /
delete / fsync, io.dir.remove_dir, fs.ops.remove_file, fs.ops.rename,
fs.stat.stat) and two of the second (fs.perm.perm_failure_kind, and
io.dir.change_dir_failure_kind, which wrote the same predicate inverted).
All eleven were private, so no public API changed. fs.stat is the home
because it already owns exists(), all six consumers already import it, and it
imports only core and the runtime.
Five are genuinely different and keep their own bodies: io.dir's
create_dir / create_dir_all / list_dir, fs.link's (an existing path is
AlreadyExists there, not IoError), and fs.ops.copy, which takes two paths.
A constraint moved with them. io.buffer had a warning on its own copy:
the helper must never classify a write open, because fopen(path, "w")
does not create the file on failure, so an exists()-recheck always sees the
path absent and reports NotFound for what is really permission denied on the
parent directory. That is a property of the helper, not of one caller, and it
now sits on io_failure_kind. The tree already honoured it —
io.stream.stream_open branches on mode first, and io.buffer's write and
append opens use a flat IoError.
examples/test_fs_failure_kind.reef (18 checks) drives every entry point whose
copy was deleted, through both branches of the predicate, and both of its
load-bearing assertions are RED-proven: routing change_dir to the wrong
helper, and routing a write-open through io_failure_kind, each make it fail
with the diagnostic that names the mistake. These functions decide which
ErrorKind a caller sees, so a mis-wire compiles cleanly and only shows up as
a misclassification.
Breaking — gameui drops its 32 field accessors (C2, ROADMAP §8.2)
The ROADMAP row read "34 renaming accessors, each a judgement call, not a sweep." Measured, it was 32 accessors of which only 11 were judgement calls, and for those the judgement went the other way from what the row assumed.
-
19 were pure redundancy —
style_backgroundover a public field namedbackground(seventeen of that shape),ui_hot_id/ui_active_id, andmouse_x/mouse_yover fields of the same name. Deleted; read the field. -
11 were abbreviation-versus-full-name, and the FIELD was renamed rather than the accessor kept, because one name per concept beats two:
was now Style.button_h/.checkbox_s/.slider_h/.slider_thumb_s/.text_field_h.button_height/.checkbox_size/.slider_height/.slider_thumb_size/.text_field_heightUIContext.surf/.inp/.st.surface/.input/.styleInputState.text.text_inputscroll_x()andscroll_y()are the exception: they returned.scroll_dxand.scroll_dy, and the fields were the better half of that pair — they are deltas, set per frame from a wheel event. The fields keep their names.A
UIContextfield namedsurfacesits alongsideimport graphics.backend.surface as surfacewithout trouble (the receiver disambiguates); the constructor parameters keep the short names, because a parameter of that name genuinely would shadow the module.
Nothing outside gameui used a single one of the 32, which is what made
this a sweep rather than an API negotiation.
Fixed — gameui state stores could fail invisibly
find_state and ensure_state return option.Option[int], and
ui_set_float / ui_set_bool now return bool instead of the
UIContext they were handed.
§8.3 recorded ensure_state's -1 as "a silent drop". The drop was real but
it was one step further out than the sentinel: both call sites already tested
the -1 — and then returned the unchanged UIContext anyway. A UIContext is
a reference, so that return value carried no information at all. A caller who
filled the 256-entry state table got back the same context it passed in, with
the value not stored and no way to distinguish that from success.
examples/test_gameui_state.reef (29 checks) fills the table and asserts that
the set reports the failure and that the value really was not stored.
Breaking — one KeyCode for the whole tree (input.keys, ROADMAP §8.2a)
ui.event, reefvision.event and gameui.input each declared their own
KeyCode. They were three different types, not three copies of one — the
duplication survey that flagged them compared function body text, and
return KeyCode_Enter() reads identically in two files that mean different
things by it.
Owner decision, 2026-09-10: one shared KeyCode in the new input.keys,
whose Char carries an int Unicode codepoint.
import input.keys as keys
let k = keys.key_char(0x1F600) // an emoji, past the BMP
keys.key_codepoint_or(k, 0) // 128512
keys.keycode_eq(k, keys.key_char(0x1F600)) // true
ui.event.KeyCode.Char(char)→keys.KeyCode.Char(int). A Reefcharis one byte, souicould not represent a non-ASCII keypress at all. This is a breaking change in the direction of correctness.ui.event.key_pressnow takes anintcodepoint.ui.event.KeyEvent.characterandreefvision.event.KeyEvent.chare both gone. Each was a second place to keep the codepoint, and therefore a way for the two to disagree. Read it withkeys.key_codepoint(code)(orkey_codepoint_or, since asking a non-printable key for its codepoint is ordinary rather than a failure).reefvision.event.key_eventlost itschparameter — the code carries it now.key_esciskeys.key_escape;keycode_tagiskeys.keycode_index.- The arrow constructors are
key_arrow_up/_down/_left/_right. Plainkey_upalready means "a key was released" inui.event, and a shared module is exactly where that collision would have bitten. Modifiersmoved too, withmodifiers_none()as the single spelling (ui.event.no_modifiersis gone).gameui.inputkeeps its ownKeyCodeand shares onlyModifiers. That enum exists to be indexed — one variant per letter and digit feedingkey_index()into a fixed-size array — which is a different design, not a copy of this one.
Fixed — reefvision.commands accelerator matching
accel_eq had been relying on keycode_eq being tag-only to fold Alt-q onto
an Alt-Q binding. Once the shared keycode_eq started comparing the payload,
that fold stopped happening and the two accelerator tests went red. It now asks
for the variant explicitly (keycode_index) before applying the documented
case-folding, which is what the function's own comment always said it did.
This was the only behavioural regression in a port that touched 35 files, and the suite caught it.
Known defect surfaced by that port — == on an enum is tag-only
Filed as reef-lang#68, with severity high, because it reaches the stdlib's own core types:
@option.Option[int].Some(1) == @option.Option[int].Some(2) // true
@result.Result[int, error.Error].Ok(1) == Ok(999) // true
codegen.ml's Eq/Ne arm emits ((a).tag == (b).tag) for every enum, with
no branch on whether the enum declares a payload-carrying variant. Tag-only is
correct for a payload-free enum, which is most of the tree — which is why
this stayed invisible until KeyCode gained a payload. A grep of the whole
tree found zero live sites comparing an Option or Result with ==, so
nothing is getting a wrong answer today; it becomes live the moment someone
writes the obvious thing. input.keys.keycode_eq exists to avoid it, and
examples/test_shared_keycode.reef asserts the trap directly so the workaround
cannot be "simplified" away while it is still open.
Changed — three cross-package duplicate bodies collapsed (C4, ROADMAP §8.2a)
io.file.rename now calls fs.ops.rename; io.file's private
rename_failure_kind is deleted. Both files carried a comment naming the other
and stating that the bodies were independent — which describes the setup for a
drift rather than a defence against one. Two public names for one operation is
right (io.file is the file API, fs.ops the tree API); two bodies is not.
io.dir.is_directory now calls fs.stat.is_directory. io.dir.dir_exists
was deliberately left alone — it is not a third copy, despite looking like
one: it runs an access(2) check before the isdir, so it asks a different
question even where the two answers agree.
ui.widget.Size, ui.widget.size() and ui.widget.size_zero() are deleted
rather than shared. They were a byte-identical copy of graphics.core's, and
every other file under ui/ was already spelling it core.Size — only
constrain_size, in the same file, used the local one. constrain_size now
takes and returns core.Size. No caller outside ui/widget.reef was affected.
Fixed — ROADMAP §8.2a was measuring text, not types
Three of that section's five "duplicated across packages" rows were the instrument, not the tree. The duplication pass hashed function body text, and body text is identical whenever two different enums happen to share a variant name:
ui.event.key_enter()andreefvision.event.key_enter()are bothreturn KeyCode_Enter()— naming two unrelated enums.ui.event.KeyCodehasChar(char)plusNone/Escape/Space;reefvision.event.KeyCodehas a payload-freeChar(the codepoint lives inKeyEvent.ch: int) plusEsc/BackTab/Insert/Unknown;gameui.input.KeyCodehas no payloads at all and spellsA..Z,0..9,F1..F12as separate variants feeding a densekey_index().text_align_left/_center/_right:graphics.font.font.TextAlignhas a fourth variant,Justify.point/size_zero:Pointis float x/y ingraphics.coreand int x/y inreefvision.cell(terminal cells).
The keycode module §8.2a asks for is therefore a design decision, not an
extraction, and the sharpest edge is Unicode width: Reef's char is one byte,
and reefvision decodes UTF-8 into an int codepoint. §8.6 gained the general
rule (hash the TYPE, not just the text — within a package that collision is
nearly impossible, across packages it is the normal case).
Breaking — reefvision stops encoding "absent" and "failed" as negative ints (C16)
Four public entry points changed shape. Each returned a negative number that
the caller had to remember to test, and forgetting was not a no-op — -1 fed
straight back into an index is a bounds violation, so the sentinel put memory
safety on caller discipline:
| Was | Now |
|---|---|
Desktop.window_hit_test(x, y): int |
Option[int], None for no window there |
Desktop.window_drag_owner(...): int |
Option[int] |
sys_glyph_x(...): int |
Option[int], None when the titlebar glyph is not shown |
yield_exec(...): int |
Result[int, error.Error] |
self.window_at(-1) was the shape that made this urgent.
sys_glyph_x was safe only accidentally: its three callers compare
x == sys_glyph_x(...) and a real screen x is never negative, so the sentinel
could not match by mistake — it was doing no work the type could not do better.
yield_exec is the one to read carefully, because three unrelated meanings
shared one int:
-2 could not launch (missing path / not executable / spawn failed)
-1 the child ran, but the terminal could not be restored afterwards
0..255 the child's exit status, 128+sig when it was signalled
The ambiguity was live enough that the existing test already carried a
defensive assertion against it. Ok(status) now means the child ran; the two
failures are Err with distinct messages.
window_hit_test had a second return the first pass missed — an early
return self.active_idx before the loop. The compiler caught it, not review.
Changed — reefvision helpers exist once (C4)
Eleven redundant copies of ten helper bodies are gone; reefvision's internal
identical-body duplicate count is zero. is_left_click and is_activate_key
now live in reefvision.event, next to the event type they inspect, and are
exported — seven definitions replaced by two. clamp_byte, clamp_dim,
min_int, ansi16_r / ansi16_g / ansi16_b and rgb_cube_level likewise
moved to their owning module and are exported rather than re-declared.
The event predicates are worth a note because no single instrument found them.
is_left_click sat privately in button, checkbox and radio;
is_left_press in combo and window. They are the same predicate, spelled
differently only because one combined two guards with or — so a body-identity
check reported two separate duplications (3 copies and 2 copies) rather than
one predicate copied five times, and a name-only check would have called them
two unrelated functions. Reading the pair is what saw it.
Breaking — crypto (ROADMAP §8.5 item 4)
-
sha256_filereturnsresult.Result[string, error.Error], not""on a failed open. A SHA-256 digest is never the empty string, so the old sentinel was at least detectable — but it carried no reason, and a caller who skipped the check would compare""against a real digest and simply get "not equal". This is the package's only genuine failure channel: hashing a string cannot fail, which is whycryptositting at 0/6 on Option/Result was mostly correct-by-nature rather than debt. -
md5_hex,md5_hex_lower,sha1_hex,sha1_hex_lower,sha256_hexandsha256_hex_lowerare deleted. All six had exactly one line in the body —return md5(data)and its twins — and the suffix was actively misleading:crc32.crc32_hex("123456789") -> CBF43926 (UPPERCASE) md5.md5_hex("abc") -> 900150983c… (lowercase)The same
_hexsuffix meant opposite things in two modules of one package. Deleting the aliases removes the ambiguity without renaming anything that works today:md5(),sha1()andsha256()already return the canonical lowercase digest, andcrc32_hex/crc32_hex_lowerremain an unambiguous pair within their own module. -
crc32_bytes(data: [int], len: int)is deleted. It wascrc32_binary(data: [byte])with a worse signature — an[int]standing in for bytes, plus a separate length the array already carries.
Changed — crypto hex formatting is one module (C4)
crypto/hexfmt.reef is new and replaces five copies of the same machinery:
| Was | Copies |
|---|---|
md5_hex_digits / sha1_hex_digits / sha256_hex_digits |
three copies of the literal "0123456789abcdef" |
sha1_state_to_hex / sha256_state_to_hex |
byte-identical apart from the word count |
md5_state_to_hex |
the same loop, walking each word's bytes the other way |
crc32's get_hex_digits_upper / _lower |
a fourth and fifth copy of the table |
crc32's int_to_hex_upper / int_to_hex_lower |
one loop written twice, differing only in which table it indexed |
crc32.reef alone went 411 → 256 lines. The buffer-filling loops live in
crypto.hexfmt rather than delegating to encoding.hex for a measured reason:
encoding.hex.byte_to_hex allocates a fresh string per byte, which would put a
per-byte allocation in a hash's hot path. hexfmt writes into one preallocated
buffer, as all five originals did.
Behaviour is unchanged, and that is asserted against published vectors rather
than against this implementation's own output —
examples/test_crypto_hexfmt.reef (24 checks) pins MD5/SHA-1/SHA-256 for "",
"abc" and the pangram, the RFC-2202/4231 HMAC pair, the CRC-32 catalogue check
value CBF43926, and int_to_hex at 0, 255, -1 and INT_MIN — the last
two being the case each hand-written copy had to get right on its own, since C's
% can go negative and the divide has to behave as a logical shift.
Breaking — stdlib C16, the "small tier" (ROADMAP §8.5 item 2)
Six stdlib entry points stopped reporting failure with an in-band value. Each change is here because a call site in this tree was measured getting it wrong, not because a sentinel is untidy.
-
sys.process—process_forkreturnsForkResult, notint.match process.process_fork() ForkResult_Child() => ... process.exit_now(0) end ForkResult_Parent(pid) => ... process.process_wait(pid) end ForkResult_Failed() => ... end end matchThe measurement: four of the five
process_forkcallers in the tree never tested for-1. A failed fork therefore ran the parent branch, and theprocess.process_wait(pid)that followed wasprocess_wait(-1)— which waits for any child, not for the one that was never created. The one caller that did check (examples/test_http_local_server.reef) is the reason the enum has aFailedarm rather than anOption: the three outcomes are three different places to be, not one value plus an error.Also in
sys.process, same release:process_spawn,process_spawn_shell,process_run,process_setsid,process_getpgidandprocess_wait_anyreturnoption.Option[int];process_setpgidandkillpgreturnbool(matchingprocess_kill, whose success carries no value); andprocess_wait_any_nohangreturns a newWaitAnyResultenum —Exited(pid)/NoneReady/Failed— because its three answers used to arrive aspid/0/-1, one of which means loop-again and one give-up, told apart only by a caller who remembered both.process_wait's-1is deliberately NOT converted. It is entangled with theprocess_was_signaled()/process_exit_code()/process_term_signal()global side-channel — the exit status lives in module state, not in the return value — and that is a separate design problem across 17 call sites. -
net.http—HttpUrl.is_validis gone.http_parse_urlreturnsoption.Option[HttpUrl]andhttp_request_newreturnsoption.Option[HttpRequest].A
boolfield is aMaybespelled as a product, and therefore aMaybea caller can forget to open. Two of the six call sites innet/http.reefdid exactly that: both redirect loops re-parsed the current URL after a 3xx and used the result without ever reading the flag, so a malformed URL became a zero-hostHttpUrlthat was then formatted into a request line. Both are now forced by the type. The redirect loops also parse each URL once now (request_forbuilds a request around an already-parsedHttpUrl), where before they parsed it twice per iteration.Two guards were deleted as unreachable:
http_send_timeoutandhttp_send_binaryeach opened withif req.url.is_valid == false, and aHttpRequestcan no longer exist without a URL that parsed."://host/"still parses, with an empty scheme — that rejection happens downstream inhttp_send_timeout("Unsupported scheme"), not in the parser. Pinned byexamples/test_http_parse_url_option.reefrather than assumed.
Changed — stdlib hygiene
-
test.framework: nine copies of the assertion skeleton collapsed to one.assert_eq_int/_bool/_string/_float,assert_true/_false,assert_not_eq_int/_bool/_stringandassert_contains_stringeach carried their own copy of increment count, increment passed-or-failed, print "." or the FAIL header. That skeleton is now oneexclusive fn tally(ok, message): bool, and the two operand lines are one module-levelshow_operands. Public API unchanged; 268 -> 200 lines.assert_eq_floatkeeps printing its own operands on purpose:print_floatemits C's%f(1.500000) while string interpolation emits1.5, and that difference is pinned bytest_framework_new.expected. The refactor is byte-identical on all three framework tests. -
ROADMAP §8.2/§8.3/§8.5 re-stated against the tree, including one item measured and then deliberately left:
net'sDownloadResultencodes four outcomes in three booleans (success+is_redirect+cancelled), so illegal combinations are representable. It is internal-only, 55 boolean-field touches across 6 functions, and no defect is reachable from it today — which is why it is recorded rather than done.
Fixed
-
Diagnostics inside a
${...}interpolation fragment report the real line number (reef-lang#11 / BUG-182). An error at a node inside a fragment reported a line in the millions whenever any literal text preceded the first${:println("a=${n + 1} b=${nope + 1}") -> Name Error: Undefined variable 'nope' at inner_err.reef:line 3000003, column 37 (should be line 3)Codegen_util.interp_part_locaddedidx * 1_000_000to the line each fragment's parse was seeded with. That offset existed to keep (line, column)-derived C identifiers from colliding across sibling fragments of one string — two${match ...}fragments used to collapse onto one__reef_localsGC root slot (BUG-139). 0.9 Stage 4b re-keyed every synthesized temp to its node'seid, which left the offset with no job except to corrupt diagnostics, sointerp_part_locis deleted rather than adjusted.The issue carried an explicit "do not fix by dropping the offset" warning, because dropping it would re-open BUG-139 and BUG-184. That warning is spent, and it was verified spent rather than assumed:
- With the offset gone the emitted C is byte-identical across all 909 corpus units, which is the proof that nothing derives identity from those locations.
- That corpus includes BUG-139's own repro
(
test_match_scrutinee_interp_disambig.reef— two${match}fragments in one string), whose generated C still shows two distinct temps (__scrut_e7,__scrut_e12) and four distinct frame roots. - BUG-184's nested-fragment witness (
baseline_nested_interp_match) is byte-identical too; its own comment already recorded that eid-keyed temps give the two scrutinees distinct slots. - No C identifier anywhere in the compiler is built from a line or column;
interp_part_lochad exactly one live caller.
A wrong line number is worse than it reads: it silently defeats tooling that consumes the diagnostic. This one cost a compiler-guided refactor 60 wasted iterations, each editing a line that could not exist in a 99-line file. Pinned by
test_interp_fragment_line.reef, RED-proven against a reintroduced offset (line 1000025vsline 25).Still imperfect, and unchanged by this: a fragment's nodes carry the string's position, so the column can point at the string rather than the offending sub-expression. True fragment offsets from the lexer are independent work.
-
ui.textfieldcan be typed into (reef-lang#64, medium).insert_charanddelete_char_atreturned the receiver unmodified anddelete_char_beforemoved the cursor without deleting anything — whilehandle_eventreportedChangedfor all three, so a redraw driven byis_changedrepainted forever with identical content. Measured before:after typing 'abc': text='' len=0 cursor=0 each keypress reported changed = true true true after Delete at 0: text='hello' len=5 changed=trueThe three helpers now do real
substring/concatedits and return whether they changed anything, soChangedis reported only when something did; a refusal (atmax_length, or backspace at position 0) isHandledinstead. The bodies had said "in a real implementation, we would modify the string buffer" since beforecore.strcould express it.A second facet fixed with it: the cached
text_lengthdisagreed withtext.set_texttruncated the count tomax_lengthwhile storing the full string —set_max_length(5)thenset_text("abcdefghij")lefttext='abcdefghij'withtext_length=5— andtextfield_with_textapplied no cap at all. Every write totextnow goes through one privatewrite_text, which caps the string, sets the length from it, and clamps the cursor.text_lengthis redundant (str.lengthis O(1) since BUG-147) but it is public API; a single writer is what keeps it from drifting, since a cache maintained by hand at each of five sites is a cache that drifts.Suite 146 → 168 assertions; section 8 covers insertion at the cursor rather than only at the end, backspace vs delete cursor behaviour, both no-op-is-not-Changed cases,
max_lengthenforcement, and theset_texttruncation. -
uihas one event-result type instead of six — D3 ofdocs/design/2026-09-08-ui-modernization.md. Breaking.widget.EventResultis now what every widget'shandle_eventreturns. Its kind enum gainedClicked,ToggledandChanged— the three semantic kinds the per-widget types added — plus predicatesis_clicked,is_toggledandis_changed.is_event_handledis true for all of them, which is what the five deleted converters did.The design counted four per-widget types; there are five. Four are structs (
ButtonEventResult,CheckboxEventResult,SliderEventResult,TextFieldEventResult) andScrollViewEventResultis an enum (Consumed(ScrollView)/Ignored) with its own accessor pair, which is why a name-based survey missed it.Each of the five carried the widget itself as a payload, and that was the active defect, not just duplication — see reef-lang#63. Since setters mutate (reef-lang#62) the payload was always the receiver the caller already held, so it is gone rather than merged.
-
A button losing focus no longer reports that it wants focus (reef-lang#63, medium). Measured at rev 1393:
focus OUT -> requests_focus = true focus OUT -> releases_focus = false hover -> requests_focus = trueButtonEventResultKindhad four variants —Ignored,Handled,Clicked,RequestFocus— and noReleaseFocus, so the focus-out arm returned the nearest thing that existed and focus-in and focus-out were indistinguishable to the caller. A focus manager honouring the request re-focuses the widget that just released focus.The hover case is worse and has the same root:
button_result_handled()filled the struct'sbuttonfield with a freshly allocateddummy_button()and threw the real one away, so it was unusable wherever the caller needed the widget back — andRequestFocusbecame the de-facto "handled, here is the widget" constructor. Every state transition inhandle_mouse_eventreturned it, so a plain mouse-move onto a button asked for keyboard focus. The payload field distorted the kind.All four struct types also allocated a throwaway widget in their
_ignoredconstructor (new Checkbox(widget.widget_id(0), "")and siblings) — in an event loop, one allocation per ignored event. Both facets die with the payload; no result allocates now.Latent rather than live:
window_handle_eventdoes not yet dispatch mouse events to widgets. It would have become live for the first consumer to wire a focus manager to widget results. -
uiwidget behaviour lives on the widgets — phase 4.preferred_size,layout,handle_eventandpaintare methods; 82 free functions deleted, 0 added, andui/widget.reefgrew 87 lines against 520 removed from the eight widget modules.handle_eventis virtual onWidget(default:event_ignored()), which is what D3 was approved to enable — a common result type is the precondition for a common slot.paintwas already virtual;layoutis now anexclusive procrather than another mutate-and-return.preferred_sizeandlayoutare deliberately NOT virtual.panelandscrollviewtake acontent_size, the other six take atheme.Theme, and those are genuinely different inputs rather than a naming accident — a panel cannot compute its preferred size from a theme. Unifying them needs a decision about what a layout pass passes down, which this phase does not make.The private per-event helpers (
handle_mouse_event,handle_key_event,handle_focus_event, thepaint_*helpers) stay module-private free functions. They are implementation detail, not surface.Dropping the payload orphaned nine
letbindings whose value nothing read any more, because the private helpers behind them were themselves mutate-and-return (start_v_drag,end_drag,update_hover,insert_charand six siblings). Those nine are nowprocs called as statements, so the code no longer reads as though the returned widget mattered.Also deleted here: the 24
<widget>_get_id/_get_state/_get_widget_state/_get_boundsfree functions that phase 2 left behind because they were filed under "widget interface" rather than "accessors". They were field reads (C2).Verified differentially rather than by inspection: each of the 32 moved bodies was reverse-substituted (
selfback to the original receiver) and diffed against rev 1393. 20 are byte-identical; the 12 that differ do so only in the intended ways — thepreferred_sizecall becoming a method call, the D3 result constructors, and panel's child-painting tail being merged into its moved body.Suite 126 → 146 assertions. Section 7 is new and covers
handle_event, which D4 asked for from the start and phase 1 did not deliver — that gap is why reef-lang#63 went unnoticed. -
uiwidget setters now mutate and say so — phase 3 ofdocs/design/2026-09-08-ui-modernization.md, closing reef-lang#62. Breaking.49 free setters of the shape
fn button_set_label(b: Button, label: string): Buttonbecameexclusive proc set_label(label: string)methods, and 7 internal*_with_*helpers taking a receiver (button_with_state,checkbox_with_hover,slider_with_dragging,textfield_with_focusand siblings) becameset_state/set_hovered/set_dragging/set_focused.button.button_set_label(b, "x")is nowb.set_label("x").The signature was the defect, not the behaviour. Widgets became objects on 2026-08-23 and objects have reference semantics, so these functions had been mutating their argument and returning the same reference ever since. Measured before the change:
let b1 = button.button("original") let b2 = button.button_set_label(b1, "changed") b1.label = changed b2.label = changed aliased = trueA
: Buttonreturn type promising a copy while delivering an alias is worse than a mutating method, and thewith_spelling — the conventional name for a copy — was worse still. Removing the return value is what makes the mutation visible at the call site. -
uiwidgets gainclone(design D2).shared fn clone(): <Widget>on all eight widgets, hand-written per type because objects have no derived copy. It is a shallow copy: inheritedWidgetstate (bounds,wstate,visible,enabled,focusable,tab_index) and every own field are copied,Panel'skidsarray is copied element-wise, and referenced widgets are shared rather than deep-copied.This is the replacement for what the old setter signature falsely promised.
button_with_state(base, Hovered)to paint a hover preview — the real use case, and exactly the one that was silently broken — is nowlet hovered = base.clone()thenhovered.set_state(...).The behaviour suite goes 98 → 126 assertions: section 5 was rewritten from pinning the #62 defect to pinning the fixed contract, and a new section 6 asserts clone independence once per widget so a reintroduced pseudo-copy setter fails loudly.
-
193 field-wrapping accessors deleted from
ui(C2, modernization phase 2). Breaking: read the field.190 free functions across all 22 modules —
theme25,backend/platform20,event17,window16,backend/x1115, and so on — plus the three method-form wrappers on theWidgetbase itself (get_id,get_bounds,get_wstate, 55 call sites). Net −769 lines.The phase-1 behaviour suite's 98 assertions pass unchanged, because they were written against field reads rather than accessors. That was the point of sequencing coverage first.
-
uigains a widget behaviour suite — phase 1 ofdocs/design/2026-09-08-ui-modernization.md. 98 assertions across all eight widgets: construction defaults, every setter's effect on the field it names, state inherited from theWidgetbase, andpreferred_sizerelations.uihad roughly 26 real assertions across 13,355 lines before this, with two of its four test files containing none —test_ui_widgets_importwould pass against a package that painted nothing. Phases 2–4 delete 190 getters and change 66 setter signatures; the design sequences coverage first for that reason. No production code changed. -
graphics.image.create_imageno longer allocates a short buffer on 32-bit size overflow (reef-lang#61, high). It computedwidth * bytes_per_pixel * heightin a 32-bitintunchecked. A negative size reaches the allocator's BUG-056 guard and aborts; an overflow wraps to a positive value the allocator cannot recognise.Measured:
create_image_rgba(100000, 100000)succeeded, reportingwidth=100000 height=100000 stride=400000while allocating 1,345,294,336 bytes instead of 40,000,000,000 — the struct lied about its own dimensions. And becauseget_pixel's offsety * stride + x * bppwraps the same way, a high-row access landed back inside the short buffer: wrong pixel, no bounds violation, no error anywhere. Silent corruption rather than a crash.graphics.backend.memory.surface_to_rgba8_byteshad the same hole and was not covered by the same fix — it computesw * h * 4from a surface that may be 1 byte per pixel, so a legal 30000×30000 grayscale image (900 MB) needs 3.6 GB as RGBA8. Both now check by division, so the check cannot itself overflow, andpanicwith a specific message.Panicking rather than returning
Resultmatches the allocator's existing policy for an impossible allocation, and callers that must recover from attacker-supplied dimensions already bound them first —image/png.reefandimage/bmp.reefcap and return their ownErrwell before reaching this. -
graphics.image.pngand.bmpuse the realResult. Breaking:load_png/load_png_bytes/load_bmp/load_bmp_bytesreturnResult[image.Image, error.Error]; the hand-rolledPngResult/BmpResultstructs ({ok, img, error_msg}) and their six accessors are gone. Useresult.is_ok/unwrap_ok/unwrap_err(r).message.Both modules already imported
core.result. The clones also forcedmake_png_errorto allocate a dummy 0×0 image on every failure, purely to fill a field the caller was never meant to read — anErrcarries no image at all. -
31 field-wrapping accessors deleted from
graphics(C2). Breaking: read the field.image_width(img)→img.width,glyph_x(g)→g.x,metrics_ascender(m)→m.ascender,path_bounds(p)→p.bounds, and 27 more acrosscanvas,core,image,path,font/bitmap,font/font,font/textandfont/ttf. -
ReefVision's exported sentinels return
Option/Result(C16). Breaking.term.term_writeandterm.term_readreturnResult[int, Error]. The old-1collapsed two different failures into one value — the terminal was never opened and thewrite(2)/read(2)itself failed — which a caller could not tell apart, nor from a short write.term_read'sOk(0)is EOF and is not an error.theme.desktop_fill_preset_indexreturnsOption[int]; its own comment already documented the-1.Five other sentinel-returning functions in the package are deliberately unchanged: they are private index finders whose
-1never escapes (commands_find,syntax.find_ctx/find_list,desktop.walk_poll_fd,window.sys_glyph_x), andview.View.poll_fd()returns the POSIX no-descriptor-1straight intopoll(). -
38 field-wrapping accessors deleted from ReefVision (C2). Breaking: read the field.
term_width(t)→t.width,theme_name(th)→th.name,group_focused_idx(g)→g.focused_idx,vt_cursor_x(vt)→vt.cx,menu_item_label(it)→it.label, and 33 more acrosscommands,input,syntax,term,theme,view,vtandmenubar. Each returned a public field unchanged. Net −152 lines.Five sentinel-returning functions were deliberately left alone, because they are private index finders whose
-1never escapes the module as a value:commands_find,syntax.find_ctx/find_list,desktop.walk_poll_fd,window.sys_glyph_x. That is the same line drawn forsymbol_table.slot_in_scope, which C16 kept for the same reason. -
net.http's privateparse_intgot the overflow guard too — the fourth copy of the same unguarded accumulate, afterjson,tomlandyaml. This one parses HTTP header values, Content-Length among them, so an over-long digit string from a hostile peer wrapped and returnedSome(small number). -
gameui.contextgains theOptionform its_orAPI was missing (ROADMAP §8.2).ui_get_float(ctx, id, default)andui_get_booltook a default with noOptiontwin at all — so a caller could not distinguish "stored 0.0" from "never stored", which is exactly what035_ERROR_HANDLING.md:427requires theOptionform to answer.ui_get_float/ui_get_boolnow returnOption, andui_get_float_or/ui_get_bool_orare the defaulted twins delegating to them — the documented direction, rather than the default form being the only one. Both had zero callers, so nothing outside the module moved. -
sys.proclist.proclist_getreturnsOption[Proc]andsys.pty.pty_openreturnsOption[Pty],pty_spawnreturnsResult[int, Error](ROADMAP §8.2). Breaking.proclist_get(999999)used to return a fabricated zero record —pid: 0, emptyuser/comm— indistinguishable from a real entry, andpid 0is a real pid on some systems. (The runtime'sat()does bounds-check and return NULL, so this was never an out-of-bounds read; the fabrication happened on the Reef side.) It now checks againstproclist_count().pty_openused to returnPty { master_fd: -1 }— a half-built value whose invalidity you had to remember to ask about, exactly the shapesync.mutexhad before rev 1373.pty_oksurvives for the same reasonmutex_is_validdid: it is a derived predicate (master_fd >= 0) answering a later question the constructor'sOptioncannot. -
encoding.{json,toml,yaml}no longer wrap on integer overflow. A value too large for a 32-bitintwas accumulated with no guard, so{"big": 9999999999}came back asSome(1410065407)— a wrapped value indistinguishable from one that was really there.The 0.9.2 C16 pass converted these three to
Option, which fixed "not a number" but not "a number that does not fit". C5 step 0 had already added exactly this guard tocore.convert.to_int; the three private copies never received it. The guard is asymmetric — INT_MAX ends in 7, INT_MIN's magnitude in 8 — so-2147483648is still accepted. -
encoding.varint's decoders returnResultand check bounds. A varint whose continuation bit was set on the final byte of the buffer ran off the end, and Reef's array bounds check aborted the process:Runtime error: array index 3 out of bounds (length 3)For a decoder handed untrusted bytes, truncation is an ordinary input condition, not a program error. The 10-byte cap is an error now too, instead of a silent
breakthat returned a value assembled from a prefix of a malformed varint.New
varint_decode_atreturns value and size together;varint_decode_bytesis deleted — it was the tree's other literal out-param (out_value: [int]) and duplicated whatvarint_decode_atnow returns. -
Five byte-identical private
char_to_inthelpers deleted fromencoding.{html,json,toml,yaml}andgraphics.font.text, all restatingcore.convert.to_code(D4/C4: one library per concept). -
compress.lz4returnsResult[int, Error](ROADMAP §8.2, C16). Breaking:lz4_compress,lz4_compress_fast,lz4_compress_hcandlz4_decompress. Buffer-passing kept, as for zlib/gzip.The three compressors share one failure convention — LZ4 returns
0, and only0, meaning the destination was too small — so they share one wrapper.lz4_decompresssignals malformed input or an undersized destination with a negative return, a different convention the oldintreturn blurred together with a valid size.Verification is weaker here and worth stating:
liblz4.so's dev symlink is absent on the development machine, so nothing importingcompress.lz4links, and nothing in the tree imports it. This is--emit-cplusgcc -fsyntax-only(the stdlib gate) only — no runtime test. -
compress.zipis deliberately NOT converted, and is now reef-lang#60. Itszipfile_entry_sizereads libzip'szip_statat a hand-computed offset, with the source's own comments saying "Actually on 64-bit it's more complex due to struct packing" and "Approximate - may need adjustment" — and on LP64sizesits at offset 24, so the code reads the low half ofindexinstead. That wants a C accessor, not a Reef-side signature change, andlibzip.so's dev symlink is absent so it cannot be tested here either. -
compress.zlibandcompress.gzipreturnResult[int, Error]instead of a-1sentinel (ROADMAP §8.2, C16). Breaking:zlib_compress,zlib_compress_level,zlib_decompress,gzip_compressandgzip_decompressall changed.The buffer-passing shape is deliberately KEPT. The survey proposed converting these to
Result[[byte], Error], moving allocation into the library — butreefzip, the only real consumer, allocates its buffers once and reuses them across every call, which is exactly what a compression primitive's caller should be able to do. Taking that away would be a regression dressed as modernisation. What was actually wrong was the return: a size and a failure sharing oneint.The
Errnow carries what the sentinel discarded — zlib's own return code, and a message naming which stage failed (deflateInit2_, a too-small destination,deflate/inflateitself, truncated input).gzip_compressalone had three distinct-1sites that were indistinguishable to a caller.No
_ortwin: a failed compression is not an ordinary miss.graphics.image.pngwas the notable consumer — its own comment read "zlib_compress returns -1 on failure; downstream byte-math would under-size the output buffer and corrupt the chunk". It now matches on theResult, so that hazard is unrepresentable rather than commented. -
Eight field-wrapping accessors deleted from
compiler.diagnosticsandcompiler.source(C2). Breaking: read the field.error_count/warning_count/total_count→bag.errors/bag.warnings/bag.count;pos_file/pos_line/pos_col→pos.file/pos.line/pos.col;span_start/span_end→span.start/span.ending. Each returned a public field unchanged.has_errorsdeliberately stays. It isbag.errors > 0— a derived predicate, not a wrapper, and the same distinction that keptsync.mutex.mutex_is_validwhen thevalidfield went away. C2 removes accessors that restate a field; it does not remove ones that compute something.Output of
test_diagnosticsandtest_source_locationis byte-identical. -
compiler.symbol_tableis one generic table, not three (ROADMAP §8.2, D9/D13). Breaking: every name changed.before after create_int_table()/create_string_table()/create_ptr_table()create_table[:V]()define_int/define_str/define_ptrdefinelookup_int/lookup_str/lookup_ptrlookup→Option[V]scope_push_int/_str/_ptr(and pop, depth)scope_push/scope_pop/scope_depthThis was the last typed
Int*/String*/Ptr*triplication in the tree — the collections 0.9.2 redesign deleted the others (IntOrderedMap/StringOrderedMap/PtrOrderedMap,IntTrie/StringTrie). The module's own comment said the split existed "due to generic limitations"; generics carry it now, asHashMap[K, V]andTrie[V]already demonstrate.Collapsing removed two things the copies were hiding:
lookup_ptrreturned a barepointerwith null meaning "not found", so thePtrcopy never received the C16 treatment itsIntandStrsiblings got in 0.9.2. One genericlookupreturnsOption[V]for everyV, so the sentinel has nowhere left to live.- a private DJB2
hash_string, duplicatingimpl Hash for stringincore.hash— which D4 established as the single home for hashing and explicitly de-exported the standalone copy from. Keys hash withname.hash()now, masked against the sign bit before the modulo the waycollections.hashmapdoes.
The
default_valuefield is gone: a per-table constant nothing could set, and the reason the pre-0.9.2 lookup could not distinguish "bound to 0" from "not bound". 656 lines → 235, with behaviour byte-identical —test_symbol_table.expecteddid not move.Unchanged and still true: scopes are fixed-capacity, so an insert into a full scope is silently dropped. Pre-existing, noted in
examples/katas/stdlib, and not addressed here. -
reefcnow auto-links the compression modules (ROADMAP §6.5), and the four tests that were skip-gated because of it now run.auto_detect_libscoveredcompress.zlibonly, socompress.gzipfailed at link withundefined symbol: compressBoundunless the caller passed-lzby hand. The roadmap entry framed this as "gzip/zip use libz directly", which is wrong about zip: the three modules need three different libraries, and-lzwould never have helpedcompress.zip.module library compress.zlib,compress.gziplibzcompress.ziplibzip(zip_open,zip_fopen_index)compress.lz4liblz4(LZ4_compress_default)All four are auto-linked now, and duplicate flags are de-duplicated so importing both
zlibandgzipemits-lzonce.test_gzip,test_zlib,test_save_pngandreefzipare un-skipped — suite skips drop 28 → 24 and all four pass.test_save_pngandreefzipneeded nothing but the auto-link.test_gzipandtest_zlibdeclare the zlib externs themselves rather than importing the stdlib, so auto-detection had nothing to detect; each gained a link-effect import, the same patterntest_bug121_gzip_framing.reefalready used and thattypechecker.link_effect_modulesexists to sanction. -
IEEE-754 special-value conformance in
math.basic, and therefore inmath.floats(which forwards to it since reef-lang#56). Three defects, all found by adversarial review rather than by the measurements that shipped with the original changes:-
hypot_f(±inf, NaN)returned NaN; IEEE-754 requires+inf— infinity wins over NaN forhypotby specification.abs_f(NaN)is NaN, every magnitude comparison against NaN is false, so control fell into the scaled branch and computedNaN/NaN. Infinity is now tested before any arithmetic. This one is a conformance regression the forwarding introduced:math.floats.hypot_fused to belibc.hypot, which is conforming. -
Signed zeros were not preserved.
sqrt_f(-0),atan_f(-0)andcbrt_f(-0)all returned+0; IEEE gives-0. Each guard readif x == 0.0 then return 0.0, and-0.0 == 0.0is true. Returningxinstead carries the sign through. -
atan2_fcollapsed four signed-zero cases into one.x == 0.0is true for both+0and-0, soatan2(+0, -0)returned+0where IEEE requires+π, andatan2(-0, +0)returned+0where it requires-0. The full zero table now matches libc bit-for-bit on all eleven cases. These are branch-cut semantics, not conventions — they are what makesatan2usable for complex argument.
Pinned by
test_math_ieee_specials.reef(31 assertions), which compares bit patterns wherever a sign is under test, because-0.0 == +0.0is true and an equality assertion cannot see the defect.Not fixed, and not claimed: the
atan2/hypotinfinity tables beyond the cases above, andsqrt_f(-0)-adjacent behaviour for other functions in the module, have not been audited against IEEE-754 as a whole. -
-
sync.mutex.mutex_createreturnsOption[Mutex](ROADMAP §8.3, C16). Breaking: callers must unwrap.It used to return a
Mutexcarrying avalid: boolsaying whether it had actually worked. A caller that forgot to check got an object that silently did nothing —mutex_lockon an invalid mutex was a no-op, which for a mutual-exclusion primitive is the worst available failure mode: the critical section runs unprotected and nothing reports it.The
validfield is gone.lock_ptris now the single source of truth for liveness (non-nil = usable, nil = destroyed), so two fields can no longer disagree.mutex_is_validremains, but it is now a derived predicate overlock_ptranswering a different question — use-after-destroy — which the Option cannot express because it is a later state change, not a creation failure.No
mutex_create_ortwin:Nonemeans the lock could not be created, and a defaulted mutex is exactly the silent no-op this removes. -
sync.mutex.mutex_guard_is_lockeddeleted (C2). It wrapped the publiclockedfield and returned it unchanged. Readg.lockeddirectly. -
time.date.days_in_month/time.time.days_in_monthreturnOption[int]instead of a0sentinel for an out-of-range month (ROADMAP §8.3, C16). Breaking: callers must unwrap.The sentinel was not cosmetic.
time.date's owndate_makecarried a comment naming it "the C16 violation" and worked around it rather than fixing it — the day-sum loop addeddays_in_month(year, m)form in 1..month-1, and a0form > 12contributed nothing, sodate_make(2024, 13, 1)anddate_make(2024, 14, 1)both returned 20089 with no error.There is deliberately no
days_in_month_ortwin:_orsuits a lookup whose miss is ordinary, and hereNonemeans the month argument is invalid, so defaulting would resume exactly the arithmetic the sentinel corrupted. -
The calendar rules now live in one place.
time.timecarried byte-identical copies ofis_leap_yearanddays_in_month— including a duplicate of the comment describing the sentinel bug — so a fix applied to one module would not have reached the other.time.dateis now the single implementation andtime.timeforwards to it. Both public names are kept, since both have callers.Internal callers use a private
month_lengthwith a documented1 <= month <= 12precondition that every call site establishes (explicit validation, awhile month <= 12loop, or a month fromdate_month()). Intime.timethat helper isoption.unwrapof the forwarded call, so a future precondition violation panics loudly instead of silently contributing 0 to a day sum — which is the failure mode the sentinel had.A now-dead
if max_day == 0check intime_parse_isois removed; the month is validated three lines above it. -
math.basic.sqrt_fis now correctly rounded (reef-lang#57). It was faithful — within 1 ulp — but IEEE-754 requiressqrtto return the nearest representable double (<= 0.5 ulp), and every libm does. Measured over 20,000 samples atx = i/97: 5005 of them (25.0%) differed from the correctly rounded value, each by exactly 1 ulp. After the fix, 0 mismatches on a 24,552-point grid that walks every one of the 2,046 normal exponents against pathological mantissas (all-zero, all-ones, top bit, low bit) plus pseudo-random ones — and 0 across 120,000 samples on three arithmetic strides.The stride measurement alone was not sufficient evidence, and this is worth stating because the first version of this entry rested on it:
x = i/97tops out around 1237, so it cannot reach the last binade, the subnormal neighbourhood, or the even-exponent/all-ones-mantissa family where the residual comparison underflows. An adversarial review supplied the grid, and the grid found real defects the strides could not. A measurement is only as good as the range it covers.Three separate causes, and fixing any one alone was insufficient:
- The Newton loop stopped on
float_equal(y, prev), an absolute 2.2e-16. Below 1 that tolerance is wider than an ulp, so it exited while still 1–2 ulp out. The test is now exact equality, so the iteration runs until it genuinely stops moving. - Even run to the floating-point fixed point, that fixed point is not in general the correctly rounded root — the division and the averaging each round, so the iteration can settle one ulp away and stay there. This is why an epsilon fix alone could not close it.
- The residual
x - c*cused to discriminate candidates underflows for smallx: two adjacent candidates produce the same subnormal magnitude, the strict<keeps whichever was tried first, and the answer lands 1 ulp out.sqrt_fnow scalesxby an even power of two into a mid-range binade first, corrects there, and scales the root back by half that shift — both scalings exact. Without it, 27 of 10,230 grid points were 1 ulp wrong, all at small exponents.
The final choice between adjacent doubles is now explicit: the residual
x - c*cis computed exactly (Veltkamp splitting recovers the bits the single rounding ofc*cdiscards, using only multiply and subtract — no fma and no runtime support), and the candidate amongc,nextafter(c, -inf),nextafter(c, +inf)whose square is nearestxwins. The one-ulp steps use the runtime's existingreef_float64_to_bits/reef_bits_to_float64, since for positive doubles the bit pattern is monotone — an ulp is not a fixed quantity, so no magnitude constant would have worked.This is the accuracy floor under
hypot_fand every othersqrt-derived value in the tree. Cost is ~90 ns/call for the whole pure-Reef softwaresqrt; the correction is a small fraction of that next to the Newton divisions. - The Newton loop stopped on
-
Float literals now accept exponent notation (reef-lang#58). The spec had documented
2.5e10and1.5e-8since the language reference was written (reef-language-spec-v2.md:460-464), but the lexer's float rule wasdigit+ '.' digit+with no exponent part, so1.0e150lexed as the float1.0followed by an identifiere150and failed withUndefined variable 'e150'.Both forms now lex:
1.0e150,2.5E10,1.0e+3,1.5e-8, and the no-fraction1e3. Any constant outside roughly 1e-15..1e15 previously had to be spelled out in full, and the extremes could not be written at all —math.basicstill buildsmath_HUGE()out ofreef_make_float64for exactly that reason, and ahypot_foverflow guard wanting a 1e150 threshold had to be restructured around the gap while fixing reef-lang#56.The trailing-digit requirement is preserved, so
1..10still lexes as a range and not1..10; the exponent group requires at least one digit aftere/E, so1.0estill lexes as1.0thene; and0x1e5is unaffected. Lexical change only — the menhir conflict baseline is unchanged at 61 S/R and 81 R/R.Underscore separators from the same spec block (
1_000_000,0.1_234) remain unimplemented and are tracked separately as reef-lang#59. -
Float literals were truncated to 12 significant digits (reef-lang#55).
codegen.mlemitted them with OCaml'sstring_of_float, which is exactly%.12g; a double carries ~17. Every floating-point constant in every Reef program silently lost its low digits — a relative error of ~1e-12 — in every release up to and including 0.9.2. Two literals two ulp apart became one C token:source 1.1071487177940904 -> emitted C 1.10714871779 source 1.1071487177940906 -> emitted C 1.10714871779Literals now emit as the shortest form that round-trips (
%.15g/%.16g/%.17g, each verified withfloat_of_stringrather than assumed), so0.1stays0.1and long constants survive intact.Measured effect in the stdlib:
math.basicdeclares its constants to 21 digits and got 12, somath_PI_2()was ~1e-12 off and everyatan_f(|x| > 1)— computed asmath_PI_2() - atan_f(1/x)— inherited it.atan(2.0)andatan(5.0)were −4.9e-12 against libc before, +4.4e-16 (2 ulp) after. All 16 emitted-C goldens are byte-identical, because no program in the corpus needed more than 12 digits — which is exactly why no gate ever caught this. -
math.basic.atan_flost ~4 digits near |x| = 1 (reef-lang#56), and with itasin_f,acos_fandatan2_f.atan(1)returned0.784898against a true0.7853981634— an absolute error of 5.0e-4. The Taylor seriesx − x³/3 + x⁵/5 − …converges more slowly the closer |x| gets to 1, and at x = 1 it is the Leibniz series for π/4, whose error after n terms is ~1/(2n); the loop capped at 500 terms and so exited on the cap rather than on convergence. The function was not even monotonic across the seam —atan_f(1.0)returned less than the trueatan(0.999)— and becausex > 1folds back via the π/2 identity, the whole neighbourhood of 1 was affected.Propagation was structural:
asin_f(x) = atan_f(x/√(1−x²))mapsx = √2/2onto exactlyatan(1.0),acos_fisπ/2 − asin_f, andatan2_f(y, x)forx > 0isatan_f(y/x).Fixed with one more argument reduction —
atan(x) = π/4 + atan((x−1)/(x+1))forx > tan(π/8)— which bounds the series argument at ~0.414 everywhere, about 22 terms for 1e-17. Against libc,atan_f/atan2_f/asin_fare now 2.2e-16 andacos_f4.4e-16, from 5.0e-4 and 1.4e-7. -
math.basic.hypot_fmissed exact results.hypot_f(5, 12)returned13 + 1.8e-15(8 ulp). The scaled formax·√(1 + (ay/ax)²)rounds four times around thesqrt; the direct form√(ax² + ay²)rounds twice and is exact for Pythagorean pairs. The direct form is now used whenever squaring neither overflows nor underflows, with the guard testing the result (sum > 0 and sum < HUGE) rather than the inputs —+infand a flushed-to-zero sum are both rejected, and no magnitude threshold is needed. That last point is not stylistic: Reef has no exponent notation in float literals, so1.0e150lexes as1.0followed by an identifiere150, and a constant like that cannot be written at all.
Changed
-
mathis now freestanding —math.floatsno longer depends on libc/libm. It wrapped libc for eleven functions (trunc_f,cbrt_f,log2_f,asin_f,acos_f,atan_f,atan2_f,sinh_f,cosh_f,tanh_f,hypot_f), and those eleven were the module's only libc use. They were also exactly the eleven names it duplicated frommath.basic, which implements all of them in pure Reef (IEEE-754 bit manipulation, ported from A2 OberonMathL.Mod). The module therefore carried two bodies per name, with no caller able to tell which it would get, and the whole libm dependency existed only to serve the duplicates.They now forward to
math.basic: one implementation per function, no libm, and the same public API — no signature changed and nothing was removed. Divergence from libc was measured across[-10, 10]before converging, and every one of the eleven agrees to ≤2.8e-14, nine of them to ≤1e-15 (trunc_f0;cbrt_f/tanh_f/atan_f/atan2_f/asin_f2.2e-16;log2_f3.3e-16;acos_f4.4e-16;hypot_f~1 ulp;sinh_f/cosh_f2.8e-14).That was not true before the two fixes above: forwarding would have silently degraded
atan/atan2/asin/acosby up to 5.0e-4 ingraphics, the one package that imports both modules. Fix the pure implementation, measure, then converge.Known residual, not fixed here:
math.basic.sqrt_fis a few ulp rather than correctly rounded (measured up to 3.6e-15 over a swept range; IEEE-754 requiressqrtto be correctly rounded, and libc is). It is the accuracy floor underhypot_fand every othersqrt-derived value in the tree.
Added
-
*_orlookups acrossencoding.{toml,json,yaml,ini}— 21 convenience accessors pairing everyOption-returning getter the 0.9.2 C16 conversion created (reef-lang#53) with a caller-supplied default. Same shape assys.env.get_env_or.module added encoding.tomltoml_get_or,toml_get_int_or,toml_get_bool_or,toml_array_get_or, and the fourTomlDoctwinsencoding.jsonjson_get_or,json_get_int_or,json_get_bool_or, and the threeJsonDoctwinsencoding.yamlyaml_get_string_or,yaml_get_int_or,yaml_get_bool_or,yaml_get_list_item_orencoding.iniini_get_value_or,ini_get_section_or,ini_get_key_orA present value always wins, including
"",0andfalse. Those are real values these APIs return on success —{"name": ""}is legal JSON,key =legal TOML,[]a legal INI section — and substituting the default for them would reinstate exactly the sentinel collision C16 removed. Each module's test asserts that property directly, not just the happy path.The
Option-returning getters are unchanged and remain the source of truth; these delegate tooption.unwrap_or.Originated as a TOML-only change requested downstream; extended to the two TOML array accessors it had missed and to the three sibling formats converted by the same sweep, so the encoding package is consistent rather than one format having helpers and three not.
-
*_orfor the C16 conversions outsideencoding— 11 more:sys.optparse.argv_get_or,sys.hoststat.cpu_pct_at_or,ui.backend.x11.x11_default_screen_or,compiler.symbol_table.lookup_int_or/lookup_str_or,net.http.http_response_header_or/http_binary_response_header_or/http_auto_header_or, and the ReefVision widget methodsTable.header_or,Table.cell_or,TextArea.line_or.symbol_table's pair is the interesting one: this is what the olddefault_valuefield provided, but as an argument the caller chooses per lookup rather than a struct field nothing could set.Four C16 conversions deliberately did NOT get an
_or, and say so in the source._orsuits a lookup whose miss is ordinary; it is wrong whereNonemeans an operation FAILED, because a default silently continues past the failure:sys.poll.poll_revents—Nonemeans the index is not in the set.poll_revents_or(idx, 0)answers "no pending events" for a stale index, which is the pre-reef-lang#54 behaviour and how you miss a hangup.io.console.getch—Noneis EOF. A default turns end-of-stream into a real character, which is the defect C16 fixed here (EOF collapsed to'\0', loopingconfirm()forever and filling a 47 GB tmpfs).sys.fd.fd_pipe,sys.signal.selfpipe_create—Nonemeans the syscall failed; a defaulted fd pair hands out descriptors nobody opened.
-
encoding.iniexport block repaired —ini_get_value's entry was unindented and carried a stray copy of the definition's doc comment, both introduced by the C16 conversion's own edit at rev 1343.
0.9.2 — 2026-09-05
Post-0.9.1: spawn lowering, method/parameter defaults, lambda-in-AO
codegen, field-less structs, object init defaults, await-cond
captures, cross-module diagnostics, menhir-measurement integrity, and
a slimmer source tarball. Then the collections redesign, which is a
breaking change to the standard library and adds four language features
to the compiler, and the core redesign, which is a second breaking
stdlib change on the same 0.9.2 cut — one conversion API, one character
library, no -1 or '?' sentinels, and no accessors wrapping public
fields. One additive runtime symbol (reef_poll_count, reef-lang#54);
no descriptor/layout change, so no ABI break — but a stdlib built from this
cut needs the matching runtime, or the link fails loudly.
Also on this cut: reachability-based emission is the default (reef-lang#47).
The compiler now drops functions, vtables and descriptors it can prove
unreachable, which removes 42.2 % of emitted C on an identical corpus. The
size baseline is re-cut accordingly (899 units / 94,010,285 B). A follow-up
landed on top of it (reef-lang#49): construction is now read from the new
node's own stamped type rather than from the class that declares the effective
init, which for an inherited init named the base and never the class actually
allocated. Worth a further 0.90 %.
Breaking
-
39 stdlib lookups/parsers return
Optioninstead of a magic value (reef-lang#53, C16). A lookup, parse or convert must not signal failure with a value a caller could legitimately receive. Every one of these collided:module now returns Optionencoding.tomltoml_get,toml_get_int,toml_get_bool,toml_array_get,toml_get_doc,toml_get_int_doc,toml_get_bool_doc,toml_array_item_doc,str_to_intencoding.jsonjson_get,json_get_int,json_get_bool,json_get_doc,json_get_int_doc,json_get_bool_doc,str_to_intencoding.yamlyaml_get_string,yaml_get_int,yaml_get_bool,yaml_get_list_item,parse_int,parse_keyencoding.iniini_get_value,ini_get_section,ini_get_keynet.httphttp_response_header,http_binary_response_header,http_auto_header,parse_hex_from_bytes,parse_hex_intsys.optparseargv_getsys.hoststatcpu_pct_atcompiler.symbol_tablelookup_int,lookup_strreefvision.widgetsTable.header,Table.cell,TextArea.lineui.backend.x11x11_default_screenreefmarker.evaluatoras_int,as_floatMigration: wrap a call site in
option.unwrap_or(…, <old sentinel>)to preserve today's behaviour exactly, or branch onoption.is_noneto handle the case that was previously indistinguishable. The sentinels were"",0andfalse— all of them values these APIs also return on success:{"name": ""}is legal JSON,key =is legal TOML,Content-Length: 0is a legal header,prog ""passes an empty argv element, and""is the DEFAULT content of aTablecell. -
A parse defect fixed with them, in four places.
net.http's two chunked-transfer size parsers and thestr_to_int/parse_intinencoding.json,.tomland.yamlran a digit loop that never checked it had consumed a digit, so garbage returned the accumulator's initial0. A"1z"chunk-size line decoded to a 1-byte body returned as the complete HTTP response — silent truncation, no diagnostic; andword: bananaread back as the integer0. Measured at rev 1343 vs the fix, gated byexamples/test_http_chunked_malformed.reef. Written up as a standing constraint indocs/design/2026-09-04-parse-loops-must-prove-they-consumed-something.md. -
sys.poll.poll_reventsreturnsOption[int](wasint). It answered0for both "index out of range" and "this fd has no pending events", and the second is the common legitimate answer — so a caller holding a stale index (one kept across apoll_clear(), say) read "idle" forever instead of learning the index was gone. That is how you miss a hangup.Nonenow means "not in the set". Newsys.poll.poll_count()exposes the bound, which was previously unreachable from Reef: it lives in a file-static__thread intinreef_poll.c, which is why this one function could not be fixed during the C16 sweep and needed the runtime accessor.The four boolean predicates (
poll_readable,poll_writable,poll_error,poll_hangup) are deliberately unchanged. They are closed-world predicates rather than lookups, the Result/Option adoption policy exempts bool predicates, and they have real callers. -
encoding.base64encode — bytes 128–255 are masked with& 255before the alphabet index. Copy/OSC 52 of UTF-8 used to die withstring index -15 out of bounds (length 64). -
TextArea keyboard highlight —
start_mark/end_mark/toggle_mark(F4). While mark mode is on, arrow/Home/End/Page movement extends the selection without Shift (many TTYs never send Shift+arrows). -
Kate-style TOML syntax — languages in
reefvision/syntax.d/*.toml(context stack, keyword lists, itemData roles). Schemes gained type, operator, function, preprocessor, variable, constant, alert, error, others, special, attribute, and builtin colors. Unknown files stay unhighlighted. Extra files: system-wide and per-usersyntax.ddirectories. Languages: Reef, TOML, shell, C, C++, Python, Ruby, Crystal, OCaml, Assembly, JavaScript, XML, HTML, CSS, YAML, V, D. Color schemes: Default, Darcula, High Contrast, Paper, Monokai, Solarized, Twilight, Vibrant Ink, Dracula, One Dark, Tokyo Night, Gruvbox, Catppuccin, Everforest, Gruvbox Light, Synthwave '84. Syntax submenu capacity is 24 entries.
Fixed — tooling and stdlib hygiene
-
make docsno longer deletes files it did not generate (reef-lang#52).generate-docs.shopened withrm -rf "$OUTPUT_DIR", so any file underdocs/stdlib/without a generating source was destroyed on every run — and one such file was tracked (reefvision/syntax-toml.md, a hand-written file-format reference, now atdocs/guides/reefvision-syntax-toml.md). It now generates into a temp directory, copies in, and reports rather than removes anything it did not produce. A run that generates zero files fails loudly instead of wiping the tree. The 11 generated docs that had been accidentally tracked are untracked;docs/stdlib/was already in.hgignoreand the rule now takes effect again. -
C4 is enforced by a gate that can see renamed duplicates (reef-lang#53).
char_classifier_gate.shmatched candidate NAMES taken fromcore.chars, so it caught "same name, different body" and "same name, same body" but was structurally blind to "different name, same body" — renaming a duplicate hid it. A body-first pass now compares every stdlib function body against everycore.charsbody of the same signature, regardless of name (2,307 bodies). It found one:encoding/toml.is_toml_spacewas a verbatim copy ofcore.chars.is_blank, sitting behind a comment asserting C4 did not apply to it. It now delegates. Composite classifiers in nine modules migrated tocore.chars; 22 sites that classifybyte/int/keycodes rather thancharare deliberately left, being a different domain.
ReefVision
- Clock widget (
reefvision.widgets.clock) paints date plusHH:MM:SSwith blinking colons. Date style is YYYY-MM-DD or MM-DD-YY; time style is 24-hour or 12-hour AM/PM; TZ style is UTC or the host timezone (TZ//etc/localtime, including DST). Colon blink usesreef_time_subsec_millis(0–999) because epoch milliseconds overflow 32-bitint. time.time_offset_at/time_local_offset_seconds— host TZ offset in seconds east of UTC.sys.proclist— OS process snapshot (proclist_scan/proclist_get). Linux reads/proc/<pid>/stat; illumos reads/proc/<pid>/psinfo.sys.hoststat— overall CPU %, per-CPU % (ncpu/cpu_pct_at), RAM used/total, 1-minute load×100, uptime, anduname(Linux/proc+sysinfo/uname; other Unix returns RAM + uname, CPU/load/uptime 0 / −1).- Sparkline (
reefvision.widgets.sparkline) — one-row▁▂▃▄▅▆▇█history strip (ring buffer; caller pushes). - Graph (
reefvision.widgets.graph) — boxed one-series plot with 0/50/100 labels;·history and█latest. - MenuBar right label —
MenuBar.set_right_textandApplication.set_menubar_rightfor a right-aligned name/version string. - FileReq (
reefvision.widgets.filereq) — open/save requester with a directory list, Name field, Parent, OK, and Cancel (Turbo Vision layout). - TextArea — wheel/scrollbar pan without moving the caret (
ensure_visibleonly on caret motion). Optional line-number gutter. Insert key toggles overwrite. CSI2~is Insert. - TextArea scrollbars — vertical and horizontal gutters;
col_offsetuses the current widget width so resize keeps the caret in view. - TextArea horizontal scroll —
col_offsetkeeps the caret in view. Shift+arrows and mouse-drag select;selected_text/delete_selection/find_next/select_all. - Window title caps use display width, not
str.length(byte length). A UTF-8 title such asEditor — fileno longer leaves a gap before├. - Window
_ + xstays inset so the top-right cell remains a box corner (┐). - TextArea scrollbars are frame-integrated in ReefVision: the right
border opens
┴/ empty / one█/┬; the bottom opens├/ empty / one█/┤. Both knobs are a single cell. - TextArea re-fills each text row before paint so h-scroll cannot leave highlighted fragments on short lines.
- Text paint never emits TAB/CR/C0 to the terminal (tabs become
spaces at tab stops).
\r\nfiles no longer carriage-return the TTY through the editor window. Syntax spans use display columns, not byte indices. - Table double-click / Enter — same
activatedflag as ListBox. - ListBox double-click — a second click on the same row within 500ms
sets
activated(same as Enter). FileReq uses this to enter a directory; a file still OKs the requester. - FileReq directory rows use a
▸prefix instead of a trailing/. - ComboBox (
reefvision.widgets.combo) — one-line field with a scrollable dropdown. Overlay paint/hit lives outsidebounds.hso Group/Window can stack the list over sibling widgets. - Syntax schemes — editor palettes independent of the UI theme
(
default,darcula,highcontrast,paper,monokai).TextArea.set_syntax_scheme/syntax.scheme_by_namepaint the buffer; chrome (frames, menus, status) still follows the UI theme. - Syntax highlighting (
reefvision.syntax) — keywords, strings, comments, and numbers for Reef, TOML, C, and shell.TextArea.set_syntaxenables it. - Menu mnemonics —
menu_item("&Refresh", cmd)strips&and paints that letter bold.&&is a literal ampersand. - Per-window context menus —
Window.context_menu()(override to return items). Right-click hit-tests the window and opens that popup without replacing the app-wide menu fromset_context_menu. Empty desktop / other windows do not reuse the last window menu. Table rows select on right-click too. - MessageBox sizes to its text (wraps if still too long).
Label.set_wrapis opt-in word wrap at the label width. - Block caret uses SGR reverse without also swapping fg/bg (the two cancelled, so the PTY cursor was invisible).
- Ctrl-C is a key, not Quit, in the Application decoder so a focused
Terminal gets 0x03.
spawn_shellresets the VT so a reused window is a fresh session.
Breaking — collections 0.9.2
The collections package was three eras stacked on each other: typed
leftovers from before generic structs, generic free-function APIs that
still looked like C, and a handful of method-based types. There were
three growable arrays, three maps and two sets. It is now one API, and
the duplicates are deleted rather than deprecated — no shims, on the
same rule sys.optparse followed: one library, and the breakage is the
migration.
Modules deleted. collections.list, collections.growable,
collections.map and collections.hashset are gone, as is the dead
generated collections/hashmap.c snapshot.
| Was | Now |
|---|---|
collections.list, collections.growable |
collections.vec — Vec[T] absorbed insert / remove_at |
collections.map (StringAnyMap, typed *Map) |
collections.hashmap — HashMap[K, V] |
collections.hashset, linear collections.set |
collections.set — one hashed Set[T] |
IntTrie / StringTrie |
one generic Trie[V] |
IntOrderedMap / StringOrderedMap / PtrOrderedMap |
one generic OrderedMap[K, V] |
HashMap is keyed by any Hash type, not just string, and no
longer takes a dummy default value. That dummy existed so value_at()
could return a V for an empty slot — a value the caller could not
distinguish from a real entry. Lookups return Option[V];
is_occupied_at / key_at / value_at are replaced by keys() and
values(), which walk the same slots in the same order.
Per-type factories are gone everywhere, replaced by one generic
create[T]() / create_with_capacity[T](n) per container:
create_int_hashmap, create_string_queue, create_int_list,
create_int_vec, create_int_set, create_int_stack, create_int
(trie) and their siblings. Vec.cap() is now capacity(), matching
every other container. is_full() and the *_to_array(arr, max)
out-parameter forms are dropped; to_array() returns a copy sized to
len.
stack, queue, bitset and trie are method-based. It is
s.push(x), not stack.push(s, x); bs.set(10), not
bitset.set_bit(bs, 10).
Two silent-drop behaviours are gone. OrderedMap had a fixed
capacity and discarded a new key once full — its own comment said so —
and Trie's insert no-opped when the node pool was exhausted. Both
grow now. Trie.try_insert therefore fails only on invalid input (an
empty key or a non-ASCII character), never on capacity.
Map keys compare with ==, which is reference identity for structs
and objects. For int, string and bool it is value equality, and
those are the key types to reach for. Two separately constructed structs
with identical fields are different keys, so get(new User(...))
cannot find an entry inserted under an equal-looking User. Key by an
int or string field instead. There is no Eq trait in 0.9.2 — one
would need Self, which the language does not have yet.
Map literals changed type. #{ } and to_map() now produce
HashMap[string, Any] instead of collections.map's StringAnyMap.
Keys stay strings and values stay Any. A program whose only map is a
literal now compiles and runs without importing anything — previously
it passed --check and failed at the C stage naming a module the source
never mentioned.
Downstream: type annotations and factory calls drop the dummy default:
name_map: hashmap.HashMap[string, int] // was HashMap[int]
name_map: hashmap.create[:string, int]() // was create[:int](0 - 1)
get already returned Option, so the -1 was never the
not-found path.
Breaking — core 0.9.2
core was the same three eras collections had been: C out-parameters and
-1 sentinels, duplicate classifiers and int parsers, and accessor functions
wrapping struct fields that were already public. Same rule as collections and
sys.optparse — the duplicates are deleted rather than deprecated, on the
same 0.9.2 cut, because a second breaking stdlib release one version later is
worse than one.
One int/float/bool conversion API. core.str's integer conversions are
gone and core.convert's parsers are snake_case.
| Was | Now |
|---|---|
str.from_int(n) |
convert.to_string(n) |
str.to_int(s) |
convert.to_int(s) |
convert.toInt / toFloat / toBool |
convert.to_int / to_float / to_bool |
str.to_int and convert.to_int are not the same function. They agree on
trailing garbage — both reject "123abc" — but differ on whitespace:
convert.to_int(" 42 ") is Ok, str.to_int(" 42 ") was Err. Every
migrated call site therefore starts accepting surrounding whitespace. All 18
were audited; the two in production stdlib are safe (a TOML theme value, where
being more forgiving is harmless, and an already-delimited lexer token).
One character library. Classification lives only in core.chars.
| Was | Now |
|---|---|
str.is_digit / is_alpha / is_alnum / is_whitespace |
chars.is_digit / is_alpha / is_alphanumeric / is_whitespace |
chars.char_upper / char_lower |
chars.to_upper / to_lower |
Note is_alnum → is_alphanumeric is a name change, not just a
qualifier. The four str predicates were verified byte-for-byte identical to
their chars counterparts before deletion, so no call site changes behaviour.
libc.ctype is kept but is a raw <ctype.h> binding, not a peer: its
predicates return int, so they need an explicit != 0, and it is hosted
only.
No sentinels. Lookups and digit conversions return Option.
| Was | Now |
|---|---|
str.index_of / index_of_char / last_index_of / last_index_of_char → -1 |
→ Option[int] |
chars.to_digit / hex_to_digit / octal_to_digit → -1 |
→ Option[int] |
chars.from_digit / from_hex_digit → '?' |
→ Option[char] |
array.index_of, min_int, max_int |
already Option; only the array_ prefix moved |
'?' was a particularly bad marker: it is a legitimate character, so a caller
could not tell a converted '?' from a failure.
split returns an array; join drops its count.
// was
mut parts: [string] = new [string](64)
let n = str.split(csv, ',', parts, 64)
let s = str.join(parts, n, ",")
// now
let parts = str.split(csv, ',')
let s = str.join(parts, ",")
split sizes its own result, so there is no caller buffer and no cap.
Splitting is total — adjacent and trailing delimiters keep their empty fields
— so join(split(x, d), d) == x. join uses parts.length(), so a caller
can no longer pass a count that disagrees with the array it was given.
BEHAVIOUR CHANGE — max_parts is gone, including its remainder-absorb.
The old cap did not just limit the count; the final part swallowed everything
after it. Measured before removal:
str.split("a,b,c,d", ',', buf, 2) -> ["a", "b,c,d"]
There is no replacement parameter. A caller who wants that shape splits fully and rejoins the tail, which reproduces the old output exactly.
Fields, not accessors. Error and the five Box* types have public
fields; the functions that wrapped them are deleted. A method of the same name
would have shadowed the field it returned.
| Was | Now |
|---|---|
error.error_kind(e) / error_message(e) / error_code(e) |
e.kind / e.message / e.code |
box.unbox_int(b) and siblings |
b.value |
Construction is unchanged: error.error, error.error_with_code and the five
box_* constructors do real work and stay.
Deleted outright. str.format (string interpolation superseded it; no
production call sites), memory.fill_bytes / fill_ints (one-line forwarders
onto memset_bytes / memset_ints), array.array_length (arr.length() is
a language builtin), and libc.format.parse_hex / parse_octal /
parse_binary (-1-returning forwarders onto core.convert.from_* — call
convert directly and test the Result). array's sort, reverse and
is_sorted families drop their len out-parameter and derive it from the
array.
Renamed in core.array — the array_ prefix is gone from every export:
array_contains → contains, array_index_of → index_of. (The
Option-table row above says "only the prefix moved"; this is the row that
names the two symbols, so a downstream grep for array_contains finds it.)
BEHAVIOUR CHANGE — convert.to_char clamps to 0..255 instead of casting.
It used to wrap: to_char(-1) gave -1 and to_char(300) gave 44, a
comma. It now saturates, matching memory.byte_from_int's range. Note the
asymmetry that comes with it: char is signed and byte is not, so a code
above 127 reads back negative through to_code — to_code(to_char(200)) is
-56 while byte_to_int(byte_from_int(200)) is 200. The range is
deliberately not narrowed to 0..127 to hide that: UTF-8 lead and continuation
bytes are ≥ 128 by construction, and a 127 ceiling silently destroys every
multi-byte encode.
Fixed — core 0.9.2
core.convert's formatter and its four parsers were silently wrong at the
32-bit boundary, while core.str's equivalents were correct. Since C5 keeps
convert and deletes the str copies, the correct behaviour was ported
first — comparing the two by name alone would have deleted the right
implementations. Measured before the fix:
convert.to_string(INT_MIN) '-'
convert.toInt("2147483648") Ok(-2147483648)
convert.toInt("99999999999") Ok(1215752191)
convert.from_hex("FFFFFFFFFF") Ok(-1)
convert.from_binary(33 ones) Ok(-1)
convert.from_octal("77777777777777") Ok(-1)
Every one returned Ok. These were not "returns a bad value" bugs but
"reports success on input it cannot represent" bugs, so a caller checking
is_ok was told nothing had gone wrong. to_string now special-cases
INT_MIN (which has no positive counterpart, so the negate-then-extract path
produced a bare "-"), and all four parsers carry exact overflow guards —
134217727 * 16 + 15, 1073741823 * 2 + 1 and 268435455 * 8 + 7 are each
precisely INT_MAX, so the largest literal in every base still parses.
Fixed — compiler
-
Generic inference now binds a type parameter reachable only through an
Fn[..]return position (reef-lang#46).fn map[T, U](x: Option[T], f: Fn[T, U]): Option[U]and every combinator shaped like it were unwritable: the call was rejected withexpected fn(int) -> ?, got fn(int) -> string. Nothing to do withOption, enums or closures — a barefn tr[T,U](x: T, f: Fn[T,U]): Uand an arraymapfailed identically, whileFn[T,T]and plain-argument inference both worked. Two structural walkers (Typechecker.infer_type_argsandMonomorph.infer_type_args_for_function) each had arms for type variables, generics and arrays and then a catch-all; neither had aTFunctionarm. Both were needed: fixing only the typechecker moved the failure from typecheck to link, because the call site was named correctly while the specialization was never generated. This is what had blockedmap/and_thensince the 2026-07-09 design. -
reefcwarns on unused imports. Nothing flagged an import no one referenced, which is how 79 of them accumulated. The check is narrow on purpose: it reports only a module that exports no type (Reef does not import functions into the main env, so such a module is reachable only through its namespace, making "no namespace lookup" exactly equivalent to "unused"), that is not arch-conditional, and that has no link-time side effect —import compress.zlibcan be the only thing supplying-lz, and removing it type-checks and then fails at link. Default is the module being compiled;--warn-unused-importssweeps every loaded module.
Added — core 0.9.2
map and and_then over Option and Result, specced in the 2026-07-09
Result/Option adoption design and never shipped because the compiler could not
type them (see the Fn[..] inference fix under Fixed — compiler).
option.map(opt, f) // Option[T], Fn[T, U] -> Option[U]
result.map(r, f) // Result[T,E], Fn[T, U] -> Result[U,E]
result.and_then(r, f) // Result[T,E], Fn[T, Result[U,E]] -> Result[U,E]
map leaves an Err untouched with its error type unchanged; and_then is
for the case where f itself can fail, which map cannot express — using
map there would give Result[Result[U, E], E]. Free functions, because
enums have no method production and a generic impl cannot bind T on
Option[T]. No ? operator.
Downstream
str.split and str.index_of* carry the migration: those are semantic
changes, not renames, so they are rewrites at each site rather than a
search-and-replace.
- A downstream package manager — 44 sites:
split17,index_of*18,error_message2,chars.to_digit3,str.from_int1. - A downstream init system — 24 sites:
index_of*12,str.from_int5,str.to_int3, error accessors 2,split1,str.is_digit1.
Neither is in this repository and neither is updated by this release.
Added — language
Four compiler features the collections redesign required. Each is independently useful.
impl Trait for int/string/bool. A trait implementation may target a primitive.42.hash()and"x".hash()resolve like any method call. Deliberately narrow:floatis still rejected, because NaN and-0.0break the contract that equal values hash equally.- Traits are importable.
export type Hashmarks a trait, and an importer binds it under its bare name —implandwheretake an identifier, not a qualified path. - Type-level
whereon generic structs.type Box[K] where K: Hash = structbounds the type itself. Method bodies may use the bound, and it is enforced at every instantiation:Box[float]is a type error. Generic structs only; active and passive objects do not take type-level bounds. - Trait implementations are program-wide. An
implis visible once its module is in the build graph, without being imported directly. This is what letsimport collections.hashmapalone satisfywhere K: Hashforstring, whose implementation lives incore.hash. Previously the bound was checked only against impls the calling module could see.
Added — stdlib
-
core.hash— theHashtrait (fn hash(): int) with implementations forint,stringandbool. It sits besidecore.optionso a user type canimpl Hashwithout importing a container. The hash/eq contract is documented on the trait and is not statically checked. -
encoding.value— JSON-shapedValuetree (Null/Bool/Int/Float/Str/Array/Object) withfrom_bool/from_int/from_float/from_string/from_object/array/null, plusget,to_json, and a one-levelfrom_any_map. -
encoding.js—js_escape_stringfor JS string-literal escapes (\,',", newline, CR, tab, NUL, UTF-8 U+2028/U+2029). -
reefmarker— veemarker-subset FTL (import reefmarker.engine):${},#if/#list/#assign/#noparse/#include/#macro/#attempt/#switch/#stop,?builtins,StringLoaderandFileLoader. Data isencoding.value.${true == true}/${flag == false}work (==/!=on Bool/Null);#include/<@macro>nesting is capped at 32.
Changed
- Source tarball no longer ships in-repo development trees.
package-source.shalready staged fromhg archive(untracked files cannot leak) but still copied tracked bug notes, internal planning notes, CI YAML, Dockerfiles, and marketing copy. Those are now excluded. The binary tarball'sshare/examples/omitstest_*compiler fixtures. Gate:scripts/test-package-source.sh.
Fixed — generics
Three defects found while building the collections redesign. All three are pre-existing and reproduce on the installed v0.9.1.
new <module>.Generic[T]()now builds (reef-lang#43). It was rejected by codegen with "Cannot instantiate this type with 'new'" for any type argument, aliased or not, with no trait bounds involved — it typechecked, and only emission refused. Two omissions: the construction could not be named, and its specialization was never recorded.new [<module>.Generic[T]](n)had the same gap.selfinside a generic struct's method isT[params], not the bareT(reef-lang#44). Passingselfwhere the struct's own type was expected — including to another method of the same struct — failed with "expected Pair['T], got Pair", so symmetric or delegating operations had to be written twice.- One module's generic instantiations no longer specialize another module's same-named function (reef-lang#45). Instantiations were keyed by bare function name, so any module declaring a matching name specialized against every recorded instantiation. With differing arity that emitted invalid C; with the same arity it could specialize a bounded function for a type that does not satisfy its bound, in a program where that function was never used with it. Keys now carry the callee's resolved defining module.
Fixed
-
uiwidget setters now mutate and say so — phase 3 ofdocs/design/2026-09-08-ui-modernization.md, closing reef-lang#62. Breaking.49 free setters of the shape
fn button_set_label(b: Button, label: string): Buttonbecameexclusive proc set_label(label: string)methods, and 7 internal*_with_*helpers taking a receiver (button_with_state,checkbox_with_hover,slider_with_dragging,textfield_with_focusand siblings) becameset_state/set_hovered/set_dragging/set_focused.button.button_set_label(b, "x")is nowb.set_label("x").The signature was the defect, not the behaviour. Widgets became objects on 2026-08-23 and objects have reference semantics, so these functions had been mutating their argument and returning the same reference ever since. Measured before the change:
let b1 = button.button("original") let b2 = button.button_set_label(b1, "changed") b1.label = changed b2.label = changed aliased = trueA
: Buttonreturn type promising a copy while delivering an alias is worse than a mutating method, and thewith_spelling — the conventional name for a copy — was worse still. Removing the return value is what makes the mutation visible at the call site. -
uiwidgets gainclone(design D2).shared fn clone(): <Widget>on all eight widgets, hand-written per type because objects have no derived copy. It is a shallow copy: inheritedWidgetstate (bounds,wstate,visible,enabled,focusable,tab_index) and every own field are copied,Panel'skidsarray is copied element-wise, and referenced widgets are shared rather than deep-copied.This is the replacement for what the old setter signature falsely promised.
button_with_state(base, Hovered)to paint a hover preview — the real use case, and exactly the one that was silently broken — is nowlet hovered = base.clone()thenhovered.set_state(...).The behaviour suite goes 98 → 126 assertions: section 5 was rewritten from pinning the #62 defect to pinning the fixed contract, and a new section 6 asserts clone independence once per widget so a reintroduced pseudo-copy setter fails loudly.
-
ReefVision
CMD_QUITno longer callsApplication.quit(). Accel and window commands setlast_commandso the app can confirm.run_loopstill quits when it seesCMD_QUIT.EventKind_Quitstill stops the loop. Demos that relied on auto-quit (reefvision_app_shell,reefvision_ide) now callapp.quit(). -
Alt-letter accelerators match without Shift.
norm_chalready uppercased Ctrl+letter; TTY Alt-q is often ch=113 while the bound accel is Alt-Q (ch=81). -
Desktop.addcaps visible windows, not lifetime children.close_window_atonly hides; countingchildren.length()blocked a new window after close. -
Object
inherited initand object-method calls apply parameter defaults (reef-lang#42). The last two call forms after struct methods (#15), objectnew(#21), and AOnew(#41).inherited init(x)againstinit(x, y = 7)splices y;obj.pick()andinherited pick()omit a defaulted method argument. Required params are still errors. -
AO
newappliesinitparameter defaults (reef-lang#41). Objectnewalready did (#21).--checkused to ignore AO construction args, sonew Sized(1)againstinit(x, y = 5)was a C too-few- arguments error. -
A
typecasearm must be the scrutinee class or a subclass of it (reef-lang#17 / BUG-222). An unrelated class never matches; a superclass of the scrutinee always matches. Both are now unreachable- arm errors. Exact-type arms stay legal. -
menhir-conflicts.shno longer treats a hard error as 0/0/0/0 (reef-lang#35 / BUG-223). A dead measurement now printsMEASUREMENT_OK=0and exits 2; the O7 gate requires the sentinel. -
A closure inside an
awaitcondition that capturesmutis a typecheck error (reef-lang#25 / BUG-263).--checkused to accept it and only codegen refused, soEXPECT_FAILcould not pin the diagnostic. -
Cross-module type mismatches name both modules (reef-lang#31 / BUG-271).
expected Widget, got Widgetis nowexpected liba.Widget, got libb.Widgetwhen the defining modules differ. Same-module messages stay unqualified. -
A qualified generic call infers against the defining module's signature (reef-lang#19 / BUG-236(a)). Two modules ending in the same path component (
pa.gen/pb.gen) no longer share a first-wins namespace env, sopb.gen.pick(1, "hi")emitspick_stringrather than the other module'spick_int. -
A field-less struct allocates (reef-lang#18 / BUG-233). GNU empty structs have
sizeof0;reef_heaps_new_objectrejected that, printed[Heaps] ERRORand returned NULL with exit 0. Field-less structs now emit achar __reef_pad. Objects already had D1's__reef_vtable. -
Object
initparameter defaults are applied bynew(reef-lang#21 / BUG-245).new Sized(1)againstinit(x, y = 5)used to be a checked error (M1 decision C5). Required-arity now ignores defaulted params; codegen splices the omitted default, including when a derived class inherits the init.inherited init(...)is unchanged (still raw arity). -
The t8 satellite-door gate locksteps Typecase arms with statement walkers (reef-lang#36 / BUG-256(b)). An armless walker used to leave the arm count green. Facet (a) already shipped.
-
spawn mod.f(args)typechecks (reef-lang#9 / BUG-178). The BUG-105 AO-method spawn guard treated the qualifier as a value, so a module namespace was "Undefined variable 'mod'".spawn mod.f[:T](...)already worked. Codegen uses the stampedCtFreesymbol. -
spawn f(...)splices omitted parameter defaults (reef-lang#16 / BUG-216).spawn greet("world")againstgreet(name, punct = "!")typechecked; the trampoline used to pass too few C arguments. -
spawn f(x)whenfis a closure local (reef-lang#10 / BUG-180). The trampoline used to call an undefinedreef_f. It now invokes the closure (fn(env, args…)). -
Struct-method parameter defaults work at the call site (reef-lang#15 / BUG-204).
b.pick()againstfn pick(fallback: int = 5)wasexpects 1 arguments, got 0. Required-arity now ignores defaulted params; codegen splices the omitted default (including generic methods). -
A parameter default's type must inhabit the declared parameter (reef-lang#12 / BUG-185 remainder).
fn f(n: int = "x")is a typecheck error. Stage 3 already typechecked defaults in isolation. The check uses the same inhabitation rule as argument passing, sofn defaulted(w: Base = new Derived(1))stays legal. -
A lambda with an explicit
returninside an AO method no longer unlocks the monitor (reef-lang#24 / BUG-262). The lambda is not the method;in_ao_methodis cleared for lambda scopes. -
awaitinside a lambda is rejected at typecheck (reef-lang#22 / BUG-254), same house rule asawaitinrun()(BUG-117). -
f[T](args)without the colon hintsf[:T](args)(reef-lang#32 / BUG-292). The documented explicit generic-call spelling uses[:T]. -
test_bug117_run_await_workaroundno longer lost-wakes (reef-lang#39).run()does not hold the AO monitor, soself.fired = truethere never calledmonitor_handoff. The test now setsfiredvia an exclusive self-call, matchingparallel_hash. Reproduced 4/300 on one CPU before the fix; 0/300 after.
0.9.1 — 2026-08-25
Patch after 0.9.0: sys.optparse replaces sys.flag / sys.args,
the ParseError codegen ICE is gone, Derived→Base inhabitation is
total, and this is the first tag whose tarballs CI uploads to DigitalOcean
Spaces (s3://reeflang/v0.9.1/). Compiler and runtime still upgrade
together (descriptor ABI v4, unchanged from 0.9.0).
Changed
sys.flagandsys.argsare gone. CLI parsing issys.optparse: oneCommandtree, typed cells (verbose.value()afterparse), git-style globals before the subcommand token, built-in--help/-hand--version/-V. Raw argv isoptparse.program/argc/argv_get. This is the break downstream CLIs need in order to drop their private scanners (reef-lang#38).
Removed
reef-stdlib/sys/flag.reef(sys.flag) andreef-stdlib/sys/args.reef(sys.args).
Fixed
-
Matching an imported enum no longer ICEs when another module declares the same type name as a struct (reef-lang#40).
resolve_variant_from_constructorstill looked up the enum by the bare name"ParseError". Afterimport sys.optparseandimport encoding.toml(or json), the last-registeredParseErrorwas the struct, sooptparse.format()'smatchdied at codegen while--checkstayed green. The lookup now uses(module, name). Witness:reefvision_hello(SKIP in the suite as interactive) andtest_parseerror_optparse_toml_{a,b}/_json. -
A
Derivedobject now inhabits aBaseslot everywhere (reef-lang#34, BUG-320). 0.9.0 shipped with four positions missing from the inhabitation rule; investigating them found the rule was actually missing from a whole category of positions, so the fix is broader than the four reported:- a module-level
mut g: B = new Dv(1)and an array-element storea[0] = new Dv(3)emitted C with no pointer cast. Both built under clang and were a hard error under gcc (14+ makes-Wincompatible-pointer-typesan error), soreefc --checkpassed and the failure surfaced in generated C. reefc prefers clang when both are installed, which is why a clang box never saw it. - a lambda expression body
fn(k: int): B => new Dv(k)and a module-level array literalmut ga: [B] = [new Dv(1)]were refused at--check, though the named-function and local-declaration spellings of each were accepted. - Fixing the lambda refusal exposed the wider defect underneath it: an
implicit trailing-expression return never emitted the cast in ANY
of its six generators (free function, struct method, object method,
Active Object method, lambda expression body, lambda block). Only the
explicit
returnspelling did. Sofn mk(): B return new Dv(1) end mkcompiled whilefn mk(): B new Dv(1) end mkdid not — the same program, under gcc only.
Both halves are now single points rather than call lists: every arm of the assignment generator takes its right-hand side from one coercion helper, all three module-global initializer arms publish through one store helper, and every return — explicit and implicit — produces its value through one shared
gen_ret_expr. The emitted C for the whole 824-unit corpus is byte-identical across the change, since the cast is a no-op unless the source is a strict subclass of the target. - a module-level
Added
-
The emitted-C corpus is now compiled with gcc in the example suite (
gcc_syntax_corpus.sh, 822 units, ~15 s). Before this, no gate compiled the general corpus with gcc at all: the other corpus gates read emitted C textually, the dual-backend leg covers only the 17 window fixtures, and the stdlib gate covers only stdlib modules. That gap is what let BUG-320 reach a release, and it is the same shape as the other entries on the "gates blind to their own purpose" list. Cannot-run — no gcc, zero units, or a gcc that does not reject the defect shape — is a failure, not a skip. -
Tag pipelines now upload the source and linux-amd64 tarballs to the
reeflangSpaces bucket (publish-spacesstep) afterpackageandimage. Versioned keys refuse overwrite;latest.jsonis the only floating pointer.https://dl.reeflang.dev/is not live yet; origin URLs are underhttps://reeflang.mkc1.digitaloceanspaces.com/v$VER/. 0.9.1 is the first release that goes through that path.
0.9.0 — 2026-08-24
0.9 is the passive-object release: single inheritance, virtual methods,
typecase, traits on objects, precise GC of heap-embedded enum payloads,
and an opt-in owner-check harness. Language walkthrough:
040_OBJECTS.md. Showcase:
examples/widget_tree.reef. Compiler and runtime must upgrade together
(descriptor ABI v4).
Changed
-
CI is two-speed. Every push runs
make all+make test(runtime ctest +dune test) +docs-check. The example suite (make test-harness) andvtable-checkrun on@, pull requests, and tags. ASAN, UBSAN, and the gcc/clang runtime matrix run on tags only (same commands locally any time). Job timeout 90m so the tag lane can finish; a feature-bookmark push no longer tries to do the whole matrix inside 30 minutes. -
ReefVision is a passive-object hierarchy.
View→Group→Window/Desktop; widgets extendView. Insertion iswin.add(child)with locals (no widget id).new Application(th)is passive, not an Active Object; the loop isdo_step/run_loop(stepandrunare reserved).InputLinetakes three arguments (bounds,max_len,initial).handle_eventreturnsEventResult. Deleted:ChildKind, parallel arrays,window_add_*,group_add_*,*_slot_by_id,app_input_text_by_id. Guide: reefvision-app.md. Demos:examples/reefvision_{hello,app_shell,installer,ide,gauge}.reef. -
ui.widget.Widgethierarchy for allui.widgets.*. Label, Button, Panel, Checkbox, Slider, Progress, ScrollView, and TextField extendWidget. Prefixed wrappers remain.Panel.addholds[Widget]children and virtualpaintwalks them.WidgetBaseis unused by these widgets (still in the module for any out-of-tree embedders). Example:examples/test_stdlib_ui_objects.reef. Copy-on-write setters now mutate and return the same object. -
graphics.backend.surfaceis a passive-object hierarchy.Surfaceis the base class;MemorySurfaceandFramebufferSurfaceoverridekind/end_frame. Prefixedsurface_*functions remain as wrappers (canvas/ui/gameui unchanged). Example:examples/test_stdlib_surface_objects.reef. -
io.stream/io.bufferare passive objects.Streamis the base;FileStreamis fopen-backed (stream_open);MemoryStreamis an in-memory buffer with virtual read/write/seek.BufferedReaderandBufferedWriterare objects. Prefixedstream_*/reader_*/writer_*wrap methods. Example:examples/test_stdlib_stream_objects.reef. -
Language reference chapters renumbered to reading order. The
NNN_prefix is the order (gaps of 5). Index: 000_INDEX.md. Bookmark mapping from the previous numbers:Was Now 031_PROJECT_STRUCTURE 010_PROJECT_STRUCTURE 010_BASICS 015_BASICS 015_STRING_INTERPOLATION 025_STRING_INTERPOLATION 028_ERROR_HANDLING 035_ERROR_HANDLING 035_OBJECTS 040_OBJECTS 040_ACTIVE_OBJECTS 045_ACTIVE_OBJECTS 080_TRAITS 050_TRAITS 050_GENERICS 055_GENERICS 055_CLOSURES 060_CLOSURES 026_DEFER 065_DEFER 027_SPAWN 070_SPAWN 060_MODULES 075_MODULES 029_TESTING 080_TESTING 025_UNSAFE 100_UNSAFE 070_FFI 105_FFI 075_INLINE_ASSEMBLY 110_INLINE_ASSEMBLY 076_SYSTEMS_PROGRAMMING 115_SYSTEMS_PROGRAMMING 077_FILESYSTEM_OPERATIONS 120_FILESYSTEM_OPERATIONS 078_STREAMING_IO 125_STREAMING_IO 085_RAW_SYSCALLS 130_RAW_SYSCALLS 090_REEF_OS 135_REEF_OS Unchanged:
000_INDEX,005_COMPILER_USAGE,020_FUNCTIONS,030_TYPES. Systems chapters start at 100 so a later language chapter can use 070–095 without colliding with FFI. -
Passive-object pointers in the reference chapters that would otherwise invite the wrong construct: generics reject
object Stack[T]; closures note that capturing a widget does not transfer ownership;unsafedoes not bless a cross-owner touch.typecaseis pointed from Basics' match section; Testing showsreefc --owner-harness. -
Owner-check harness (0.9 Phase 10 / M4). Opt-in:
reefc --owner-harnessstamps eachnewobject with its owner (innermost held Active Object, else the allocating thread) and aborts on a mismatch at object method entry and field writes. Finalize-time access always fires (O8). Default builds emit no probes (goldens unchanged). Not a release CI gate. -
impl Trait foran object class (0.9 Phase 9 M3 / spec §4.5). Subclasses inherit the base's impls for method syntax; awhere T: Traitbound accepts a Derived when a Base implements the trait. Trait methods stay statically dispatched. The documented pattern for per-class behaviour is delegate-to-virtual (class method is the virtual; the impl forwards under a different name — same-name own method plus impl is still D42). Reference chapterdocs/language/reference/040_OBJECTS.md(docs-check floor 6); showcaseexamples/widget_tree.reef. -
typecaselowers to anis-chain (0.9 Phase 9 M3). Multi-arm runtime type dispatch over a hierarchy (typecase w ... end typecase) compiles and runs. Binders are the narrowed view. The previous'typecase' not yet enableddoor is gone. The T8 satellite gate now counts the six Typecase traversal arms (the old grep demanded the door-shaped(_, _, _)wildcards and reported 0 after the rewrite). Two fixtures that now run (test_object_typecase_single_class,test_typecase_empty_arm) gained.expectedfiles (BUG-166). -
Trait-impl methods are callable from object, struct, and Active Object method bodies (BUG-255). Prototypes live in the module forward-declaration block, same naming pair as the definition.
-
A Derived object inhabits any Base slot (owner decision D39 / BUG-302). Struct literals, positional
new S(...),append, returns, call arguments, match expressions and if expressions accept a Derived where a Base is expected. Array types stay invariant ([B]is not a[W]).types_compatibleis still unification only — the object arm stays ontypes_compatible_unsafeso trait-impl signatures cannot become covariant. -
Every stdlib module is compiled in CI (owner decision D38). A package with no example can no longer sit broken while the suite stays green (BUG-315's seven months).
tests/tools/stdlib_compile_gate.sh: importer →--emit-c→gcc -fsyntax-onlyfor every tracked module (arch-specific ones with--target). 159/159. Cannot-run is a failure. -
gameuiis buildable (owner decision D37 / BUG-315). Arrow-key constants arekey_arrow_up/down/left/right(); the querykey_down(inp, key)is unchanged. A duplicate top-level function name in one module is a type error (Reef has no overloading).--checkused to accept the collision; codegen emitted both prototypes and one body; C was the only catch. The package had never compiled. Same-nameextern "C"plus a Reef wrapper, andasmbodies for different architectures, remain legal. -
UBSan is a CI gate (owner decision D49).
make test-ubsannow useshalt_on_error=1(the old=0printed diagnostics and still exited 0) and runsscripts/ubsan_halt_canary.shfirst so a mute sanitizer cannot green the suite. CI has atest-ubsanstep. Local parity:make test-ubsan.
Fixed
-
BUG-323: InputLine / TextArea paint as inset fields: a darker panel shade (not the window client, not near-black). Idle and focused still share that fill (BUG-322). No
[ ]chrome — it read as part of the value and stole two columns. -
BUG-322: InputLine / TextArea idle and focused fills use the same panel background (caret / brighter fg mark focus). Clicking into a field no longer punches a near-black bar.
-
BUG-321:
Application.message_box/modal_runno longer write the dialog result intolast_command. Ado_steploop that opens a Greeting box onCMD_OKused to close the modal and immediately reopen it. -
BUG-269: a generic enum used only as a parameter (or field) type is still specialized.
Box[string]no longer ICEs inresolve_variant_from_constructorwhen the module never constructs aBox. -
BUG-276: a type alias used as a generic argument is the same type as its underlying (
Option[MyRect]vsOption[Rect]).mangle_type/same_type_argsexpand aliases too, soResult[StrOpt, Error]withtype StrOpt = Option[string]is one specialization (BUG-081's fixture stays green). -
BUG-277: a local
type Errorno longer stealscore.result'sE. Enum-ctor return types use the user-type ambient ("main", not""), and imported signatures are converted against a registry seeded from the whole loaded graph before main is checked — soconvert.toIntstaysResult[int, core.error.Error]. -
BUG-187: generic-instantiation scan walks lambdas, struct/map literals, type_name/type_id/is/as, and to_map.
-
BUG-190:
substitute_typedescends intoFn[…]andset[…]. -
BUG-207: trait-impl bodies and module-level initializers are scanned for generic instantiations.
-
BUG-217 / BUG-316: match binders substitute nested generics and array payloads (
Inner[T],[T]), not only a bare type variable. -
BUG-210: a do-while/do-until condition that names a non-GC local declared in the body is a typecheck error, not an undeclared C identifier.
-
BUG-228: struct methods get C prototypes before their definitions, so
self.later()no longer depends on declaration order. -
BUG-235:
gameui.widgetscallssys.platform.runtime.float_to_intinstead of a missingreef_float_to_int. -
BUG-241:
type_namein interpolation orreturnno longer discardsconst(call-site cast from BUG-225). -
BUG-244: record field names that collide with C reserved words are mangled at declaration, access, and
offsetof. -
BUG-246:
Any(andany) fields emitvoid*; a heap value can inhabitAny. The object-retention fixture traces anAnyfield. -
BUG-183: a module
constwhose initializer is not a C constant (interpolation, enum constructor) is initialized at module-init time. -
BUG-247:
new Box[int](9)now assigns constructor arguments to fields instead of splicing them intoreef_heaps_new_object. -
BUG-215: defer bodies are emitted in a C block, so a shadowed
let ninside a defer is legal C (and legal Reef). -
BUG-225:
type_name/type_idon a scalar is a typecheck error; on a heap value, theconst char*result can be bound without a-Werrorfailure. -
BUG-300: slice assignment (
a[1..3] = b) is rejected at typecheck — a slice is not an lvalue. -
BUG-221: a missing field on a passive object is reported as
Object, notStruct. -
BUG-242:
reefc docdocumentsobjectdeclarations. -
BUG-281:
is_enum_valued_typelooked up types by bare name, so a module global of an imported generic struct beside a local enum of the same name was not registered as a GC root and was collected while referenced. It now uses the qualified table (is_enum_type_q), same one-liner as the locals/fields twin at rev 993. -
BUG-296:
+=/-=/*=//=/%=evaluated the left-hand side twice (side()[i] += 1calledsidetwice). Codegen now evaluates the designator once when both sides share the same source node. -
BUG-256 (a): the cross-module test runner treated
All tests passedwith zero assertions as a pass. It now also requiresTests run:with a positive count. -
reefc --target *-baremetalwithout--emit-cis a pointed error (owner decision D46 / BUG-280). The help text advertised a full compile; the driver then invoked hostedcc(-lreef_runtime -lpthread) and died with'reef_baremetal.h' file not found. Baremetal targets emit C for an external freestanding build (the kernel Makefiles).--checkand--emit-cstill work.--linker-scriptwas parsed and ignored; it now errors as unused. -
GC values are no longer unrooted across the CALL/RETURN HANDOFF (0.9 Phase 8, BUG-299 / owner decision D33). This was silent memory corruption in any multithreaded Reef program, and it shipped. A GC value returned BY VALUE — a string, an array, a struct, an object, a closure, an enum's payloads — was rooted NOWHERE between the callee's frame pop and the caller's publication of it: the callee's frame is gone, the caller has not stored it yet, and precise marking cannot see a register or a return temp. That interval contains no allocation by that thread, which is why the compiler's rooting was correct at every site on the value's path. It stopped being sufficient when the collector gained preemptive stop-the-world (0.7.x): a SIBLING thread's collection suspends this thread at an arbitrary instruction, so a collection can land in a window this thread never allocates in. The value is then swept while live, its memory reused, and read back as unrelated data — no crash, no diagnostic. It needed only a second thread, i.e.
spawnor any Active Object; neither a sanitizer nor the GC torture mode was required (measured: 4 of 32 ordinary runs, no sanitizer, plain build). Values returned by a function are now published into a per-thread GC handoff cell that the collector scans, and per-call-site argument roots are held until the enclosing frame pops instead of being released after the call. Programs get slightly larger emitted C; nothing about the language changes. -
int as floatis accepted (BUG-317). Integer sources mayastofloat/float32/float64. This was a missingis_castablearm, not a design:float as intalready worked and codegen already emitted((double)n). Justify as explicit conversion, not as lossless — a 32-bitintis exact infloat64and not infloat32.intstays 32-bit.5 as size_tandint as char/bool as boolare accepted in the same batch (BUG-194'sashalf). Mixedint + floatarithmetic is still rejected. -
awaitconditions are live expressions (owner decision D44 / BUG-251). Captured locals and parameters are re-read from the parked frame on every wake;selfwas already a live pointer. Amutlocal written through a captured closure while the waiter is parked is now visible (the snapshot copied the int at entry and deadlocked).await self.count == expectedis unchanged. Shadowed names in a condition remain a type error (D5). No runtime ABI change. -
Trait impl methods on enum targets are callable with method syntax (owner decision D43 / BUG-238).
impl Show for ShapewhereShapeis an enum already emitted and ran asShape_show(c)and insidewhere T: Show;c.show()was rejected withCannot resolve type 'Shape'. The method-call resolver now looks up the impl on an enum receiver the same way it does for a struct with no own method. A miss saysType 'Shape' has no method 'show'.
Breaking
-
Ordering operators (
<,>,<=,>=) are numeric only (owner decision D45 / BUG-219).==/!=still apply to any matching pair (strings viastrcmp, objects/AOs as reference identity). Same-type struct, array, string, bool, set, enum, and Active Object ordering used to typecheck as a raw C<— pointer compares, and for enums invalid C. They are now a type error:ordering operators are not defined for type 'T'. Integer family (includingcharandsize_t) and floats are unchanged. String lexicographic<is not this bug. -
A type cannot have two methods of the same name (owner decision D42 / BUG-274 option 3). An own method
m(struct, Active Object, or object — including inherited object slots) plus a trait-impl methodm, or two impls providingm, is a type error:Type 'T' already has a method 'm'; an impl cannot provide another. This used to pass--checkand fail only as a C-symbol collision (D12).x.m()andType_m(x)are not two namespaces. An own method satisfying a trait (so the impl may omit it) is deferred to Phase 9. -
A body on a trait method is a type error (owner decision D41 / BUG-202 option B). The grammar still parses default implementations, but they were never typechecked, never emitted, and never callable —
reefc --checkaccepted arbitrary garbage inside them. A trait is signatures only; everyimplmust provide every method (the documented 0.8 rule). Clone-per-impl defaults — the better long-term language — are deferred: reef-lang#5. -
asno longer C-casts a heap value to an arbitrary type (owner decision D40)."hi" as bool,"hi" as int, andobj as intused to typecheck and compile to a raw C cast of the pointer (always-true bool; address bits as int). They are now a type error, including insideunsafe. Downcasts (p as Person,any as BoxInt,w as Button) and FFIarr as pointerare unchanged. The diagnostic for a refusedasnames both types instead of claiming a heap-allocated source is required. Integer-to-pointer still requiresunsafe(BUG-119); its error says so. -
Descriptor ABI v4 — compiler and runtime must upgrade together (0.9 Phase 8, BUG-299). The descriptor STRUCT is unchanged (LP64
sizeofstays 96);REEF_DESCRIPTOR_ABI_VERSIONmoves 3 -> 4 because generated C now writes a NEW RUNTIME ROOT — the per-thread GC handoff cell__reef_gc_handoff(reef_heaps.h) — and the dangerous skew direction for that is the SILENT one. An OLD runtime with a NEW compiler is already loud (the emitted C names an undeclared array and does not compile); a NEW runtime with an OLD compiler would link, run, never write the cell, and silently restore the memory unsafety BUG-299 removes. The version's documented scope is therefore widened here from "the descriptor ABI generation" to "the emitted-C <-> runtime CONTRACT generation", of which the descriptor layout is one part. Every unit a 0.9 reefc emits carries a_Static_asserton it, so a mismatched install fails the C compile loudly. Downstream GC implementors must add__reef_gc_handoffto their root set when they migrate — the freestanding mirror inreef-baremetaldeclares the array so generated C compiles, but nothing there scans it, exactly as with the shadow-frame chain. Same lockstep note as the ABI v2/v3, the finalizer-API and the 0.8.6 string-header breaks. -
The finalizer registration API changed shape — compiler and runtime must upgrade together (0.9 Phase 8-D31, BUG-303).
reef_heaps_add_finalizernow takes the finalizer PROCEDURE (reef_heaps_add_finalizer(obj, proc)); it used to take a caller-allocatedreef_finalizer_node_t*, and that type — along with the unusedreef_finalized_collection_t— is removed fromreef_heaps.h(and fromreef-baremetal's mirror). The registration record is now allocated, recycled and freed by the runtime. Why it is a break rather than an addition: the old signature made two defects unfixable from inside the runtime. It could not free the record (it did not own the storage, and in-tree callers passed stack and static memory), so 24 bytes leaked per finalizable object EVER constructed — a leak 0.9's door 5 multiplies from "one per Active Object" to "one per object". And caller-supplied storage could die before the object did, which is a use-after-free the API invited (BUG-304). Emitted C shrinks: three lines and acallocper construction become one call, and thecalloc's unchecked NULL return — a silent crash on OOM — is now a loud runtime abort. A unit generated by a pre-0.9 reefc namesreef_finalizer_node_tand therefore fails to COMPILE against the 0.9 runtime, which is the loud direction; the reverse (0.9 units, older runtime) fails at link. Same lockstep note as the ABI v2/v3 and 0.8.6 string-header breaks. Downstream GC implementors inherit the ownership rule with it. -
Descriptor ABI v3 — compiler and runtime must upgrade together (0.9 Phase 8e, object milestone M2).
reef_record_block_t.methodsis re-typedreef_method_proc_t*->const void*andnum_methodsis RETIRED;REEF_DESCRIPTOR_ABI_VERSIONmoves 2 -> 3 (LP64 sizeof 104 -> 96, the retiredinttaking its own trailing padding hole with it), and the now-unusedreef_method_proc_ttypedef goes with them. The method table is a per-class VTABLE STRUCT — a struct of differently-typed slots, each typed by the class that INTRODUCED the virtual — so no single function-pointer type describes it, and slot COUNT is a compile-time property of that struct type rather than something a pointer beside it could track. The field is OPAQUE to the runtime: the collector, the allocator and the RTTI walk never dereference it; only generated C, which knows the class, casts it back. Measured before the change:methods/num_methodswere read NOWHERE across runtime, baremetal and compiler. Every unit generated by a 0.9 reefc carries a_Static_asserton the version and onsizeof, so a mismatched install fails the C compile loudly. Downstream GC implementors must dropnum_methodsfrom their descriptor mirror and re-typemethods; nothing reads either, so no logic moves with it. Same lockstep note as the ABI v2 and 0.8.6 string-header breaks. -
Descriptor ABI v2 — compiler and runtime must upgrade together (0.9 Phase 8c, BUG-240).
reef_record_block_tgained four fields (enum_fields/num_enum_fields/variants/num_variants— the conditionally-a-pointer root kind; LP64 sizeof 72 -> 104) andREEF_DESCRIPTOR_ABI_VERSIONmoved 1 -> 2. Every unit generated by a 0.9 reefc carries a_Static_asserton both, so a mismatched install fails the C compile loudly instead of silently zero-initializing the new fields (which would reintroduce the exact bug the fields fix). Units generated by a pre-0.9 reefc carry no assert and compile silently against the v2 header; their zero-initialized new fields are the correct empty state, so the consequence is benign. Same lockstep note as the 0.8.6 string-header break. Downstream GC implementors must adapt their descriptor mirror before consuming 0.9 descriptors: an enum's descriptor now carries a per-variant trace table, and containing types reference it for by-value payload-enum fields. -
Type identity is now MODULE-QUALIFIED, and generated C symbols carry the defining module. A type is identified by its defining module plus its name, not by its name alone. Two modules may each declare a
Rectand a program may import both; qualify at the use site and each keeps its own fields, layout and GC descriptor. What changes for existing code:- A bare reference to a type that two imported modules both declare
is now a compile error naming both modules, instead of silently
picking one. Qualify it (
core.Rect). This matches what bare references to same-named GENERIC types have done since 0.7. - Passing one module's
Widgetwhere another module'sWidgetis declared is now a type error. It used to compile, because the two were one type. - Generated C symbols are renamed, including monomorphized
specializations, which now carry their type ARGUMENT's module as
well. For a type you DECLARED,
type_name()still returns the source name you wrote. A SPECIALIZATION has no source spelling, so its descriptor name is its mangled name and follows the rename:type_name()on aResult[int, Error]value now returnscore_result_Result_int_core_error_Error. Generic FUNCTION specializations move with them (reef_core_result_is_err_int_Error->reef_core_result_is_err_int_core_error_Error, 84 of them across the compiler's standing corpus). Measured over that corpus, no symbol is removed: every one of those 84 has a renamed counterpart, and the rename adds 2,369 names without dropping one. Nothing else in the language depends on these names, but anyone reading--emit-coutput or a linker error will see the new spelling. - Two declarations that derive the same C symbol are now a checked
error naming both. Symbol components are joined with
_and Reef identifiers may contain_, so this is possible even with distinct modules (moduleA_bclasscand moduleAclassb_c). Rename one. One shape that used to compile is caught by this at the C-symbol door: a struct with an OWN methodmand a trait impl providingmfor the same struct. Those were two C symbols before (Buf_sizeandreef_Buf_size) and are one now. As of D42 that shape is a type error (BUG-274 option 3); D12 remains the backstop for theA_b.c/A.b_cjoin, which is not a source-level same-name pair. - Two modules declaring the same
objectclass were still refused when this note was written; they coexist as of the object inheritance milestone later in this same release (BUG-272 — passive-object class identity is module-keyed too now). See the entry under Added.
- A bare reference to a type that two imported modules both declare
is now a compile error naming both modules, instead of silently
picking one. Qualify it (
-
typecaseandinheritedare now RESERVED WORDS (0.9, owner decision 2026-08-12 — the only two additions to the reserved-word list in the 0.9 cycle;extendsandoverrideremain contextual and safe to use as identifiers). Any program using either word as an identifier — a variable, function, field, type, or method name — stops parsing with the standard reserved-word diagnostic:program.reef: Syntax error at line 2, column 17 note: 'inherited' is a reserved word and cannot be used as an identifier(As with every reserved word, the reported line/column points at the token AFTER the offending identifier — the
=,(, or:that follows it — not at the identifier itself.)Migration: rename the identifier; any other name works. A scan of the entire in-tree corpus (stdlib, examples, reef-os, baremetal) found ZERO real uses of either word as an identifier, so typical programs are expected to be unaffected. Why: the contextual spellings were implemented first and measured inadequate — contextual
typecaseis structurally unparseable at statement head (LR-ambiguous against bare-identifier statements), and contextualinherited(recognized by statement-pair fusion) could not represent value-position base calls likelet r = inherited area(). Reserving the two words makes both features ordinary grammar:typecasegains its statement form (arms shaped exactly like statement-match arms, with per-armend), andinherited m(...)/inherited init(...)work in statement AND value position. When this note was written both were still gated at the build step;inheritedis fully implemented as of the object inheritance milestone later in this same release, andtypecaseremains gated ("'typecase' not yet enabled") until its lowering lands. -
selfcan no longer be assigned to (BUG-226; 0.9 Phase 7 T1). Rebinding the receiver itself —self = expr, in a struct, Active Object or passive-object method, or ininit/finalize— type-checked cleanly in every receiver kind and is now a checked error:Type Error: Cannot assign to 'self': the receiver is an immutable binding and cannot be rebound. (Assigning a FIELD of the receiver, 'self.field = value', is a different statement — legal in exclusive methods, and rejected in shared ones by the data-race rule.)Breaking in the strict sense: a program containing
self = ...compiles at v0.8.6 and does not compile now. Nothing useful is lost — the receiver was always bound as immutable (the check simply never looked at it, becauseselfis not an ordinary variable node), and the emitted C assigned a local alias, so the statement never did what it appeared to do. A scan of the whole in-tree corpus found ZERO uses. Migration: assign the fields you meant to change (self.x = ...), which is what the statement was silently approximating. -
A closure now captures the INNERMOST binding visible where the closure is written (0.9 Phase 7 T9, owner decision D4). This is a behaviour change, not a new rejection: affected programs compiled before and compile now — they just produce a different (correct) value. If a name is not shadowed anywhere in your program, nothing changes.
The rule is now the ordinary scoping rule, applied once. Capture decides how a value is stored (copied, or boxed by reference), never which binding a name means; a capture and a direct read of the same name at the same point always refer to the same binding.
The headline shape is a loop variable that happens to share a name with something already in scope — no deliberate shadowing involved:
let v = "OUTER" let xs: [string] = ["A", "B"] mut out = "" for v in xs let f = fn(): string => v out = out + f() + ";" end for println(out) // was: OUTER;OUTER; now: A;B; println(v) // OUTER, unchangedWhat was actually happening. The rule being replaced was never "outermost" in any principled sense — it was "the first binding of that NAME registered in the enclosing function's frame, in source order", a statement about a hash table rather than about scopes. It therefore also mis-resolved DISJOINT SIBLING BLOCKS, where no shadowing exists at all and neither binding encloses the other: two
ifblocks each declaring their ownlet v, and the second block's closure read the FIRST block's value. Worse, it was not even one behaviour — captures of GC-managed values (strings, arrays, structs, objects) went through that name map, while captures of plain scalars emitted a bare C name and were resolved INNERMOST by C's own block scoping. So the same shadowed name could resolve two different ways in one function depending only on the captured value's type. All of that is gone: captures are resolved by the type checker against real scopes, per binding instance.Migration: none required, and there is no diagnostic to look for (the old behaviour was silent). If a program deliberately relied on a closure reaching past a shadowing binding to an outer one of the same name, rename one of the two bindings — that program was reading the outer value by accident of the implementation, and its scalar-typed equivalent already read the inner one. Also fixed by the same change: a lambda-local binding that shadows a captured name is now read correctly inside the lambda (BUG-229), and closures nest to any depth (BUG-230) — both listed under Fixed below.
-
An
awaitcondition may no longer name a binding that is shadowed at the await site (0.9 Phase 7 T9, owner decision D5). This is a new checked error on code that compiles at v0.8.6:await condition names 'v', which is SHADOWED at this point. An await condition is a by-value snapshot taken once and re-evaluated on every wake without refresh, so a shadowed name is never what you meant — the wait would be either instantly true or never true. Rename the inner binding, or read the value through 'self'.exclusive fn go(v: int): int if true let v = 2 // shadows the parameter await v == 5 // <- now rejected return v end if return 0 end goWhy refuse rather than pick a rule. An
awaitcondition is not a closure: it is a by-value SNAPSHOT, taken once when the wait begins and re-evaluated on every wake WITHOUT refreshing the values it named, withselfthe only term that can change in between. So a shadowed name is wrong in both directions, and both were measured on this exact program shape: under the old outermost reading you wait on a predicate the programmer did not write (a string probe returned immediately); under D4's innermost reading you wait on a frozen local that nothing in the condition can ever change, and theintversion DEADLOCKED. House precedent is BUG-117, whereawaitinrun()was rejected rather than made to compile, on exactly the reasoning that a silently non-blocking (or never-blocking) wait is worse than a compile error.Migration: rename the inner binding, or make the condition depend on
self(await self.ready) — which is what a condition that is meant to become true has to do anyway. An UNSHADOWED name in an await condition is unaffected. As of D44 the condition is live (BUG-251 closed): unshadowed names are re-read each wake. D5 stays because a shadowed name is still never what was meant. -
Declaring a
mutbinding that a closure captures now ALLOCATES (0.9 Phase 7-ter; the storage half of the BUG-253 fix below). This is behaviour-breaking in exactly two places, both narrow:- Inside
finalize(). Finalizers run inside the allocator and must not allocate — an allocating finalizer hangs the program with no diagnostic. Writing a closure in a finalizer already allocated (the closure's environment is a heap object), so the newly affected shape is precise: amutbinding declared unconditionally in a finalizer body whose capturing closure is only built on a branch that never runs. That used to complete; it now hangs. What to do: do not declare capturedmutbindings in a finalizer. The full list of allocating constructs — which now names closures and capturedmutbindings, and previously mentioned neither — is in Active Objects → finalize. - On freestanding/baremetal targets, where the allocator is a panic
stub. A kernel that declares a captured
mutbinding now panics at that declaration instead of running. This is deliberate: the alternative is keeping a write-through-a-dangling-stack-pointer primitive alive inside kernel code, which is the worst possible place for one. Emitting the box is unconditional — there is no target switch — and the freestanding shape is gated in CI across amd64, aarch64 and riscv64.
Everything else about the change is a fix, not a break: see below.
- Inside
Fixed
-
Argument evaluation order no longer decides whether a GC value survives a call (0.9 Phase 8, BUG-301, owner decision D29). C leaves the order in which a call's arguments are evaluated unspecified, and the two supported C backends disagree: gcc evaluates argument lists right-to-left, clang left-to-right. reefc's argument-rooting gate asked whether a rootable argument appeared before an allocating one in the SOURCE — which is not a fact about evaluation — so a call whose only GC-typed argument was the LAST one, beside an earlier argument that allocates, was left unrooted. Under gcc the GC value is produced first and can be collected while the earlier argument runs, and the callee receives a dangling pointer: 374 of 2000 iterations corrupted under gcc, 0 under clang, from safe (non-
unsafe) Reef with no diagnostic. Invisible on an ordinary build, because reefc prefers clang; live on gcc-only targets. The gate is now order-independent. No Reef-source change is needed; recompiling is enough. Pre-existing: the shape is present in every release with argument rooting, and releases before it (0.8.6 and earlier) have no argument rooting at all. -
The mutable-capture GC box migrated onto the recursive descriptor root kind (0.9 Phase 8d, owner decision D17 — internal to the emitted C; no Reef-source or runtime change). A captured
mutpayload-enum's cell no longer carries compiler-emitted sibling shadow words refreshed at every store site (7-ter's BUG-253 fix shape, where a missed store site would have been a silent collection); the cell descriptor now carries a typedenum_fieldsentry and the collector reads the live tag at mark time, the same mechanism as every other heap population since 8c. All five BUG-253 shapes, BUG-261 and the enum-payload retention gate re-proven in the migration commit, including load-bearing scratch controls in both failure directions. -
Heap-embedded enum payloads are now traced by the collector (BUG-240 — Critical, silent GC corruption, live in the shipped stdlib:
graphics.canvas's current fill/stroke paint color could be reclaimed and reused mid-program with exit 0; 0.9 Phase 8c). Struct fields, Active Object fields, passive object fields, array elements, closure-env captures and nested enums (any depth) now keep their payloads alive through typed descriptor entries + per-variant tables; the collector reads the LIVE tag and descends, defended against corrupt tags (an out-of-range tag marks nothing and never indexes the table; underREEF_GC_STRICTit aborts with a diagnostic). -
Array literals of GENERIC by-value enums no longer allocate as pointer arrays (BUG-282; 0.9 Phase 8c — 8-byte slots for 16-byte-plus values: element 1+ stored out of bounds and tag/union words were traced as heap pointers; a 32-element literal SIGSEGV'd under a small heap, identically on shipped v0.8.6). They are value arrays now, elements traced through the enum's variant table, and the enum-array literal's base pointer is rooted across element construction.
-
An array literal with allocating element expressions can no longer be collected mid-construction (BUG-283; 0.9 Phase 8c-bis — pre-existing on shipped v0.8.6). While a literal's elements evaluate, the array block was reachable only through an unrooted C temp; an element expression that allocates (
["a" + x, ...],[new S(...), ...], or a plain value array whose element expressions allocate) could trigger a collection that reclaimed the block, and the remaining element stores wrote into reused memory — measured SIGSEGV 3/3 on an 8-element string literal and on a two-elementnew Struct(...)literal under a small heap. Every array-literal branch now roots its base pointer across element construction (extending the rooting 8c gave enum-array literals); literals whose elements cannot allocate are unchanged. -
.append()on a by-value enum array now compiles (BUG-284; 0.9 Phase 8c-bis — pre-existing on shipped v0.8.6, where it NEVER compiled, plain or generic: the emitted compound literal brace-initialized the enum's tag member with the whole enum rvalue, a C constraint violation rejected by gcc and clang with a cryptic generated-C error). The item now goes through a named temporary — uniformly for every element type, including all-scalar-payload enums, which are struct-lowered too and failed identically. -
Stored map values could be reclaimed while still in the map — a shipped v0.8.6 standard-library hole (closed by the BUG-286 change; 0.9 Phase 8c-bis).
collections.mapkeeps its values in an internalnew [any](32)array, andany-element arrays were allocated UNTRACED — under memory pressure a value whose only reference was the map itself was invisible to the collector and could be reclaimed and reused. Measured on v0.8.6: a boxed value read back from a map after allocation churn failed its type assertion 3 runs in 3 ("expected BoxString, got<array>" — the value's memory reincarnated as something else); shapes that don't type-assert would see silently wrong values instead. If you kept data in maps on 0.8.6 under load, this affected you.any-element arrays (the map's values array included) are traced pointer arrays now. -
A map entry whose key AND value are both freshly computed no longer loses the key (BUG-289; 0.9 Phase 8c-bis — 0.9-exposed: on v0.8.6 the same program failed earlier through the untraced-values hole above). The emitted store call received both freshly-allocated expressions as unrooted sibling arguments; one could be collected while the other allocated. Measured: entries missing from the map (
Option.unwrap called on None/map_hasfalse), deterministic under pressure. Entry keys and values now evaluate into rooted slots, in defined key-then-value order. -
Concatenating two freshly-built arrays no longer corrupts the result (BUG-290; 0.9 Phase 8c-bis — pre-existing on shipped v0.8.6).
[a, b] + [c, d]passed both operand arrays as unrooted sibling arguments to the concat helper — string concatenation has rooted its operands since C6.3, but the ARRAY branch never did — so the left array (and its element payloads) could be collected while the right one was still being built. Measured: 16/2000 corrupted read-backs, deterministic; operands already held in variables were unaffected. Both operands now evaluate into rooted slots when either can allocate. -
Map literals and
to_map()can no longer be collected mid-construction (BUG-288; 0.9 Phase 8c-bis — pre-existing emission shapes). A map literal whose entry expressions allocate (#{ "k" => box.box_string("x" + y) }), or ato_map()whose struct argument is freshly built, held the new map — and forto_map(), the struct too — only in unrooted temporaries while later allocations could trigger a collection; under memory pressure the map was reclaimed mid-construction (measured: a NULL-array bounds panic 3/3 for the literal, a SIGFPE 3/3 forto_map— and silent wrong data in shapes that don't crash). Both constructions now root their temporaries across entry evaluation; literals whose entries cannot allocate are unchanged. -
Arrays of closures are now visible to the garbage collector (BUG-286 + BUG-287; 0.9 Phase 8c-bis follow-on). An array of lambdas (
[f, g]) compiled — newly, in 0.9; v0.8.6 rejected the shape with a generated-C error — but allocated as a plain untraced array, and separately the closure CONSTRUCTION held the freshly allocated environment in an unrooted temporary while allocating the wrapper, so a collection landing there freed the environment under a live closure (that half also exists on shipped v0.8.6 for ordinary closure bindings, where it fires only if a collection lands exactly in the two-instruction window). Closure-element arrays now allocate as traced pointer arrays (literals,new [T](n)andappendalike), and every capture-bearing closure construction roots its environment across the wrapper allocation. Invoking an array element after memory pressure was a deterministic SIGSEGV in the witness; both halves were independently measured necessary. -
An array rebuilt from empty keeps its GC visibility (BUG-285; 0.9 Phase 8c-bis — pre-existing on shipped v0.8.6, silent GC corruption: measured 429-500/500 corrupted read-backs). Empty Reef arrays are NULL at runtime, and
appendtook its "does the collector trace the elements?" truth from the source array's heap block — absent for NULL — so the first append onto an emptied array (pop()to empty,remove()of the last element, an empty slice or[]) allocated a PLAIN untraced array, and its string/array/struct/enum-payload elements could be reclaimed while still in use, for the array's whole life. The append call now carries the element's GC truth from the compiler (pointer flag or enum descriptor, from the static element type), so arrays rebuilt from empty are stamped correctly at allocation; scalar arrays stay unstamped. Fixed together with BUG-284 in one change (fixing 284 alone would have made 285's enum variant live the moment enum append first compiled — demonstrated 134/500 corrupted in a scratch build with the second half withheld). Compiler and matching runtime headers upgrade together, as usual since 0.8.6. -
A value stored inside an enum-inside-an-enum could be collected while still in use (BUG-214, release blocker; 0.9 Phase 7-quater). When an enum's payload was itself a payload-carrying enum, the string, array or object at the bottom had no garbage-collection root at all — for the entire lifetime of the binding holding it. The most common spelling in real code is the standard library's own
Result[Option[string], Error], whichio.stream.stream_read_line,io.buffer.reader_read_line,io.buffer.reader_read_bytesand their callers return:let o = Outer_Wrap(Inner_Payload(str.concat("nested", "!"))) // ... anything that allocates ... peel(o) // the string was goneNo diagnostic at any stage: it type-checked, emitted clean C, compiled and ran, and the loss appeared only under memory pressure — 30 reads out of 30 returned corrupted or empty content once a collection ran between the store and the read. A plain local was enough; no closure, no
match, nothing exotic. Measured shapes: a plain local, a match scrutinee with an allocating guard, and a variable captured by an escaping closure.On 0.8.6 and earlier this was not only wrong data — it could crash. The affected value is read through a pointer the collector has already reclaimed, so the outcome depends on whether that memory is still mapped: garbage content when it is, a SIGSEGV when it is not. A program doing nothing more exotic than reading a file line by line with
io.buffer— no user-declared enum anywhere in it — segfaults 3 runs out of 3 on shipped v0.8.6 once it prints a line it held across an allocating call. If you read lines throughio.stream,io.bufferorfson 0.8.6, treat this as a memory-safety fix, not a correctness nicety.The cause was one level of analysis. The compiler decided a variant's field needed a root only when that field's own type was a garbage-collected type — and an enum is a value, not a pointer, so an enum-typed field looked like a scalar and contributed nothing. The analysis now follows the nesting transitively, at any depth, emitting a compound tag-guarded root chain (
OuterisWrapand theInnerinside it isPayload→ root that pointer, otherwise root nothing), so every place a payload is rooted — locals, match scrutinees, call andspawnarguments, and captured mutable variables — gains transitivity from the same place. Programs without nested enums emit byte-identical C.Still open and deliberately distinct: a payload-carrying enum stored in a struct, Active Object or object field, in an array element, or in a closure environment is a different defect (BUG-240) with a descriptor-side fix, and is not addressed here.
-
An escaping closure that captured a
mutvariable read and wrote memory that no longer existed (BUG-253, release blocker; 0.9 Phase 7-ter). A closure that captured amutbinding held the address of that binding's storage in the enclosing function's stack frame. When the closure outlived that function — returned from it, stored in a field, passed to a callback — every read and every write went to a stack slot that had already been reused:fn make(): Fn[int, int] mut acc = 211 let peek = fn(x: int): int => acc + x return peek // peek outlives make() end make proc main() let f = make() println("${f(0)}") // printed 0, not 211 end mainThere was no diagnostic at any stage: it type-checked, emitted clean C, compiled clean and ran. AddressSanitizer named it exactly —
stack-use-after-return, for reads and writes. The write case is what made this critical rather than merely wrong: a closure assigning to its captured variable deposited a value into a frame that had already returned, i.e. into whatever ran next. Captures ofstring, arrays and objects crashed outright (SIGSEGV), and the same value was also invisible to the garbage collector, so it could be reclaimed while the closure still held it.This affected every released version, including v0.8.6. It was not introduced by 0.9.
Fixed: a
mutbinding that any closure captures now lives in a garbage-collected cell instead of on the stack. The declaring function and every closure share that one cell, so writes from either side are visible to both — the semanticsmutcapture always advertised — and the cell lives as long as any closure that holds it.What changes for you:
- Closures that capture
mutvariables just work now. Returning one, storing one in a field, handing one to a callback that runs later: all correct. Any workaround written around the old defect (copying into aletbefore capturing, keeping the declaring function alive artificially) is no longer necessary, though it stays harmless. - A
mutbinding declared inside a loop body gets its own cell per iteration, so closures created in different iterations see different variables rather than all sharing the last iteration's value. - Reading a
mutvariable is enough to trigger this. Capture storage is decided by the variable's mutability, not by whether your closure assigns to it, so a closure that only reads amutvariable shares the cell too. If you want an independent copy, capture alet. - A closure that captures a
mutvariable and escapes into aspawnnow gives two threads a shared, unsynchronised, mutable heap value. That is the intended fix — a crash became a well-defined data race — but it is your race to manage. If the value crosses threads, put it behind an Active Object. - The generated C for capturing closures changed shape. This
matters only if you inspect
--emit-coutput or link against generated code; the Reef-level API is unchanged. - See also the two behaviour changes under Breaking above.
Fixed alongside it: storing into a captured enum from inside a closure published no root for that enum's payload, so the payload could be collected while still in use (BUG-261 — measured at 138 of 200 values lost, on v0.8.6 as well).
- Closures that capture
-
A garbage collection during
newcould collect a constructor argument (BUG-227; 0.9 Phase 7 T4). Constructing an Active Object with arguments left those argument values unprotected across two collection points, so a collection at the wrong instant could reclaim a freshly built string, array or object while it was on its way into the constructor — the field ended up holding reused memory, with no diagnostic:let w = new Worker(tag("one"), tag("two")) // either value could be lost let h = new Holder(tag("solo")) // this one could be lost tooTwo independent windows, both closed:
- At the call site. Construction sites never received the per-call-site argument root slots every other call form has had since 0.9's Stage 6. With two or more arguments, the value produced first was unrooted while a later argument allocated.
- Inside the allocator. The generated
<AO>_newwrapper allocated the object before establishing the frame that roots anything, so its own parameters were unprotected across that allocation — which is itself a collection point, reached on every construction. This one bit even a single-argumentnew.
Construction with no arguments, and struct construction (
new Point(a, b), which stores each value into the object as it goes), were never affected and are unchanged. Programs need no source changes; recompile to pick up the fix. -
A call to a function in one package no longer silently runs a same-named function from a DIFFERENT package (BUG-206; 0.9 Phase 7 T2). When a program loaded two modules whose paths end in the same component —
ui.eventandreefvision.event,ui.themeandreefvision.theme,graphics.backend.memoryandcore.memory,reefvision.inputandgameui.input— code generation resolved every qualified call through a program-global table keyed by that last component, first module compiled wins.event.key_event_char(ke)in a module that importsui.eventcould therefore emit a call toreefvision.event's function of the same name.reefc --checkaccepted the program (the type checker resolves imports correctly, per module); when the two signatures happened to match, the binary simply called the wrong package's code with no diagnostic at all. Callee identity is now decided at type-check time and carries the resolved defining module, so each call goes where its own module's imports say it should. Programs that combine two such packages may see corrected behavior; nothing that was already correct changes (the compiler's full example/stdlib corpus emits byte-identical C apart from the programs that actually hit the collision). -
A function's default argument may again call its own module's functions and be used from another module (BUG-211; 0.9 Phase 7 T2). Given
fn decorate(prefix: string, suffix: string = join2(part("x"), part("y")))exported from moduledshared, callingdshared.decorate("CCC")from anywhere else failed to build withcall to undeclared function 'reef_part': the default expression was spliced into the caller and its interior calls were then named against the CALLER's module. The workaround — module-qualifying every call inside a default — is no longer needed. -
A
sharedActive Object method can no longer smuggle anexclusiveself-call past the compiler inside an expression wrapper — closing a guaranteed runtime DEADLOCK (BUG-220; 0.9 Phase 6g). The shared-method checker previously missed calls nested inside string interpolations ("${self.bump()}"), struct-literal fields, map/set literal elements,is/as/type_name/type_id/to_mapoperands, and lambda bodies; every one of those shapes compiled, and the interpolation shape self-locked on the AO reader lock at first call (verified hang). All are now rejected at typecheck with the standard BUG-114 diagnostic ("Cannot call exclusive method '...' on 'self' from a shared method..."). Breaking in the strict sense: previously COMPILING programs containing one of these shapes now failreefc --check— but every such program deadlocked (or would deadlock) at runtime, so nothing working is lost. Migration: same as BUG-114's original rule — make the calling methodexclusive, or move the mutating call out of the shared method. Wrapper forms that merely READ state ("${self.n}",P { x: self.n }) remain legal and running. Internally, both shared-method checkers (Active Objects and 0.9 passive objects) now drive the compiler's single total AST walker, so this class of descent blind spot is closed structurally, not per-form. -
A trait implemented for a payload-carrying enum no longer reads its receiver's payload after the collector may have reclaimed it (BUG-239; 0.9 Phase 7 T3).
impl Show for Shape, whereShapeis an enum whose variants carry strings or other heap values, compiled a method whose implicitselfwas the last enum-typed parameter in the compiler without the GC payload root slots every other enum parameter has had since 0.8.x. A method that allocated before reading its receiver's payload — and whose caller held no other reference, e.g.Slot_show(mk("P"))— read freed memory: a 400-concatenation repro fails 20/20 runs before the fix and passes 20/20 after. The receiver is an ordinary stamped binding now, so it gets the same treatment. No other emitted code changes. -
A method parameter named after a C keyword builds again (BUG-200; 0.9 Phase 7 T1). Method SIGNATURES emitted their parameter names raw while the bodies read the mangled form, so
fn get(volatile: int)on a struct or Active Object emittedint Sbox_get(Sbox* self, int volatile)against a body readingreef_volatile— a loud C compile failure for any parameter named after a C keyword that is not a Reef keyword (register,union,volatile, ...). The title of the original report understated it: struct methods had the identical defect, and the fix covers all six signature-emitting sites (AO exclusive/shared methods,<AO>_init, struct methods and their forward declarations). Emitted C for the rest of the corpus is byte-identical. -
A binding declared inside a lambda no longer reads the captured outer value of the same name (BUG-229; 0.9 Phase 7 T9). A lambda that declared a local shadowing a name it also captured emitted the local's initializer as a dead store, and every read of the name inside the lambda returned the CAPTURED outer value — a silent wrong answer on an ordinary program, with no diagnostic and no crash:
let msg = "outer" let g = fn(): string let msg = "inner" // a local of g, not the capture return msg // was: outer now: inner end fnPre-existing on v0.8.6. The cause was two composing facts, both now gone: the free-variable walker never excluded a lambda-BODY declared name, so it entered the closure environment as if it were free; and code generation consulted a name-keyed capture table ahead of the binding resolver. Capture sets are computed by the type checker now, which has real scoping, and each use routes on its own resolution —
Captureand local binding are mutually exclusive per use, so there is no precedence left to get wrong. -
Lambdas nested two or more levels deep can capture from any enclosing scope (BUG-230; 0.9 Phase 7 T9). Closures were effectively single-level: a lambda inside a lambda could not see anything from outside its immediate parent, failing with a C compile error naming the identifier (
use of undeclared identifier 'base'). The documented feature simply did not work past one level:let base = 7 let mid = fn(): int let inner = fn(): int => base + 1 // was: build failure return inner() end fnCapture lists are now closed TRANSITIVELY — a binding an inner lambda needs from outside the middle lambda appears in the middle lambda's list too — and both capture-initialization sites resolve the enclosing scope by binding instance, so a capturing scope hands its own environment member to the scope inside it.
-
Calling a CAPTURED closure from inside another lambda works (BUG-231; 0.9 Phase 7 T9). The shape was rejects-valid, and reported as an Internal Compiler Error ("frame discovery and emission disagree") — which reads to a user as a broken compiler rather than an unsupported shape. (At v0.8.6 the same program was a loud C error, so it never worked; 0.9's stricter code generation turned the loud C error into an ICE.) The closure-call path now routes its callee on the callee's own resolution, so a captured callee reads the environment member. The shape compiles and runs.
-
Assigning to a captured payload-carrying enum inside a lambda works again (BUG-232; 0.9 Phase 7 T9). This one was a REGRESSION against the last release: the program below compiles and runs on v0.8.6, and 0.9's enum-payload root slots turned it into a rejects-valid ICE. Assignment through a capture is not a binding store at all (a mutable capture is by-reference, so it writes the enclosing variable, whose own frame slots still protect it), so the store site no longer asks for payload slots it cannot have. v0.8.6 parity is restored.
-
A lambda inside a method can capture
self(BUG-224; 0.9 Phase 7 T9).let f = fn(): int => self.n * 2inside an Active Object, struct or passive-object method emitted a bareselfinto the closure function and failed to build (loud C error, all three receiver kinds identically). The receiver is an ordinary stamped binding now, so it is captured like anything else: the closure environment gets a typed member for it, and — because that member is in the environment's GC descriptor — the receiver is a traced root for the closure's lifetime. Pre-existing; not a regression. -
Captures of a shadowed name resolve as D4 specifies (BUG-198; 0.9 Phase 7 T9). Closed as the owner decision recorded under Breaking above, which is what this report asked for: the question was never "which of two implementations is right" but "which binding does the LANGUAGE say a capture means", and the answer is the innermost visible one, stated once by the scoping rules. Documented for users for the first time in
docs/language/reference/060_CLOSURES.md("Which Binding Is Captured").
Added
-
Passive-object INHERITANCE — object milestone M2 (0.9 Phase 8e).
object Button extends Widgetworks end to end:extends, virtual methods with a mandatoryoverridemarker,inherited m(...)base calls, theinherited init(...)construction chain (and its implicit form — a class that declares noinitbut has an ancestor that does takes that signature), Derived-to-Base assignment at bindings, parameters, returns, array elements and field stores,==/!=reference identity across a hierarchy, andis/as/type_namewalking the full base chain. Hierarchies work within a module, across modules (including a three-module chain), and in either declaration order. Dispatch is through a per-class vtable whose slots are typed by the class that introduced each virtual; a derived class's vtable embeds its base's as its first member, so one dispatch shape is correct at every depth. A derived class's GC descriptor carries its base's traced fields as well as its own. Still refused, naming what opens it:typecaselowering. -
Passive objects may declare
finalize(0.9 Phase 8f). The last object-milestone refusal is gone: anobjectmay declare afinalize()body, and constructing one registers it with the collector exactly as an Active Object's is. Chaining is AUTOMATIC and base-ward — after a derivedfinalizereturns, the base's runs, then its base's, and so on. There is noinherited finalize()(it is a syntax error) andfinalizemay not be markedoverride(it is not virtual): the chain is the language's, not the program's, so the forgotten-base-cleanup mistake is not expressible. A class that declares nofinalizeof its own but inherits one still registers, because registration is per HIERARCHY. Chains work across module boundaries. What opened the door was two runtime fixes shipped with it: the global finalizer list is now protected by the heap allocation lock (BUG-278 — registration was lock-free while the sweep traversed and unlinked the same list, so concurrent construction silently dropped finalizers), and an allocating finalizer now ABORTS. -
An allocating finalizer now aborts loudly instead of hanging (0.9 Phase 8f).
finalize()runs on the reclaim path with the heap allocation lock held, so any GC allocation from a finalizer used to wait forever on a lock its own thread already owned: the process hung with no output, no diagnostic and no stack. The no-allocation rule was documented but unenforced. It is now checked at the allocator: a finalizer that allocates gets a fatal diagnostic that restates the rule and lists the constructs that allocate — including the two that surprise people, string interpolation and a closure capturing amutbinding (whose capture box is allocated where themutis DECLARED, even if the closure is only built on a branch that is never taken). This applies to Active Objects and passive objects alike. -
Two same-named
objectclasses in two different modules may now coexist (BUG-272). Passive-object class identity carries its defining module, like every other type kind since 0.9's earlier qualification work, soliba.Widgetandlibb.Widgetmay both be objects in one program: each keeps its own field layout, its own slot map and its own dispatch. The compile-time refusal that stood in for this is gone. -
make vtable-check— compiles every vtable-bearing unit of the in-tree corpus under both C backends (adding-Werror=incompatible-function-pointer-typesto clang, which is the only backend that accepts the flag; gcc 14+ rejects the same drift with no flag) to catch a vtable slot signature that drifted from the compiler's own slot map. Also a CI step. Deliberately NOT part of reefc's own build command: correct emission cannot trip it. -
Milestone M1 is complete: single-class passive objects work end to end, and everything still unfinished says so BY NAME (0.9 Phase 7, task T8). The refusals that used to open with "objects not yet enabled" were true while objects were a parse-only surface. They are not true any more — objects declare, construct, dispatch and are garbage-collected — so each one now names the single construct it refuses and the milestone that opens it:
object inheritance not yet enabled: object 'Button' declares 'extends' - ... milestone M2. Single-class objects are fully supported: fields, init, methods, dispatch, `is`/`as`/`type_name` and GC all work at M1. object finalizers not yet enabled: object 'Resource' declares 'finalize' - ... milestone M2 ... 'typecase' not yet enabled: cannot generate code for 'typecase' (0.9 passive-object model; lowering lands with milestone M3 - use an `is` chain in the meantime)The difference is practical, not cosmetic. Under the old wording a program refused for one
extendslooked like a program refused for using objects, and the obvious response was to stop using objects. The refusal now points at the line to change, and says what still works.What M1 ships: object declarations with fields;
init;exclusive/sharedmethods with order-independentself.m()calls;new C(...); reference==/!=;is,asandtype_name; arrays of objects (invariant); objects as struct fields, module globals and function parameters/returns; and full GC coverage of an object's own reference-typed fields.What M1 does not ship:
extends/override/inherited(M2), objectfinalize(M2 — refused rather than ignored, because a finalizer that registers and never runs is invisible), andtypecase(M3;ischains are the 0.9 spelling). One known gap inside what does ship: an object field whose type is a payload-carrying enum has its payload unmarked by the collector (BUG-240) — the field itself is tracked, the value behind the enum's payload is not. -
Passive objects have METHODS, and you can call them (0.9 Phase 7, milestone M1, task T7). With construction (T6) and methods (T7) an object is finally useful: it holds state and it has behaviour.
object Counter n: int init(start: int) self.n = start end init exclusive proc bump() self.n = self.n + 1 end bump shared fn describe(): string return "Counter(${self.n})" end describe end Counter proc main() let c = new Counter(5) c.bump() println(c.describe()) // Counter(6) end main- A method may call another method on
selfregardless of declaration order, including frominit. There is no "declare before use" rule to learn. - Object methods can be called from anywhere — a free function, a struct method, an Active Object method, a lambda, another object's method, or a module-level initializer.
exclusiveandshareddo not lock anything on a passive object. A passive object has no monitor of its own; the markers are the write-exclusivity classification (asharedmethod may not assign throughselfor call anexclusivemethod on it), and they are checked at compile time.==and!=on object references are REFERENCE IDENTITY — two separately constructed objects with equal fields are NOT equal. Ordering operators (<,>,<=,>=) are rejected on objects.- Arrays of objects work and are INVARIANT in their element type.
An annotated array literal (
let ws: [Widget] = [...]) checks each element against the declared element type.
extends,typecaseand objectfinalizeremain gated behind their own checked errors. - A method may call another method on
-
Passive objects can be CONSTRUCTED:
new Widget(x, y)allocates and runsinit(0.9 Phase 7, milestone M1, task T6). This is the point where objects stop being a type you can only declare and become instances you can build, hold and read:object Point x: int y: int init(x: int, y: int) self.x = x self.y = y end init end Point proc main() let p = new Point(1, 2) println("${p.x},${p.y}") end maininitruns, and it is not positional field assignment. Converting astructto anobjectchanges whatnew X(a, b)MEANS: the struct form assigns arguments to fields in declaration order, the object form callsinitwith them. An object'sinitmay transform its arguments, write fields in any order, and write fields that have no corresponding argument at all.- Objects with no
initconstruct withnew C()and zero-init — scalar fields read 0/false, GC-managed fields are NULL, which is legal and is exercised under real collections. - A constructed object keeps its contents across garbage collection. Strings, arrays, structs, other objects, closures, Active Object references and generic instantiations held in object fields are all traced; sets, subranges, by-value enums and raw C pointers correctly are not.
finalizeon an object is REFUSED with a checked error naming the milestone that opens it. It is deferred rather than silently registered-and-never-run, because the runtime's finalizer registration is not yet safe to reach from any thread.initparameter DEFAULTS are not applied — every parameter must be supplied. This was already the behaviour; the diagnostic now says so instead of reporting a bare argument-count mismatch (BUG-245).
Object METHODS remain gated behind their own checked error, as do
extendsandtypecase. -
Passive objects are a real type in the generated code (0.9 Phase 7, milestone M1, task T5). An
objectdeclaration now produces a C type — a forward typedef, a struct, a GC pointer-offset table and a type descriptor — so a program that DECLARES an object compiles, links and runs. Three things arrive with it:is,asandtype_namework on objects. They already type-checked; they execute now that the descriptor exists. Matching is exact (an object is its own hierarchy until inheritance lands), andtype_nameprints the name you wrote in the source —type_name(c)on aCounterprintsCounter.- Field-less objects are legal.
object Greeterwith no fields (behaviour-only classes are a natural first object) allocates correctly instead of failing with[Heaps] ERROR: Invalid type descriptor— the object half of BUG-233. The struct half (type Empty = struct end Empty) is still open. - Object type symbols carry their module in the generated code, so
an object's generated C name is unique to its defining module.
SUPERSEDED — see "Type identity is module-qualified" under Breaking
above. When this entry was written, structs and Active Objects still
used program-global names and Reef identified a type by its
unqualified name; both are now false. Struct, enum, Active Object,
alias and trait-impl symbols all carry their module, and a type's
IDENTITY is its defining module plus its name. Two modules declaring
the same
objectCLASS are still refused with a checked error (passive-object class identity is not module-keyed yet, BUG-272), but every other kind now coexists.
Still gated, each with its own checked error:
extends,finalizeandtypecase. (Construction and method calls were gated here too and are open as of T6/T7 — see the entries above; the diagnostics were re-worded at T8 to name their own feature instead of reporting "objects not yet enabled".)reefc --checkcontinues to accept these programs in full. -
Passive objects: the full surface grammar and its typechecking are in — objects are NOT yet buildable (0.9 Phase 6, milestone M0). Reef gains
object Button extends Widgetdeclarations with virtual-by-default methods, a mandatoryoverridemarker on redefinitions,exclusive/sharedmarkers with the same spelling Active Object methods use,init/finalize, andinherited init(...)/inherited m(...)base calls — plustypecase, runtime type dispatch over an object hierarchy (typecase w ... end typecase, arms shaped exactly like statement-match arms with per-armend, optional narrowing binder, optionalelse). Keyword policy (as amended 2026-08-12):objectwas already reserved;extendsandoverrideare CONTEXTUAL and stay valid identifiers (running example indocs/language/reference/015_BASICS.md);typecaseandinheritedare RESERVED — see the Breaking entry above for the migration note. The typechecker checks the whole M0 catalogue:extendstarget validity (objects only — never an AO, struct, enum, trait or alias), inheritance cycles, cross-module base export, override rules (mandatory marker, exact signature match, marker changes rejected in both directions,init/finalizenever virtual), theinitchain (inherited init(...)must be the first statement, transitively required, may not mentionself), thesharedrule (asharedmethod may not assign fields or call anexclusivemethod onself), Derived→Base substitutability at bindings/parameters/returns (arrays stay invariant),==/!=as reference identity with ordering operators rejected,is/ason hierarchies, and the two deliberate 0.9 rejections: generics on objects (object Stack[T],extends Container[int]) and object-typedspawnarguments or receivers. Object method bodies are fully type-checked and stamped like any other scope. What is missing is codegen: a valid object program passesreefc --checkcleanly and then fails at the build step with "objects not yet enabled" (see Changed below). Emission arrives in the next milestone. -
typecaseis not part of 0.9. The multi-arm type-dispatch sugar (typecase w … end typecase) had been accepted for 0.9 conditionally, gated on a grammar conflict-count STOP condition. The condition fired: the productions cost +34 shift/reduce and +1 reduce/reduce conflicts as specified, and no precedence-free refactoring got closer than +6/+3, because the conflict is structural — an arm's leading type name juxtaposes with Reef's bare-identifier statement form at statement head. Per the pre-agreed fallback the sugar was dropped rather than shipped ambiguous, and nothing half-wired was left behind (no productions, no keyword, no AST nodes). Type dispatch staysis-chains (if w is Buttonthenlet b = w as Button), which are unchanged.
Fixed
-
Module-level enum globals compile (BUG-213 facet 1). Every enum value is built by a constructor CALL, so a module-level
let/mutwhose type is an enum always took codegen's deferred-initializer path, which emittedstatic Slot g = 0;— a scalar0into a struct type, i.e. a hard C error. Every enum global in the language was unusable. The placeholder is now shaped by the C type ({0}for the one aggregate mapping, value-type enums;0/NULL unchanged for scalars and for the pointer-mapped struct/string/array/AO globals), so payload-free enum globals and enum globals whose payloads are all scalars now work end to end: deferred module-init assignment, reassignment from a function, match dispatch and payload reads. Emitted C for the rest of the corpus is byte-identical. -
Value-type enum module globals are excluded from GC global-root registration (BUG-213, review I2).
reef_heaps_register_global_roottakes the address of a POINTER slot and the mark phase dereferences it asvoid*— an 8-byte read. Codegen's module-global root test matchedTUserType/TGenericunconditionally, which was inert only because no enum global had ever reached a C compiler; the moment the fix above made them compile, a 4-byte payload-free enum global became an out-of-bounds read (ASAN: global-buffer-overflow, READ of size 8, 0 bytes after global … of size 4) and a larger one handed the collector a tag/payload word as a heap-pointer candidate. An enum is a value, not a pointer slot, so it is now excluded at both module-global emission sites. -
Module-level enum globals whose payload can hold a GC-managed value are rejected at typecheck (BUG-213 facet 2). Fixing the placeholder above would otherwise have made them compile while leaving them collectable: an enum is a value type with no pointer slot, so it gets no module root (see the entry above), and a store to a module global resolves as a
ModuleRef, not a binder, so the 0.9 Stage 7 payload root slots (BUG-164) never fire either — the payload would be reachable only through a root nothing scans. Instead of shipping that, such a declaration is now a checked error naming the type ("module-level enum globals with GC payloads are not yet supported"). The check is transitive through nested enums (the BUG-214 lesson: one level of payload analysis is not enough) and substitutes generic type arguments RECURSIVELY, so it holds for a generic enum nested inside a generic enum (Outer[T] = Wrap(Inner[T])atOuter[string]) as well as for the flat cases —Box[string]is rejected whereBox[int]is allowed. The classifier enumerates the non-GC types and defaults to reject, so an unrecognised payload kind can never read as safe. Rooting statically-embedded payloads for real is sequenced with the 0.9 GC-descriptor work, alongside the heap-embedded enum-field class. -
Enum-typed locals, parameters, pattern bindings and loop elements now root their GC payloads at every store (BUG-164, 0.9 substrate Stage 7 — the FINAL substrate stage). An enum is a value type and is never itself GC-tracked, so before this fix a payload-carrying enum value held OUTSIDE a match-scrutinee window — an ordinary
let/mutlocal, a parameter received by value, a match catch-all or constructor binding whose payload is itself an enum, or afor x in arrelement — kept its payload string/array/struct alive by luck alone: heavy allocation between the store and the read let the precise GC sweep the payload out from under the live enum value (reproduced 3/3 underREEF_HEAP_BLOCK_SIZE=65536for each shape). Every binder store site now publishes the payload pointer into a binder-attached__b<id>_pay<k>root slot via a tag-conditional write (VarDecl init, each reassignment, the function prologue for enum params, all four match pattern-binder paths, and the per-iteration ForEach element store). The scrutinee-attached slots from BUG-158 are retained — a non-binding match arm with an allocating guard still needs them. Known residuals filed, not silently absorbed: nested enum-inside-enum payloads have no root before their binder store (BUG-214), and module-level enum globals don't compile at all yet (BUG-213).
Changed
-
reefc --checknow accepts passive-object programs; the "objects not yet enabled" refusal moved to the build step (0.9 Phase 6/M0). While the object surface was being built, objects were rejected by the typechecker. Now that the M0 catalogue above is implemented, the typechecker's job on an object program is to check it: a well-formed object program type-checks clean (exit 0, no output beyond the success line), and the single remaining refusal fires at code generation with "objects not yet enabled". Practical effect:reefc --checkis a usable editor/CI front end for object code before codegen lands, but a clean--checkon an object program does not mean it builds. Malformed object code is still rejected at--checkwith an ordinary diagnostic, as usual. (Programs with no objects are entirely unaffected.) -
Call sites instrumented for GC argument rooting evaluate their arguments in a DEFINED left-to-right order (0.9 Stage 6, owner decision §9.1): receiver first, then listed arguments, then spliced defaults. Un-instrumented call sites keep C's unspecified order. Both halves are documented in
docs/language/reference/020_FUNCTIONS.md, with the standing advice not to depend on argument evaluation order either way. The six hand-rolled transient GC frame shapes (nested concat, interpolation, struct literals,new-with-args, return-across- defers, foreach) were also unified onto one TransientFrame builder — byte-identical emission except one latent fix: thenew Type(args)constructor temp is now NULL-initialized before its rooting frame links (previously an uninitialized root slot was live for the duration of the allocation call itself). -
BREAKING (
--checkstrictness): parameter-default expressions are now type-checked (BUG-185, 0.9 Stage 3). Previously a default went parser → codegen splice with nocheck_exprin between, so a default referencing an undefined variable (or with any other expression-level type error) passedreefc --checkand died only in the generated C. Such programs are now rejected at typecheck with an ordinary diagnostic. Scoping rule now enforced: a default is checked in the defining module's module-level scope — it may not reference sibling parameters orself(the splice into caller contexts could never bind them). This is the only intended behavior change of the 0.9 Stage 3 annotation work; a default whose type mismatches the parameter's declared type is still NOT checked (BUG-185 stays open for that half). -
BREAKING (
--checkstrictness): trait IMPL method bodies are now type-checked (BUG-188, 0.9 Phase 4a). The typechecker's body pass had arms for plain functions, struct methods and Active Object methods only, so everyimpl Trait for Typeblock fell into its catch-all: undefined variables, call-arity errors, type mismatches and bad field accesses inside such a body all passedreefc --check(exit 0) and surfaced, at best, as a C compile error. Those bodies are now checked withselfbound to the impl target type, exactly like a struct method. Trait DEFAULT method bodies (a trait method written with a body) remain unchecked — they are also never registered as callable symbols and never emitted, i.e. dead code today; recorded as BUG-202 with the design question that has to be answered before they can be checked once. -
BREAKING (
--checkstrictness): a name declared inside anIf/Unless/While/DoWhile/DoUntil/Loop/UnsafeBlock/Deferbody no longer resolves after the block ends (BUG-208 + BUG-209, 0.9 Phase 5a-bis/ter — the block-scoping fixes below). A post-block read of a name that only exists as a block-local (no outer binding of the same name) now reports a cleanName Error: Undefined variableinstead of silently resolving to the block-local's leaked binding. This is a strict improvement, not a new failure mode: for a GC-tracked block-local (e.g. astring), the leaked read previously compiled silently and read whatever the block-local's field happened to hold at runtime (wrong value, uninitialized/NULL on an untaken branch, or — forDefer— a value not yet written because the deferred code hadn't run yet); for a non-GC block-local (e.g. a plainint), the leaked read already failed at C compile time with an opaque "use of undeclared identifier" (the codegen emits such locals as bare, block-scoped C variables) — this change only moves that failure earlier, to a clear diagnostic at--checktime, matching the project's established pattern (see BUG-185's entry above) of preferring an early typecheck rejection over a late, cryptic C error. Corpus impact: zero. A full-tree scan plus this-tree's downstream consumers found no in-tree dependency on the leak — everyunsafeblock with a shadowed name (11 in-tree instances,reef-stdlib/core/str.reefand the crypto modules) reads the shadow strictly INSIDE its own block, never after it. Downstream freestanding consumers pin against reefc versions predating this change and are not expected to be affected on their next upgrade for the same reason (no code pattern in this tree relies on the leak; see BUG-208/BUG-209's resolutions for the corpus evidence).
Fixed
-
BUG-175 (general variant — CLOSED, all variants): a call site's GC-managed rvalue arguments are now all rooted simultaneously while the argument list is still being evaluated, at EVERY call, not just
spawn(0.9 substrate Stage 6). The runtime's__reef_gc_inflightis one slot per thread, so evaluating a later argument that allocates (even one whose own type is a scalar) dropped the only protection an earlier bare-rvalue argument had. Qualifying call sites (a GC-typed argument or receiver evaluated before any later argument that may allocate) now carry eid-keyed__arg_e<eid>_<i>root slots as extra fields in the enclosing function's own shadow frame: arguments evaluate defined left-to-right into typed temps, each rootable value is published into its slot before the next argument evaluates, and the slots are released only after the call returns — which also bridges the Active Object lock-before-frame window (an exclusive/shared callee blocks on the monitor before linking its own frame). Receivers, complex-callee closures and spliced parameter defaults are all covered; wrapped sites therefore have a DEFINED left-to-right argument evaluation order (documented indocs/language/reference/020_FUNCTIONS.md; unwrapped sites remain C-unspecified). The spawn variant was fixed at Stage 0 (below); the synchronous window was never statistically reproducible and is gated structurally (tests/tools/argslot_gate.sh- the
baseline_call_arg_slotsgolden).
- the
-
BUG-177: payload-carrying enum arguments are now rooted across call AND spawn boundaries (0.9 substrate Stage 6). An enum is a value type — never itself GC-tracked — so neither BUG-174/175's spawn fixes nor any slot family covered the GC-managed payload INSIDE an enum passed as a bare rvalue:
spawn f(mk_slot())copied the enum by value into the malloc'd args struct with its payload string reachable from nowhere for the whole thread-start latency (reproduced for the first time during this fix: 8/7/10 corrupted payloads per 12,000 requests under forced GC pressure, 3/3 runs; 0 after). Wrapped call sites give enum-payload argument positions tag-conditional__arg_e<eid>_<i>_pay<k>sub-slots; spawn sites engage the parent/child handshake for enum args (an all-enum argument list no longer takes the fire-and-forget path), root the payload parent-side interleaved with the struct stores, and child-side from unpack until the callee returns. Every payload write is a tag-guarded ternary chain ending NULL — never a raw union read. -
BUG-193: an Active Object's
_new()allocator wrapper no longer leaves the just-allocated object unrooted betweeninit()returning andrun()starting (0.9 substrate Stage 6).__objwas a plain C local with no frame: once init()'s own frame (which rootsselfonly while init runs) popped, an init() that had allocated internally left__objprotected by nothing — a concurrent collection could sweep the object out from under its own constructor (deterministic SIGSEGV under forced GC pressure, 5/5 runs pre-fix; clean 5/5 after). The wrapper now roots__objin a transient frame for its whole body. Found by inspection at Phase 3a, upgraded to a deterministic reproduction here (test_bug193_ao_new_window). -
BUG-209:
UnsafeBlockandDeferhad the identical unscoped-body leak as BUG-208 — found while double-checking that fix's completeness. Neither arm copiedenv/binder_envbefore checking its body, so aletinside either leaked past the block the same way. Fixed identically (env AND binder_env copied in lockstep, mirroringarm_ctx/loop_ctx).Deferhad a distinguishing worse shape: because deferred code is checked in ordinary source order but documented to run at function exit, a post-defer read's stamp could route to the defer body's own binder field BEFORE that field was assigned at runtime — a NULL-string read, not merely a wrong-but-defined value. The reviewer's scope call for the fix held: the scoping copy alone resolves this too, with no separate temporal-ordering fix needed — a post-defer read of a defer-only name now either correctly resolves to an outer binding (if one exists) or is rejected asUndefined variableat typecheck time (the shape previously masked by the leak into silently compiling and reading NULL at runtime). No codegen changes; routing follows stamps exclusively (same reasoning as BUG-199/BUG-208). Corpus-verified:--emit-cbyte-identical onreef-stdlib/core/str.reef,reef-stdlib/crypto/md5.reef,reef-stdlib/crypto/sha256.reef(the in-tree modules with the heaviestunsafe-block usage) before and after. Regression tests:test_bug209_unsafe_defer_shadow_same_type,test_bug209_mismatched_type,test_bug209_defer_no_shadow_masked_undefined. -
BUG-208: a
letinside an If/Unless/While/DoWhile/DoUntil/Loop body that shadows an outer name leaked past the block at the typechecker's stamp layer — observable today with silently wrong output. Sibling defect to BUG-199 one construct over, but worse: these five arms MUTATEctx.env/binder_envIN PLACE (Hashtbl.addshadowing on the same shared table) rather than aliasing a separate unpopped side table, so a same-typed shadow was silently wrong (if true \n let x = "a" \n end if \n println(x)printedainstead of the outer value) and a mismatched-typed shadow was rejected by a CLEAN--checktype error (not a C compile error like BUG-199's case) — both symptoms of the identical routing bug, not genuine language errors.If/Unless's two branches additionally shared ONE context (then-branch checked first), so a then-branch binding could leak into the else branch's own resolution; on the branch that doesn't execute at runtime this reads an uninitialized (zero-initialized)__reef_localsfield — a NULL string read reachable by ordinary code, nounsaferequired. Nesting compounded the leak: an inner block's shadow could clobber even the OUTER block's own reads of its own shadow, not just code after the whole nest. Fixed by scoping every body (and, forIf/Unless, EACH BRANCH independently — mutually isolated, not a copy-of-a-copy) in a copied context (env AND binder_env in lockstep), mirroring match arms'arm_ctxand BUG-199'sloop_ctx;DoWhile/DoUntil's trailing condition is deliberately checked against the same scoped context as the body (it runs after each body execution as part of the same iteration — matching the emitteddo { body } while (cond);— so it must keep seeing the body's own bindings). A corpus-wide check (full suite plus direct--emit-cdiffs of the three heaviest if/while stdlib modules) confirmed zero behavior change for existing code — the fix only stops bindings from leaking past their true scope; a tightening witness — silently accepted at 824 due to the leak itself (the shadow's own type happened to satisfy the post-block use, masking a real conflict with the outer binding's true type), rejected after — shows the fix also catches an ill-typed program the leak previously let through by accident. Regression tests:test_bug208_block_shadow_same_type(same-type shadow across all six constructs),test_bug208_if_then_else_isolation,test_bug208_nested_2deep,test_bug208_mismatched_type,test_bug208_genuine_mismatch_rejected. -
BUG-199: a ForEach/For loop's element, index, or loop variable leaked past the end of the loop at the typechecker's stamp layer. Unlike match arms (which check bodies in a copied
arm_ctx),ForEach's element/index bindings andFor's loop variable were added directly to the enclosingctx.env/binder_envand never popped, so a post-loop read of a name shadowed by the loop's own binding stampederesto the LOOP's binder instance instead of the outer one. For GC-tracked types (each binder gets its ownname_b<id>field) this silently read the wrong value (a same-typed shadow) or failed to compile (a mismatched-typed shadow, referencing a field under the wrong binder's id). Plain (non-GC)int/boolloop variables happened to stay visually correct today — codegen emits them as bare, C-block-scoped locals inside the loop's own braces rather than through the persistent__reef_localsstruct — but shared the same stamp-layer scope leak, a latent risk for any future pass that trustseres(this is exactly what BUG-197's sibling, the codegen classification walker, already hit one layer down). BothForandForEachnow check their bodies in a scopedloop_ctx(env AND binder_env copied in lockstep, mirroring match arms'arm_ctx), so every loop-introduced binding — element, index, and loop variable — pops at loop end. Regression tests:test_bug199_foreach_shadow_same_type,test_bug199_foreach_shadow_mismatched_type,test_bug199_foreach_index_and_for_var_shadow. -
0.9 Stage 5 (type-truth cutover): emission-time type truth is now the typechecker's stamps. Codegen's ~570-line second inference engine (
infer_expr_type, TInt on any unresolved binding) and monomorph's own third engine are DELETED; every emission decision — GC classification and__reef_localsmembership, VarDecl C types, match dispatch strategy AND the match expression's result type (the first-arm peek is retired for the node's own unified stamp), interpolation format specifiers, method mangling, spawn argument classification, generic type-argument inference — readstyp_of = ety. The walkers that build shadow frames read the same stamped cells the emitter reads, so walker/emitter classification disagreement (the BUG-055/143/161/197 family) is structurally unrepresentable. The cutover was baked underREEF_TYPECK_XCHECK(both engines hard-compared across the full corpus + stdlib before deletion; every disagreement triaged) and gated by a corpus-wide frame-field/roots multiset comparison. User-visible fixes that fell out of stamped truth:letC types now follow the language's rules where the old inference silently diverged (char arithmetic → int, set membership → bool, float struct fields no longer truncated to int in multi-package programs), and a nested match under an expression-form catch-all now roots its enum payload and binding (the last known BUG-158/165-shaped gap). -
0.9 Stage 5 (ICE flip): the loud-failure regime is permanent. A missing type stamp or a stamped-but-undiscovered GC binding instance at emission is now an unconditional internal compile error carrying the node's real source location (previously a deduped warning, escalated only under
REEF_CODEGEN_STRICT=1; that interim flag is retired — the warn census was held at zero across the full corpus + stdlib through every cutover step first). Exactly two scopes keep the name-keyed plain-local fallback, both documented in the code: spliced parameter defaults (the Stage 6 argument-slot residual) and await-condition snapshot functions (the BUG-118 value-snapshot mechanism, Stage 8's). -
BUG-201: an UNANNOTATED module-level initializer calling through an import alias passed
--checkbut died at emit ("Name Error: Undefined variable'<alias>'"). gen_module re-inferred the declared type by runningTypechecker.check_expr_typeagainst codegen's env, which never knew import aliases; the three consults now read the initializer's stamped type andcheck_expr_typeis deleted. Regression test:test_bug201_modinit_alias_call. -
BUG-197: the GC-classification walker's scope-blind type_env seeding rejected legal code (a match arm's binding type leaked into the walk-time classification of later unannotated
lets → hard C error). Dies with the seeding itself. Positive test:test_bug197_walker_seed_scope. -
BUG-205: a generic call with INFERRED type args and an omitted defaulted argument registered no specialization — the emitted C called a function that was never generated and spliced the template's raw-
Tdefault. Monomorph's inference now accepts defaulted arity (required..full, provided-prefix unification — the same rule the emission side always used), and the inferred-arg call arms splice defaults from the specialization (BUG-191's clones). Completes BUG-191 for the inferred-type-argument spelling. Regression test:test_bug205_inferred_type_args_omitted_default. -
BUG-191: a generic callable's parameter DEFAULT was never specialized — every specialization shared the template's un-substituted expression.
fn first_or[T](xs: [T], fallback: [T] = new [T](0))called asfirst_or[:int](a)with the default omitted emitted rawTinto the generated C (((T**)reef_heaps_new_ptr_array(0))→ "use of undeclared identifier 'T'") on a programreefc --checkaccepts. Monomorph now runs eachparam_defaultthroughsubstitute_in_exprper specialization (substituted types AND an un-shared clone, so the 0.9 annotation cells are per-specialization), and generic call sites splice the default off the SPECIALIZED declaration instead of the template. Fixed for call sites that write their type arguments explicitly. The inferred-type-argument spelling of the same call (first_or(a), default omitted) still fails to compile — monomorph's argument-count-gated type inference registers no specialization for it at all, so there is nothing to substitute; that pre-existing defect is tracked as BUG-205. Regression tests:test_bug191_generic_param_default_substituted,test_bug191_two_specializations_independent_defaults. -
BUG-188 (codegen half): bindings inside trait impl method bodies collided by name. Because those bodies were never typechecked they carried no binder stamps, so codegen routed the whole scope through the pre-0.9 name-keyed table — which meant BUG-167's collision was still live inside them after 167 itself was fixed (a match arm rebinding an outer
letclobbered it:outer=INNER), and expression-form match bindings there were emitted as plain, frame-less C locals (BUG-165's shape). With the bodies checked and stamped, trait impl methods route by binder instance like every other scope: shadowed instances get their ownname_b<id>slots and expression-form bindings become real GC roots. Thelegacy_scopefallback plumbing is deleted — there is no unstamped region left. Regression tests:test_bug188_trait_body_shadow_instance,test_bug188_trait_body_expr_match_rooting. -
BUG-167: a match binding sharing a name with another GC-tracked binding read the WRONG value (0.9 Stage 4b, the binder-identity cutover).
__reef_localsfield identity moved from bare names to typechecker-stamped binder INSTANCES (name_b<id>fields;Varreads route through each use's stamped resolution), so two same-named bindings can no longer collide on one slot: the same-type shadow (silent wrong value — a nested match arm read the OUTER binding) and the mismatched-type shadow (a hard C compile error on the shared field) are both fixed structurally. The expression-form switch path's write-then-never-read__auto_typebinding is gone — all four match lowerings now share one binder-store emitter. Regression tests:test_bug167_match_shadow_same_type,test_bug167_match_shadow_mismatched_type. -
BUG-165: match-EXPRESSION pattern bindings were never GC-registered. The statement-only registration walker missed every expression-form match; those bindings survived only via BUG-158's independent payload slot rooting. Expression-form arms now register through their stamped binder lists, per instance (fixing this name-keyed would have widened BUG-167 — the two closed together, as their bug files prescribe). Regression test:
test_bug165_match_expr_ctor_binding. -
BUG-181: binding declaration sites skipped
mangle_ident— a binding named after a C keyword (register,union,volatile, ...) emitted an invalid declaration plus an undeclared-identifier error. All the affected sites (six enumerated + the numeric for-loop variable and ForEach index binder, same defect class) now route through the shared binder-store/mangling path. Regression tests:test_bug181_reserved_word_bindings,test_bug181_reserved_word_ctor_bindings. -
BUG-184: nested
${...}interpolation fragments could share one GC root slot (synthetic location offsets compose additively). Match scrutinee temps and enum-payload slots are now keyed by the node's globally-uniqueeid(__scrut_e<N>,__scrut_e<N>_pay<k>), so no location scheme is load-bearing for identity: the sibling-fragment (BUG-139) and nested-fragment collisions are unrepresentable, and the shared module-init frame no longer needs module-qualified temp names. Golden:tests/baseline/baseline_nested_interp_matchpins the two distinct slots. (BUG-182 — fragment DIAGNOSTIC positions — is unchanged and stays open.) -
BUG-176: monomorphization skipped set literals and
inoperands. A generic function with a type-param-dependent expression inside a set literal ({tag_of[:T](x), 5}) or as a membership operand (tag_of[:T](x) in s) kept the generic form in every specialization (undefined C symbol likereef_tag_of_T), and the instantiation scanner never recorded generic calls in set positions. Both the substitution arm and the scanner arm are fixed; regression testtest_bug176_generic_set_subst.reef. -
BUG-186: set-literal membership silently miscompiled (C operator precedence).
x in {1, 2}(a set LITERAL right operand) evaluated wrong with no diagnostic — e.g.1 in {1, 2}returnedfalse. Codegen emitted a set literal as a bare|-chain ((1u<<1) | (1u<<2)) and spliced it unparenthesized into the membership template's>>operand and into the set-difference/intersection templates'&/&~operands; since C's>>/&bind tighter than|, the spliced text parsed with the wrong grouping. A set VARIABLE right operand (a single identifier) happened to parenthesize correctly by accident, masking the bug. Fixed by parenthesizing the whole or-chain at its single emission site (SetLitarm,reef-compiler/lib/codegen.ml), which also closes the identical-defect case of a set literal as a direct operand of-/*(BUG-108's set-op lowering). Regression testtest_bug186_setlit_membership_precedence.reef(bare condition, negated, compound boolean,letinitializer,SetRangeelement, and the set-difference sibling).
Infrastructure
-
Grammar conflict-count gate (
scripts/menhir-conflicts.sh+ the tracked baselinereef-compiler/tests/menhir_conflicts.baseline, run as part oftests/run_tests.sh). The parser's shift/reduce and reduce/reduce counts are now pinned to a recorded baseline and the suite fails if they move in EITHER direction — an unnoticed grammar ambiguity and a stale baseline are both caught, with a documented deliberate-update protocol in the baseline file's header. This is the mechanism behind thetypecasedecision above; the whole object surface landed with the count byte-identical to its pre-object value. -
// EXPECT_BUILD_FAIL: <substring>test marker (tests/run_tests.sh), for the class of program that must type-check cleanly and then fail at the build step. It asserts BOTH halves —reefc --checkexits 0 and the build fails with the given substring — so a test in this class cannot silently degenerate into "fails for some other reason". Used to pin the object programs whose sole remaining error is the codegen door; complements// EXPECT_FAIL:(typecheck rejections). -
Standing ASAN gate for the module-global GC-root class (
asan_gate_enum_global_rootinreef-compiler/tests/asan_exit_gate.sh, fifth gate). The BUG-213 root-registration defect above was invisible to existing CI —make test-asansanitizes the runtime, never a compiled example — so a witness program (plus a control asserting that a genuine string global still registers) now runs under AddressSanitizer on every CI run. The gate runner also now fails on an ASAN report even when the program exits 0, and leads failure output with the ASAN header. -
New downstream toolchain image
reef/reef-toolchain(docker/Dockerfile.release): the released linux-amd64 binary tarball installed onubuntu:26.04with clang/lld and the system libraries Reef programs link against (OpenSSL, X11) — no OCaml. Built and pushed as a prebuilt toolchain image (:<version>+:latest) by the tag pipeline's newimagestep (COPY-only BuildKit build from the samedist/artifact the release publishes and install-gates). Downstream projects should migrate their CI fromreef/reef-builder:latesttoreef/reef-toolchain:latest;reef-builderremains the OCaml build environment for reef-lang's own CI ("for building Reef itself" vs. "for building with Reef"), rebuilt manually viadocker/build-ci-image.shonly when toolchain dependencies change. The 0.8.6 image was first built from this Dockerfile and pushed manually, verified in-container (reefc doctor, hello, a 4-worker Active Object hashing run, anet.tlslink test) — briefly under the namereef/reef, renamed toreef-toolchainthe same day before anything consumed it. -
reef-builderno longer bakes in a Reef install. With downstream projects onreef-toolchain, the preinstalled compiler in the CI builder image was dead weight (reef-lang's CI builds from source every run and never used it — the registry copy had silently sat at 0.8.0 since 2026-07-26).docker/Dockerfile.ciis now a pure build-environment image (OCaml/opam, dune, menhir, cmake, gcc+clang/lld, dev libraries), decoupled from the release cycle:bump-version.shno longer stamps the docker files, anddocker/build-ci-image.shtags by rebuild date instead of Reef version. Rebuild it only when the OCaml switch or system deps change; the existingreef-builder:lateston the registry keeps working for CI unchanged in the meantime.
Known limitations
Loud limitations — each is a compile-time refusal with a diagnostic, not silent wrong behaviour.
-
initparameter defaults are not applied by object construction (BUG-245). A default in an object'sinitsignature is accepted at the declaration but not spliced at thenewsite, so every parameter must be supplied explicitly. The diagnostic names the bug and says so.object P n: int init(n: int = 7) // the default is not applied self.n = n end init end P let p = new P() // Type Error: expects 1 argument(s), got 0 let q = new P(7) // supply it explicitly -
Explicit type arguments cannot be spelled at a call site (BUG-292).
ident[int](3)is a syntax error; generic calls rely on inference from the argument types. Annotate the binding, or introduce a concretely-typed wrapper, when inference is not enough. -
A Derived object does not inhabit a Base slot in four positions (BUG-320). Fixed in 0.9.1. The inhabitation rule (a
Derivedis usable wherever itsBaseis) holds everywhere it is documented — locals, named-function returns, call arguments, struct and object fields, array literals — but four positions were missed, each of which has a working sibling:Position Symptom Use instead mut g: B = new Dv(1)at module levelfails the C compile under gcc; builds under clang assign in main, or annotatenew Dv(1) as Ba[0] = new Dv(3)(array element)fails the C compile under gcc; builds under clang bind first: let e: B = new Dv(3)thena[0] = efn(k: int): B => new Dv(k)(lambda return)--checkrejectsuse a named function mut ga: [B] = [new Dv(1)]at module level--checkrejectsannotate the element: [new Dv(1) as B]The first two are the awkward pair:
reefc --checkpasses and the failure appears in generated C, naming a.cline rather than yours. reefc prefers clang when both are installed, so a clang box will not see them at all.
0.8.6 — 2026-08-09
The harness-integrity + match-arm-scoping batch. Two fixes, both filed
during the 2026-08-06/07 GC-rooting batch reviews: the example-suite test
harness stopped silently auto-passing test_* files that exit 0 with no
.expected companion (BUG-166), and match arm bodies now get their own C
block scope so a let inside an arm can shadow that arm's own pattern
binding without a C-level redefinition error (BUG-168). A related
grammar-widening attempt (BUG-163: match-expression arm bodies restricted
to primary_expr, so a bare function call needs parens) was investigated
this batch and found structurally BLOCKED rather than merely untried — see
Known limitations below — and stays open, unfixed. One further issue was
filed during this batch and remains open: BUG-170 (a latent, currently
benign Menhir reduce/reduce merge that is the structural reason BUG-163 is
blocked).
Spawn exit semantics (2026-08-08). spawn now has one unified,
documented "no guarantees" contract (no ordering, no completion, no
survival past main's return, no external kill, and output can be torn
or lost, not just reordered — A2 Active Oberon lineage), and generated
main() enforces the "no survival" half structurally instead of leaving
it as a race: main returning now calls fflush(NULL) then exit/
_exit, with no heap teardown at all, closing a real (if narrow) UAF
exposure where the old teardown sequence freed every heap memory block
with no join/suspend of still-running spawned threads (BUG-171). An
initial version of this fix quiesced sanitizer builds (stop-the-world,
never resumed) before running the old teardown "for LeakSanitizer's
benefit"; SE-1 review found that design actively deadlock-prone (a
suspended thread holding the stdio lock froze the very next print solid)
and unnecessary (LSan runs its own internal stop-the-world at exit()
and does not need the heap pre-freed), so it was deleted rather than
repaired — see the BUG-171 entry below for the full account, including the empirical LeakSanitizer
verification. BUG-169 (spawn/stdout ordering nondeterminism, filed last
batch) is resolved as a documentation fix — the contract this batch adds
subsumes it. SE-1 review also escalated a further, much more serious gap
found while building (and then simplifying) the exit fix: bare spawn
threads were invisible to the GC's stop-the-world suspend/scan machinery
entirely, and this reproduced as memory corruption under default
settings (no ASAN needed) — filed and confirmed as BUG-172, Critical.
A same-day follow-up (SE-2) fixed it: spawn threads now register with
and deregister from the same GC-visible process list Active Object
threads use, closing all three documented failure modes — see the Fixed
entry below. 070_SPAWN.md's safety warning against holding GC-managed
values in a spawned task's OWN locals has been removed accordingly (the
rest of the "no guarantees" contract is unchanged). Investigating BUG-172
at high allocation/GC-cycle rates surfaced one further, unrelated issue —
a pre-existing nested-signal-handler stack-growth risk under an extremely
bursty GC cycle rate, reproduced with plain Active Objects too and
independent of this fix — filed as BUG-173 and left open (see Known
limitations). Note for anyone updating a reefc/runtime pair independently:
generated C from this batch now references reef_machine_exit(),
reef_objects_register_spawn(), and reef_objects_deregister_spawn(),
so a compiler from this batch requires a runtime from this batch (and
vice versa) — the new symbols are purely additive (no existing signature
changed) and a mismatch fails loudly at link time with an undefined
symbol, not silently at runtime.
SE-2 review of the BUG-172 fix (same day) found two further issues,
both fixed/filed in this same round: (C1, Critical, fixed) exit()/
panic() (reef_exit(), reef_prelude.h) still ran the pre-BUG-171
ordered teardown directly — a path SE-1's fix never touched, since it
only rewired generated main()'s own epilogue — so reef_objects_ cleanup()'s reap loop could still reach a live spawned thread's process
entry and cancel-then-join-then-free it despite that entry having no
joinable thread handle to join, a reachable UAF/double-free confirmed
under ASAN (5/5 in the reviewer's repro). (C2, Critical, filed as BUG-174,
fixed same day — SE-3, see the Fixed entry below) the new 070_SPAWN.md
sentence claiming unqualified GC memory safety for spawn overclaimed:
GC-managed arguments passed to a spawned function travel through a
malloc()'d struct that nothing marks as a GC root during the handoff
window between the caller dropping its reference and the spawned thread
re-establishing one — a pre-existing gap (predates BUG-172), reproduced
3/3 runs on the ordinary spawn f(arg) shape. See the C1 Fixed entry and
the "GC-managed spawn arguments are now rooted across the parent/child
handoff (BUG-174)" Fixed entry below.
Fixed
Multiple GC-managed spawn arguments are now rooted while the argument list is still being evaluated (BUG-175, spawn variant)
spawn f(build_a(n), build_b(n), n) — two or more GC-managed argument
expressions evaluated directly in one spawn, with no caller-side name for
any of them — silently corrupted the earlier arguments. Between an
argument's own evaluation finishing and the spawned child copying it into
its own shadow frame, the value's only protection was the runtime's
__reef_gc_inflight: a SINGLE per-thread slot holding the most recent
allocation. Evaluating any later argument overwrote it — including an
argument whose own static type is a plain scalar, if its evaluation
allocates internally — leaving the earlier value reachable from nowhere the
mark phase can see for as long as it took the child thread to start
(BUG-174's handshake roots the handoff, not this earlier window).
Reproduced at rev 776: 10-34 corrupted payloads per 12,000 requests, 3/3
runs per shape, with the predicted asymmetry (the LAST argument, which keeps
riding __reef_gc_inflight, never corrupts).
Fix (compiler-only; the runtime and its frame ABI are untouched): each
spawn site with at least one GC-managed argument now emits a transient
shadow frame of void* root slots, one per GC-managed argument, and each
slot is written as part of the same assignment that stores the argument
into the spawn-args struct — so argument i is chain-reachable before
argument i+1's expression begins evaluating. The frame is released only
after the parent's handoff wait returns, i.e. after the child has taken over
rooting durably. Argument GC-ness is decided from the callee's resolved
DECLARED parameter types (new plumbing: the Spawn lowering site resolves
the callee's declaration — including substituted parameter types for generic
spawns and namespace lookup for module-qualified ones — and passes them into
gen_spawn_with_args, which previously saw only a mangled C name and
re-inferred each argument's type), never from codegen's own expression
inference, which fails toward TInt and would drop roots silently. Where a
callee's declared types are unavailable (an unresolvable callee, or a
signature whose parameter count does not cover the call's positional
arguments), the site falls back to codegen inference — i.e. the pre-fix
classification, not a stronger conservative one: a genuinely uninferrable
argument is still unrooted, and its args-struct field is already mistyped,
since that field's C type comes from the same inference. No stronger
predicate is implementable at this stage; Stage 6 owns closing it, with
declared types available at every call site (not just spawn ones) and driving
the field type as well as the classification. The emitted C labels which
source each site used (callee-declared vs inference-fallback (<cause>)),
so a fallback is greppable rather than silent; across the current examples
corpus every spawn-with-args site resolves declared types.
Frame construction follows the existing F3 discipline (slots NULL-initialized
and the header fully written before the single store that links the frame,
single store to unlink), verified in the generated C and in disassembly at
-O0 and -O2. Spawns with no GC-managed argument keep the previous
zero-overhead shape. New regression tests:
test_spawn_args_multi_rvalue_roots, test_spawn_args_scalar_alloc_roots,
test_spawn_args_generic_roots, test_spawn_args_modqual_roots (each RED
3/3 pre-fix, 0 across 5 runs post-fix, non-ASAN).
Provenance and remaining scope: BUG-175 was filed 2026-08-08 during SE-3
review of the BUG-174 fix; this is Stage 0 of the 0.9 "scoped codegen
environments" substrate program
(design §5 row 0), landed on the v0.9-oo bookmark. The general multi-argument
CALL gap remains open — the same mechanism applies to any plain
synchronous multi-argument call (a much smaller window, never independently
reproduced) and is deliberately out of scope here; it is closed structurally
by Stage 6's general per-call-site argument slots. BUG-177 also remains
open: a payload-carrying enum passed as a bare rvalue still classifies as
non-GC-tracked and gets no slot, in spawn and elsewhere, until Stage 6's
tag-conditional payload sub-slots land.
spawn threads are now registered with the GC's stop-the-world suspend/scan machinery (BUG-172)
reef_objects_suspend_activities() and reef_objects_scan_all_stacks()
(reef-runtime/src/reef_objects.c) both walk g_objects.root — the
Active Object process list — to decide which threads to signal-suspend
during a GC cycle and which threads' shadow-frame chains to scan for
roots. A bare spawn fn() thread never entered that list: codegen's
Spawn case called reef_platform_thread_create() directly, a raw
pthread_create() wrapper with zero GC registration. Three consequences,
all closed by the same root fix: (1) a spawned thread's own GC-managed
locals were invisible to mark and could be reclaimed while still live and
referenced — reproduced as a 3/3 SIGSEGV at default settings, no ASAN,
inside an ordinary string comparison; (2) a spawned thread was never
signal-suspended, so it could keep mutating shared memory during a window
the collector believed the world was stopped; (3) a spawned thread whose
own allocation crossed the auto-GC threshold ran reef_heaps_gc() with
reef_objects_current_process() resolving NULL (TLS was never set for
it), so the collector never published its own roots during its own mark.
Fix: two new runtime entry points, reef_objects_register_spawn() and
reef_objects_deregister_spawn() (reef_objects.c), mirror Active
Object threads' body_starter() registration onto the exact same
g_objects.root list and process_list_mutex (no parallel bookkeeping) —
publishing TLS + thread_id before the entry becomes list-visible (the
same ordering body_starter() uses, for the same race reason), which
also fixes failure mode 3 by making reef_objects_current_process()
resolve correctly on a spawned thread. Unlike Active Object threads
(joinable, freed later by reef_objects_join_process()), a spawned
thread is fire-and-forget and detached immediately after creation, so
reef_objects_deregister_spawn() frees the process struct itself once
unlinked, rather than leaving that to a join that will never happen.
Codegen (reef-compiler/lib/codegen.ml, the Spawn case, all three call
shapes — Call, GenericCall, QualifiedGenericCall — with and without
arguments) now always routes through a generated trampoline that calls
reef_objects_register_spawn() before the spawned body's first
allocation and reef_objects_deregister_spawn() after it returns; no
join was added anywhere, so main's abandon-on-exit contract (BUG-171,
SE-1) is unaffected. New tests: test_spawn_gc_rooting.reef (the
reproducer shape: a spawned worker holds a [string] array across
concurrent main-thread GC churn — RED 3/3 SIGSEGV at pre-fix tip, GREEN
20/20 clean post-fix), test_spawn_gc_selftrigger.reef (failure mode 3:
the spawned thread's own allocation triggers its own collection while
holding live locals — RED 3/3, GREEN 20/20), test_spawn_register_churn.reef
(200 short-lived spawned tasks each registering/deregistering and calling
into a shared Active Object's exclusive method — RED: fatal abort 3/3
pre-fix, since a bare spawn thread had no process context to lock with;
GREEN 20/20, plus clean under ASAN with leak detection). No change to the
shape of reef_process_t itself (spawn threads reuse the existing
struct/list) — but see the SE-2 entry below, which adds an is_spawn
field to that same struct later the same day, so "no ABI change" does
not hold for the branch as a whole, only for this entry in isolation.
exit()/panic() could still UAF/double-free a live spawned task's process entry (SE-2 review, C1)
reef_exit() (reef_prelude.h, backing the exit() builtin — and
panic(), which calls it) still ran the pre-BUG-171 ordered teardown
directly (reef_objects_cleanup(); reef_heaps_cleanup(); reef_machine_cleanup(); exit(code);), a path BUG-171/SE-1's fix never
touched (that fix only rewired generated main()'s own epilogue to the
no-teardown reef_machine_exit()). reef_objects_cleanup()'s reap loop
calls reef_objects_cancel_process() on every remaining live process;
for a spawned thread's entry (process->thread == NULL by
construction — reef_objects_register_spawn() never captures a joinable
reef_platform_thread_t, and codegen detaches the thread immediately
after creation) the if (process->thread) join guard silently skipped
the join and fell straight through to process_cleanup()'s
condvar_destroy() + free() — tearing down the struct (and mutex) out
from under a thread pthread_cancel() had merely been asked to stop, not
proven to have stopped (cancellation is asynchronous by default). The
still-running spawn thread's own epilogue then double-frees/UAFs the same
memory. Reproduced under ASAN, 4/5 runs, heap-use-after-free, on a
spawned task hammering an Active Object's exclusive method while main
does brief unrelated work and calls exit(0). Fix, two parts: (a) a new
is_spawn flag on reef_process_t (set by reef_objects_register_spawn(),
NOT inferred from thread == NULL/body == NULL, which are legitimately
NULL for a spawn entry for unrelated reasons and would be a fragile
heuristic) — reef_objects_cancel_process() now unlinks-and-abandons a
spawn entry instead of cancelling/joining/freeing it, matching this
codebase's own "process death is the teardown" contract; the struct is
deliberately leaked at this specific call site rather than freed, since
there is no safe point at which anything other than the thread's own
eventual epilogue could free it without risking the same UAF. (b)
reef_exit() now simply calls reef_machine_exit(code), giving
exit()/panic() the same no-teardown abandon-on-exit contract main
returning already has, so reef_objects_cleanup()'s reap loop is no
longer reachable from any compiled Reef program's normal control flow at
all (it remains correct and load-bearing for reef-runtime's own unit
tests, e.g. test_ao_cleanup_uaf, which call it directly). New gate:
asan_gate_exit_spawn.reef, wired into reef-compiler/tests/ asan_exit_gate.sh (now three gates under make test-asan) — the
exit()-with-a-live-spawned-task shape the other two gates (main
returning) didn't cover. RED (pre-fix): 4/5 ASAN heap-use-after-free.
GREEN (post-fix): 10/10 clean, plus the new gate script PASS. No exit-code
regression: test_exit_in_value_fn (exit 2), test_panic_basic/
test_panic_in_value_fn (exit 1), exit_codes (exit 64) all still pass —
reef_machine_exit() preserves the requested code exactly, it just skips
the now-provably-unnecessary teardown before calling it.
main returning now terminates spawned tasks immediately instead of racing their teardown (BUG-171)
Every generated main() ended with reef_objects_cleanup(); reef_heaps_cleanup(); reef_machine_cleanup(); return N; —
reef_machine_cleanup() unconditionally free()s every heap memory
block with no join or signal-suspend of any thread a spawn call might
still have running. Empirical probing (30x normal + 30x ASAN per
scenario, plus a 16-way concurrent-spawn variant) confirmed the
mechanism is real: no crash was hit in ~140 runs on this hardware, but a
still-running spawned task's marker was absent, present, or partially
present run to run depending purely on timing — a structural race, not a
hypothetical one. New runtime entry reef_machine_exit(int code)
(reef-runtime/include/reef_machine.h) is now generated main()'s sole
exit path, uniform across normal and sanitizer builds:
fflush(NULL); followed by exit(code) (sanitizer builds, so
LeakSanitizer's own internal stop-the-world scan runs) or _exit(code)
(normal builds, skipping atexit entirely) — no heap teardown in either
case. An earlier version of this function quiesced sanitizer builds
first (signal-suspend every registered thread, never resume, then run
the old ordered teardown) on the theory that LSan needed the heap
actually freed to report meaningfully; SE-1 review found and reproduced
(11/12, then 3/3) a solid deadlock in that design — a thread parked
forever while holding the stdio lock (e.g. an Active Object mid-println)
freezes the next print, including a verbose-mode log line from the
teardown that followed, violating reef_objects.c's own documented
"collector must not touch stdio while the world is stopped" contract —
plus POSIX-UB lock destruction and an unsuspended timer thread racing the
same teardown. The whole quiesce mechanism was deleted rather than
patched, since its premise was also wrong: LSan's default leak heuristic
is reachability, and Reef's heap blocks are malloc()'d and reachable at
exit via the global memory_block_head list regardless of whether
reef_machine_cleanup() ever runs — verified empirically post-fix (an
AO + spawn program compiled against the ASAN runtime produces zero LSan
reports across repeated runs; a deliberate positive-control leak via an
extern "C" shim is still correctly reported, proving leak detection
itself wasn't disabled by the simplification). New test:
test_spawn_exit_no_survival.reef. New regression gate:
reef-compiler/tests/asan_exit_gate.sh (wired into make test-asan) —
compiles an AO-printing example and a spawn example against the ASAN
runtime and runs each under a timeout, specifically to catch a
reintroduced exit-time hang; make test-asan's own ctest suite never
exercised generated main()'s exit path at all.
GC-managed spawn arguments are now rooted across the parent/child handoff (BUG-174)
spawn f(arg) where arg is a string, array, or object copied the
argument into a malloc()'d (not GC-heap) args struct that nothing marked
as a GC root. Between the caller dropping its own reference to arg
(typically: returning from the helper that built it, right after the
spawn call) and the spawned thread unpacking that struct into the
callee's own parameters (where the callee's shadow frame re-roots it), the
value was reachable only through the unrooted struct — a GC cycle landing
in that window could reclaim it out from under the spawned task.
Reproduced 3/3 runs both for a named-variable argument (spawn log_event(msg, n), 4-22 corrupted payloads per run) and for a bare
rvalue/interpolated argument with no caller-side name at all (spawn log_event("${prefix}_${n}_payload", n), 6-24 corrupted payloads per run).
Fix: gen_spawn_with_args (reef-compiler/lib/codegen.ml, replacing what
had been three near-identical inline blocks — one per spawn-callee AST
shape) makes argument transfer a brief, synchronous handshake instead of a
fire-and-forget struct handoff, scoped to spawns with at least one
GC-managed argument (a pure-scalar arg list, e.g. spawn f(1, 2), has
nothing to root and keeps the original zero-synchronization shape,
identical to the argless path):
- The parent creates a one-shot mutex + condvar, stores them in the args
struct alongside the arguments, creates the spawned thread, then blocks
in a predicate-recheck wait loop (
while (!unpacked) wait(...)— per BUG-120 lore, the runtime's SIGURG has noSA_RESTART, so a condvar wait can wake spuriously during a GC stop-the-world and must always recheck its predicate rather than assume one wake means done). - The spawned trampoline's first act (after
reef_objects_register_spawn(), BUG-172, which must stay first) copies every argument into its own plain C locals and wraps the GC-managed subset in a shadow frame — the exact mechanism an ordinary function's own parameters get (build_shadow_frame'sextra_tracked) — before signaling the parent. Only after that frame is linked does it signal; only after observing the signal does the parent unblock and free the struct itself (never the child — freeing from the signaling side would race the waiter's still-in-progress lock reacquisition on freed memory). - The handoff mutex is a private, single-use, per-spawn object never
nested with
process_list_mutex/gc_lock/heap_alloc_lockin either direction, so it cannot participate in a lock-ordering cycle with the runtime's existing discipline.
New tests: test_spawn_args_gc_rooting.reef (the named-variable repro
shape, 20/20 clean post-fix), test_spawn_args_rvalue_temp.reef (the
rvalue/interpolated-argument shape, 20/20 clean), and
test_spawn_register_churn_args.reef (200 back-to-back spawns each
carrying two GC-managed arguments through the handshake — deterministic
via a shared Active Object tally rather than stdout ordering — also wired
into asan_exit_gate.sh as a 4th make test-asan gate covering the
handoff mutex/condvar's create-once/destroy-once lifecycle under ASAN).
Negative check: disabling the wait loop (while leaving the rest of the
handshake — including the now-premature struct free — in place)
reproduces a hard crash (SIGSEGV, 3/3) rather than merely silent
corruption, confirming the wait is load-bearing. 070_SPAWN.md's BUG-174
warning box is removed; the doc now states arguments are transferred
synchronously at the spawn point, with the call itself still fully
asynchronous. Scope, corrected on SE-3 review: this fix covers a
single GC-managed argument, or arguments already rooted in the caller's
own frame (named locals/parameters) — it does NOT cover two or more bare
rvalue GC-managed arguments evaluated directly in the same call (spawn f(build_a(n), build_b(n)) with no caller-side name for either result),
which can still corrupt the first-evaluated one; this is a general,
pre-existing call-argument gap (reproduces with spawn removed too),
filed separately as BUG-175 and NOT fixed by this entry — see the Known
limitations entry below.
The example-suite harness now fails test_* files that exit 0 with no .expected (BUG-166)
run_tests.sh's test_run() treated a zero exit code with no .expected
companion file as an automatic PASS, with no check of the program's actual
output at all — a test that printed FAIL: ... lines to stdout while
still exiting 0 gated green. Two confirmed false-greens
(test_base64_valid_decode_agree.reef, test_base64_strict_padding.reef)
had already shipped through this exact gap before being caught and fixed
ad hoc during BUG-160's review. test_* files reaching that branch now
[FAIL] with an explicit "unasserted" message instead of silently
passing. Closing the gap required a one-time audit of every test_*.reef
lacking an .expected across both example directories (233 files): 225
got a new .expected capturing current, correct output; 8 genuinely
nondeterministic tests (live PIDs, timings, host env vars, hardware info,
and spawn's racy stdout interleaving — see BUG-169) were skip-listed
with inline justifications; and 2 real test-authoring bugs (not product
bugs) were found and fixed along the way — test_chars_extended.reef was
calling the wrong stdlib function, and test_list_prereqs.reef assumed an
inclusive to-range upper bound. No product/runtime code changed.
Match arm bodies now get their own C block scope (BUG-168)
A pattern binding (a bare catch-all PVar or a constructor payload
sub-binding) and its arm's body statements used to share a single C block
in generated code, so a body let reusing the binding's name — ordinary
shadowing, unrestricted at the Reef language level — produced a hard
error: redefinition of 'v' (or similar) from the C compiler. Fixed at
all six no-guard emission sites across both the switch and if-chain
codegen paths; guarded arms were already correctly scoped by the guard's
own if. New test: test_match_arm_body_scope.reef.
Documentation
The spawn contract: no ordering, no completion, no survival, no external kill (BUG-169, BUG-171)
docs/language/reference/070_SPAWN.md's Characteristics section gained a
"The spawn Contract: No Guarantees" subsection stating, as one unified
contract rather than scattered footnotes: no ordering guarantee between a
spawned task's output and anything else (BUG-169 — measured 4-5+ distinct
stdout orderings across repeated runs of test_spawn_args.reef /
test_spawn_enum_args.reef, still skip-listed for exactly that
nondeterminism, unchanged by this fix); no completion guarantee, including
output that can be torn or lost, not just reordered; no survival past
main's return (BUG-171, now structurally enforced, not just
documented); no external kill. Cites the Active Oberon Unix.Machine.Mod
lineage (Shutdown -> Unix.exit(), no join, no heap teardown) as
precedent. Recommends Active Objects + await for anything needing
lifecycle control. Same-day follow-up (SE-2): once BUG-172 was fixed (see
Fixed above), the safety-warning box was removed from 070_SPAWN.md —
a spawned task's OWN locals are memory-safe to hold across a concurrent
GC cycle now, and the rest of the "no guarantees" contract (ordering,
completion, survival, external kill) is unaffected and unchanged. SE-2
review caught that an earlier draft of this doc update overclaimed
unqualified memory safety; the doc briefly scoped the claim to a spawned
task's own locals and carried a warning box for BUG-174 (GC-managed
arguments to spawn), fixed the same day (SE-3, see Fixed above) —
but SE-3's own fix is itself scoped, not unconditional: it covers a
single GC-managed argument, or arguments already rooted in the caller's
own frame, and does NOT cover two or more bare rvalue GC-managed
arguments evaluated directly in the same call, which is filed
separately as BUG-175 (open) and still carries a warning box in
070_SPAWN.md.
Known limitations
-
BUG-173 (open, Critical, found during BUG-172's fix, independent of it): an extremely bursty GC cycle rate (observed with
REEF_HEAP_BLOCK_SIZEset low plus one thread allocating in a tight loop while another sits in a blocking, interruptible call) can overflow a signal-suspended thread's stack via nestedreef_gc_suspend_handler()re-entry — each SIGURG that arrives while a previous invocation is still parked insigsuspend()nests rather than replacing it, and under a fast-enough back-to-back cycle rate the nested frames were observed to reach 15,000+ deep before crashing (SIGSEGV, stack exhaustion). Reproduced with plain Active Objects (nospawninvolved), at a rate independent of and comparable to thespawncase — confirming this is a pre-existing GC/signal-delivery scaling gap, not something BUG-172's fix introduced.test_spawn_gc_selftrigger.reef's parameters were deliberately tuned (REEF_HEAP_BLOCK_SIZE=1048576, a smaller churn count) to stay well clear of this edge while still exercising BUG-172's failure mode 3. Not fixed — needs its own design work (e.g., bounding nested handler depth, or coalescing back-to-back suspend requests) and is out of scope for BUG-172. -
BUG-175 (spawn variant FIXED — see the Fixed entry above; the general multi-argument CALL variant remains open, High; found during SE-3 review of the BUG-174 fix): two or more GC-managed argument expressions evaluated directly in the same call, with no caller-side name for any of them (e.g.
spawn f(build_a(n), build_b(n))), are not all rooted simultaneously — evaluating a LATER argument (even one whose own static type is a plain scalar, if its evaluation allocates internally) overwrites__reef_gc_inflight(the runtime's single-slot last-allocation root), dropping protection for an EARLIER argument's already-computed value. Reproduced independently this round: 7-11 corrupted payloads per run (3 runs, 3000 requests each) for the two-string-argument shape, and 9-15 per run for a scalar-second-argument variant that still allocates internally; the reviewing session reported higher, more deterministic rates (300/300 and 200/200 respectively) under different tuning. This is a GENERAL call-argument gap, not specific tospawn— the same mechanism applies (with a much smaller window) to any multi-argument call; the reviewing session additionally reported identical corruption withspawnremoved entirely, not independently reproduced this round. Ties to the GC design doc's F2 return-value window (docs/ROADMAP.md:347-359, owner-accepted with "revisit only if new evidence surfaces") as the sibling argument-passing case satisfying that revisit criterion. Thespawnvariant is now fixed (Stage 0 of the 0.9 scoped-codegen-environments program — parent-side rooted argument slots written interleaved with argument evaluation; see the Fixed entry above). What is still open is the general case: a plain synchronous multi-argument call gets no such slots, so the same mechanism applies there with a much smaller (no thread-creation-latency) window. Fixing it is Stage 6 of that program — a general per-call-site transient root set designed fresh, not a resurrection of the deletedgen_call_with_transient_rootshelper. -
BUG-163 (open, Low): a match-expression arm's body accepts only
primary_exprin the grammar — a bare function call, or anything else built on top ofprimary_expr(e.g. a binary operation), as an arm's body is a parse error, unlike the match-statement form, which allows a full statement list. Workaround: wrap the body in parens (other => (unwrap_or(other, "MISSING"))—LPAREN expr RPARENis itself aprimary_expr, soexprinside it is unrestricted). The obvious fix — wideningbodyfromprimary_exprtoexpr— was attempted this batch and found structurally BLOCKED: it reliably reopens a pre-existing, currently-benign Menhir LALR merge between match-expression and match-statement arms (BUG-170) into a genuine ambiguity, for every widening candidate tried. Stays open; a real fix needs to break that structural prefix collision, not just widen a nonterminal.
0.8.5 — 2026-08-07
The GC-rooting hardening batch. Six fixes: three from a systematic
audit of precise-GC rooting and match-arm codegen that followed the C6
shadow-frame work, landing in dependency order BUG-159 -> BUG-158 ->
BUG-157; a fourth (BUG-161) filed during that same audit's Task 3 review
and fixed in this batch too; and two independently-filed fixes carried
over from the previous (v0.8.4) branch's reviews and scheduled into this
batch. Guarded constructor-pattern match arms combined with a when
guard used to fail to compile for a non-GC-typed payload binding, or,
with a GC-typed payload, compile clean but silently select no arm at all
or crash (BUG-157, from the rooting/codegen audit); three codegen
contexts — lambda bodies, module initializers, and await conditions —
built no shadow frame at all, leaving any GC-typed local they computed
unrooted (BUG-159, from the rooting/codegen audit); stdlib-origin trust
for the BUG-151 FFI string boundary copy was decided by string-matching
an already-resolved path instead of the resolution step that found it
(BUG-156, found independently during the v0.8.4 branch's Task 3 review);
an enum match scrutinee's GC-managed payload left unrooted across an
allocating guard (BUG-158, from the rooting/codegen audit); a match
binding usable inside its own arm silently inferred as int in codegen,
degrading string comparisons, breaking interpolation, and in one shape
crashing the compiler outright (BUG-161, filed during the rooting/codegen
audit's Task 3 review, with a second confirmed symptom added in that same
audit's final review); and a base64 validation/decode disagreement left
over from the BUG-150 strictness change (BUG-160, found independently
during the v0.8.4 branch's final review). No ABI change. Five further
issues were filed during this batch's review and remain open: BUG-162
through BUG-165, and BUG-167 (found during BUG-161's own follow-up
review, confirmed pre-existing rather than introduced by it).
Fixed
Guarded constructor-pattern match arms now compile and match correctly (BUG-157)
A match arm combining a when guard with a constructor pattern that
binds the payload (e.g. Some(x) when x > 0 => ...) used to drop the
binding whenever codegen took the if-else-chain path (any arm guarded, or a
string pattern/string scrutinee forcing the chain) — previously a compile
error for a non-GC-typed binding (use of undeclared identifier), or,
with GC-typed payloads, a match that silently selected no arm at all
(falling through an otherwise-exhaustive match with no output), or
crashed, with no diagnostic to catch either. Guarded constructor patterns
with payload bindings now compile and run correctly in both cases. A
payload binding consumed inline by comparison or interpolation within its
own arm — the gap this bullet used to caveat — is fixed below (BUG-161).
A match binding read inline within its own arm silently inferred as int in codegen — degrading string comparisons, breaking interpolation, and crashing the compiler on a nested match (BUG-161)
A match arm's bound variable — a catch-all PVar (other => ...) or a
constructor payload sub-binding (Some(x) => ...) — was never registered
into codegen's ctx.type_env while generating that arm's guard or body,
in any of the three match codegen paths (the expression-form if-chain,
the expression-form switch, and the statement-form if-chain/switch).
Reading the binding directly in a comparison or interpolation therefore
hit infer_expr_type's generic "could not infer type" fallback and
silently defaulted to int: x == "literal" lowered to raw pointer
comparison instead of strcmp (permanently wrong for two content-equal,
pointer-distinct strings, with no compile error — just a wrong runtime
result); "${x}" interpolation emitted an int assignment from an
actual char*, a hard C compile failure; and using the binding as a
NESTED match's own scrutinee crashed the compiler outright with no
source location and no readable message (resolve_enum_tag resolving
against a bogus int "enum", raising a CompileError that propagated
past every diagnostic-formatting layer to reefc's last-resort
Unexpected error: ...(_) handler). Statement-form matches were
unaffected — a separate, dedicated walker already registered these
bindings for that path — which is what limited BUG-157's original fix to
a caveat rather than a full close. Every match arm's bound variable(s)
are now scoped into ctx.type_env immediately before that arm's
guard/body generation and popped immediately after (an exact-inverse
restore snapshotting and rebuilding each name's whole binding stack, not
a single blind pop — a review fix closing a same-day CRITICAL regression
where a nested block's own same-named let inside the arm could leak
the pattern's binding past the arm's end), so sibling arms never see
each other's bindings and a binding that shadows an outer local of a
different type — including one shadowed again by a nested let inside
the arm — resolves correctly inside the arm and reverts correctly
outside it. One known residual: a match binding used as a NESTED match's
own scrutinee still produces harmless extra "could not infer type"
warnings from the unrelated, still-open BUG-165 rooting pre-pass, which
has no per-arm binding awareness of its own — the program compiles and
runs correctly regardless. Filed during the GC-rooting batch's Task 3
review (the original silent-miscompare shape); a second symptom (the
nested-match crash) and confirmation that constructor payload
sub-bindings share the same gap were added during that batch's final
review. BUG-167 (a match binding sharing a name with a GC-tracked outer
local reads the outer's __reef_locals slot instead of its own) was
found during this fix's own follow-up review, confirmed pre-existing on
both sides of this fix, and filed separately — not fixed here.
GC-typed locals in three previously frameless codegen contexts are now precisely rooted (BUG-159)
Lambda bodies, module-level initializer expressions, and the condition
function generated for await <expr> never built a __reef_locals shadow
frame, so any GC-typed value they computed (e.g. a match scrutinee or a
freshly allocated string) had no root and could be collected while still
live. All three now get a real shadow frame, closing the gap for
systems-level code that relies on precise GC rooting
in these positions.
Stdlib-origin trust is now decided by resolution path, not by string-matching (BUG-156)
The BUG-151 fix exempts stdlib-shipped extern "C" functions from the FFI
string boundary copy because they're already known to return headered
memory — but that exemption was granted by pattern-matching the resolved
file path, so a project-local ./stdlib override directory or a legacy
REEF_STDLIB_PATH-pointed tree (both ordinary user code, not the shipped
stdlib) could be misclassified as trusted and skip the copy it needs.
Origin is now recorded at the module-resolution step that actually found
the file: only the shipped, REEF_HOME/install-resolved stdlib is
trusted; ./stdlib overrides, REEF_STDLIB_PATH, REEF_OS_PATH, and
~/.reef/os are all treated as untrusted, ordinary source.
An enum match scrutinee's GC-managed payload could be freed while live across a guard (BUG-158)
When a match scrutinee was an enum carrying a GC-managed payload (e.g.
Option[string]), the payload value was reachable only through the
scrutinee's own unrooted copy — if an allocating when guard ran before
the payload was read, the GC could reclaim it out from under the match.
Every GC-managed payload field reachable through a scrutinee is now
extracted into its own rooted shadow-frame slot, written tag-conditionally
immediately after the scrutinee binds and before any guard runs.
base64_is_valid now agrees with strict base64_decode (BUG-160)
The BUG-150 strictness fix to base64_decode enforced RFC 4648 final-group
padding rules that base64_is_valid didn't know about, so a caller could
see base64_is_valid(x) == true immediately followed by
base64_decode(x) returning Err(...) on the same input. base64_is_valid
now enforces the identical final-group rules, with a paired test asserting
the two agree.
0.8.4 — 2026-08-06
The pre-0.9 hardening release. Six low/medium-severity fixes, clearing the deck before the 0.9 OO work begins: two diagnostic-quality gaps and a silently-ignored build setting reported downstream against 0.8.3 (BUG-153/154); a match-scrutinee and a match-arm-ordering correctness bug found in the 2026-07-24 adversarial review (BUG-139/145); a literal-escaping bug found in the BUG-147 final review — already a known issue in the 0.8.3 entry below, now fixed (BUG-149); and a base64 strict-padding bug found in the BUG-147 batch C audit (BUG-150). No ABI change since 0.8.3.
Fixed
Syntax errors now name the file, and flag reserved words (BUG-153)
A syntax error caused by a reserved word where an identifier was expected
(e.g. module shared, or import shared in the importing file, since
shared lexes as the keyword SHARED before the imported file is ever
opened) printed an unattributed Syntax error at line N, column M with no
filename, pointing at code that looked valid. Both syntax-error sites (the
entry-file parse path and modules.ml's imported-module path) now print
the filename and, when the offending token is a reserved word, an
additional note: '<word>' is a reserved word and cannot be used as an identifier line.
The published keyword list was missing 18 of the lexer's 83 reserved words (BUG-153)
docs/language/reference/015_BASICS.md §Keywords existed but omitted Any,
Fn, asm, await, byte, const, defer, impl, is, module,
spawn, then, to_map, trait, type_id, type_name, when, and
where — type_name and shared were both found the hard way, by hitting
them. The list is now complete at all 83 entries.
[build] source_dirs in reef.toml is now honored (BUG-154)
Previously source_dirs was written by reefc init's project template and
never read by anything — module resolution only ever searched the CWD, the
entry file's own directory, and src/. reefc build now searches every
directory listed in source_dirs, resolved relative to the project
root and non-recursively, after the existing search order, so no
existing project's resolution changes unless it lists directories. The
non-project compile paths (reefc foo.reef, reefc run) are unaffected.
Named path dependencies ([dependencies] name = { path = "..." }) — the
better long-term fix for sharing modules between projects — are a larger
design deferred to v0.9.
Non-ASCII string literals could fail to compile (BUG-149)
A string literal with a non-ASCII byte immediately followed by a hex-digit
character (e.g. "café" + "au", since é encodes as \xC3\xA9 followed
by a) failed C compilation with hex escape sequence out of range: C's
\xNN escape has no fixed width, so the following hex digit was absorbed
into the same escape. The compiler now emits fixed-width octal escapes for
non-ASCII bytes instead, which have no such hazard. Reachable from any
string literal since 0.8.3 made every literal (not just match-pattern
strings) go through the same escaper — closes the BUG-149 known issue
called out in the 0.8.3 entry below.
A match scrutinee with side effects could be evaluated more than once (BUG-139)
match f() { ... } could call f() once to test a value against an arm
and again to bind it (e.g. a catch-all binding), so a side-effecting or
non-deterministic scrutinee could run multiple times with the bound value
coming from a different call than the one tested. The scrutinee is now
evaluated exactly once into a rooted temporary, referenced by every arm.
Only observable with a side-effecting or non-deterministic scrutinee
expression — a plain value or local was always unaffected.
A specific match arm placed after an unconditional catch-all is now rejected, instead of silently never running (BUG-145)
match x { other => ...; SpecificPattern() => ... } — a catch-all arm
before a specific one — used to compile even though Reef matches in source
order, so the specific arm could never run; on the switch-codegen path it
could even be dispatched to for a matching value, contradicting that
ordering. This is now a typecheck error naming the unreachable arm. Only
affects matches with an unconditional (unguarded) catch-all placed before
other arms — idiomatic code always puts catch-alls last.
encoding.base64 decode now rejects malformed padding instead of silently truncating (BUG-150)
A '=' character was accepted as padding at any position in the input, not
just the final group, so malformed base64 with a stray '=' (e.g.
"QQ==QQ==") decoded "successfully" to a shorter, wrong result instead of
being rejected. Decoding now enforces RFC 4648 padding placement and
returns Err(InvalidInput) for invalid characters or misplaced padding.
0.8.3 — 2026-08-02
Version numbers 0.8.1 and 0.8.2 are retired without a public release: 0.8.1 was never published, and a downstream consumer built pre-BUG-151-fix trunk labeled 0.8.2 — retiring the number keeps "0.8.2" unambiguous (it always means the build with the FFI regression).
ABI break: generated C from an older reefc has no
literal headers, and the heap-block header gains a field — the compiler and
runtime must be upgraded together (already a documented constraint for the
prelude; this makes it load-bearing).
Fixed
User extern "C" functions returning string now boundary-copy their results (BUG-151)
The O(1) string primitives below read a heap-block header at ptr - 48; a
user C helper returning a raw pointer (static buffer, environ, .rodata)
has no header there, so str.length()/indexing read unowned memory —
a downstream init system's realpath() helper made every resolved symlink look
dangling, and a fabricated length could defeat the safe-context bounds
check. The compiler now wraps the result of every user-declared
extern "C" fn ...: string call in reef_string_dup_c() (one copy per
call, cold FFI paths). Stdlib-shipped externs are exempt — they already
return headered memory. Freestanding targets are exempt (kernel FFI owns
its headers). No downstream source changes needed: existing C helpers
returning char* work as they did on 0.8.1. Note the copy is a value
snapshot: mutating the C side's buffer afterward does not change the
returned Reef string, and externs that FILL a caller-provided Reef buffer
still need str.sync_length()/str.set_length() after the fill (see
docs/language/reference/105_FFI.md).
Bounds-checked string indexing and str.length() were O(length), making character scans quadratic (BUG-147)
s[i] in safe context, and core.str.length(), both re-derived a string's
length with strlen on every call. Any safe-context loop walking a string
character by character was therefore O(L²); core.str.split compounded it
further by calling substring() (itself calling length()) once per part.
Reported by a downstream package manager, whose commands hit multi-second
to multi-minute runtimes on ordinary inputs (list: 30.4s; owner:
227s) working around it with private reimplementations of split
and base64 decode.
Fixed by storing an explicit logical length (str_len) in the heap-block
header — the same place array length already lives — instead of scanning to
the NUL. String literals gained real headers too (a compiler-emitted static
aggregate via a new REEF_STRING_LIT macro), and ~21 runtime builtins that
previously returned header-less C strings (getenv, argv, getpwuid
names, CPU/OS strings, TLS/net error strings, ...) now return a boundary
copy with a proper header. reef_string_check, core.str.length(), and
reef_string_copy_range's append destination read str_len directly; every
other operation keeps strlen (they're O(n) memcpy anyway) but writes
str_len on its output.
Measured: a 160 KB checked character scan went from 98-107 ms to 0-1 ms;
core.str.split on 16,000 lines went from 2971 ms to 3 ms (~990x).
reef_string_alloc(0) wrote one byte past a zero-byte allocation (BUG-148)
Found during BUG-147's design review. reef_string_alloc always wrote
s[0] = '\0' regardless of the requested size; with len == 0 that byte
landed past the allocation. No known stdlib caller passed 0, but the
function is a public runtime builtin, so user code could reach it directly.
Fixed as part of the BUG-147 constructor-guard work (if (s != NULL && len > 0) s[0] = '\0';).
Changed
- String buffers can now be filled by index in safe context with no
unsafeblock, closing the BUG-146 family for index-fill buffers: a freshreef_alloc_string_buffer(n)reports its full logical length (n - 1) from allocation (the data region is zeroed, so unwritten indices read'\0'), which is what makes an in-bounds indexed store legal before the buffer is fully written. string_offsetnow requiresunsafe. It returns an interior pointer with no heap-block header behind it; using it outsideunsafeis now a typecheck error rather than a silent header-corruption hazard.unsafecode that moves a string's NUL terminator now owns thestr_leninvariant.core.str.set_length(s, n)andcore.str.sync_length(s)are new — call one after anyunsafewrite that changes where a string's NUL sits, orstr.length()/bounds checks will keep reporting the old length. Seedocs/language/reference/100_UNSAFE.md.- Strings with embedded NUL bytes (e.g.
http.bytes_to_stringon binary data) now report their full byte count fromstr.length(); C interop (anything taking the string as a barechar*) still only sees up to the first NUL, unchanged. --no-gcsemantics clarified: the heap is always initialized;--no-gcnow only disables the collection pass (reef_heaps_disable_gc()). Previously implied (and in one code path, behaved as if) no heap existed at all, which broke boundary-copy allocation under--no-gc.
Known issues
- BUG-149 (Medium, pre-existing, not fixed in this release): a string
literal containing a non-ASCII byte immediately followed by a hex-digit
character fails C compilation.
c_escape_string's maximal-munch escaping emits a plain\xNNsequence for the non-ASCII byte with no delimiter before the following hex digit, so the C compiler consumes it as part of the same hex escape (\xNN+ hex digit = a wider, likely out-of-char-range escape, which GCC/Clang reject). Found during the BUG-147 final review; not yet fixed. Fixed in 0.8.4, above.
0.8.1 — 2026-07-27
The installable-release fix. 0.8.0's binary tarball could not be installed: the installer it advertised was absent from the archive, and would have failed on the archive's own directory layout if present. Nothing in the release pipeline noticed, because nothing ran the install suite. This release makes the binary distribution usable, adds the install documentation it never carried, fixes a stdlib function that aborted on every input it was given, and closes the two gaps in the test harness and CI that let all of it ship.
No API changed. Everything here is a defect in the 0.8.0 artifact.
Fixed
The binary distribution could not be installed
scripts/install.shdescribes itself as "Install Reef from distribution tarball", butscripts/package-binary.shnever shipped it — and it would have aborted if it had. It copied from a flatlib/{stdlib,runtime}layout while the packager writes nestedlib/reef/{stdlib,runtime}; underset -ethe install died after copying only the compiler, leavingbin/reefcbeside empty directories, whosereefcthen silently resolved to whatever other Reef installation existed on the machine. Fixed by copyinglib/wholesale and letting the compiler's own resolver accept either shape, which it already did.- Upgrade-in-place was broken independently.
bin/reefcis packaged mode 555, the only non-writable file in the archive, socpover any previous install failed withPermission denied. Reinstalling 0.8.0 over 0.8.0 could not work. - Installing over an older flat-layout install silently kept the old stdlib and runtime. The wholesale copy preserves the source layout but cannot remove a different layout already at the destination, and the resolver prefers flat — so a 0.7.x user upgrading in place would have got a new compiler linked against an old runtime archive, reported as a successful install. Now detected and refused, naming the stale directory to remove.
The binary distribution shipped no install instructions
- Added
README.mdandINSTALL.mdat the tarball root, written for someone installing a prebuilt archive. Both state the prerequisite that appeared nowhere previously:reefccompiles through C, so a C compiler and linker must be present — the archive is not self-contained. - The archive stopped shipping the repository's own
INSTALL.md, titled "Building Reef from Source", which was the only install document a binary user could find and which told them to install opam, dune and cmake.
io.path.normalize() aborted on every non-empty input (BUG-146)
It filled a scratch buffer with indexed stores in safe context. Such a store
is bounds-checked against the string's current length, not its capacity, and
the buffer allocator returns capacity with length 0 — so after k writes a
store at index k needed k < k, false every time. Every non-empty input
aborted at index 0. Latent since the function was written; BUG-131's addition
of store bounds-checking (after 0.7.8) turned a silently-working raw store into
a hard abort. Fixed by wrapping the loop in unsafe, matching every other
buffer-building function in the stdlib.
Changed
The installer
- Offers a choice of install location when run interactively — the three locations the compiler already searches, plus a custom path — defaulting to a no-privileges user install. Non-interactive behavior is unchanged, including the system default, so scripted callers are unaffected.
- Puts
reefconPATHby symlinking into a directory already there rather than editing a shell rc file. Editing an rc file is now opt-in via--add-path, and an existingreefcis never replaced without asking. - Verifies its own work before reporting success: it runs the freshly installed compiler and confirms it resolves both stdlib and runtime from the prefix just written. The 0.8.0 defect above produced a tree that copied without error and still could not compile.
uninstall.shnow removes thePATHsymlink the installer created, when it points into the prefix being removed.
Testing and CI
- A runtime crash no longer counts as a passing test.
run_tests.shprinted[WARN] compiled but run failedand incremented the pass count for any test that aborted without a companion.expectedfile. Compiling is not the property these tests exist to check. Both that branch and the timeout branch now fail. This is how BUG-146 reached a release, and it was simultaneously masking BUG-147 —test_match_guardshad been aborting two thirds of the way through, reported green, reading past the end of a string in a hand-rolled length helper (the BUG-123 pattern, in a file that fix did not cover). - The release is now gated on the binary tarball being installable. The
packagestep runsscripts/test-install.sh, alongside the existing source-tarball smoke build, so both published artifacts are checked: the source tarball must compile from a clean extract on both toolchains, and the binary tarball must install. The suite gained a third case coveringpackage-binary.sh→install.sh— the sequence a real user performs, and the one combination its two existing cases left untested.
0.8.0 — 2026-07-26
The concurrency-correctness release. Three interlocking subsystems that
govern how Reef programs share memory across threads are made sound: the
garbage collector (the BUG-132 family — a five-defect allocator/collector
race), the Active Object monitor (BUG-120 — Hoare-faithful directed handoff,
plus a cluster of cancellation and precise-GC-rooting fixes around await
and run()), and the SIGURG channel the new stop-the-world collector
depends on (BUG-133). Rounding it out is a compiler-correctness bug
punchlist — eight codegen/typecheck defects, several of which silently
mis-compiled or segfaulted valid-looking programs — and stdlib/tooling
fixes. Two runtime memory-safety bugs in this set (BUG-142 unrooted run()
GC-locals, BUG-137 leaked monitor mutex on cancellation) were the ones that
most warranted the deliberate, review-gated pace. No public API changed
incompatibly.
Fixed
Garbage collector — the BUG-132 family (R1-R5)
A five-defect race between concurrent allocation and stop-the-world
mark/sweep that could hand out the same memory twice or reclaim a still-live
object, latent since heap_alloc_lock was introduced (rev 111) and made
deterministic by the C6 precise-marking landing (2026-05-18). Caught by the
test-asan CI gate on a contended runner, not by local development — credit
to that gate for surfacing a latent race that had been silently wrong for
months.
- BUG-132 (R1-R4): header publication (type, mark, flags), the
allocate-black mark stamp, zero-init, and array redzone unpoison/poison
all moved inside
heap_alloc_lockas one atomic free-block→allocated-block handoff, closing the window where a concurrent sweep could observe a still-free-sentinel header and coalesce a block already handed to another thread. The GC prologue (mark-stack reset, epoch bump) moved insidegc_lock, guarded by agc_collector_ownerCAS so a second concurrentreef_heaps_gc()trigger becomes a no-op wait instead of a second, overlapping collection. - BUG-132 (R5): restored real stop-the-world marking. The collector now
preemptively signal-suspends (
SIGURG) every other registered thread; each target's own handler publishes its precise shadow-frame chain head and in-flight allocation root (__reef_gc_inflight, the F2 mitigation — seedocs/design/GC_PLATFORM_CONTRACT.md§8.3) before parking, and mark walks every published chain with the world fully stopped. Previously the cooperative-safepoint machinery this replaced had zero callers wiring it to a live GC request, so every GC scanned only the collector thread's own chain — a running sibling thread's roots were silently dropped. - Fork safety:
pthread_atforkhandlers reset collector/process-list state in a forked child and defer all collection until the child execs (allocation stays legal via heap expansion in the meantime), closing the fork hazard ranked #1 by the downstream consumer audit. - BUG-134:
lazy_sweep's found-split path now unpoisons the entire coalesced free run before writing the split remainder's header, closing an ASAN use-after-poison flake inmake test-asan(taskset-amplifiedtest_gc_fork: 17/20 failing pre-fix -> 0/50 post-fix). - BUG-133: the runtime signal API (
reef_signal_block,reef_signal_set_handler,reef_signal_ignore,reef_signal_default,reef_signal_wait_set, and the self-pipe registration path) now refusesSIGURGwithEINVAL— it is the collector's stop-the-world suspension channel, so a program that blocked or re-purposed it would deadlock the next GC cycle.reef_signal_unblock(SIGURG)stays allowed. New gatetest_sigurg_reserved.
Active Object monitor & await
- BUG-120: the monitor is now Hoare-faithful. A single shared
monitor_handoff()helper serves both exclusive release sites (unlock andawait), publishinglocked_by = nextunder the mutex before signalling — closing a transient dual-exclusive-ownership/barging window whereunlockandawaithad diverged into two different release protocols.awaitnow waits in an ownership-recheck loop (while (locked_by != self) cond_wait) rather than a single wait plus blind re-stamp — the correct spurious-wake defense under the GC'sSA_RESTART-lessSIGURGsuspension — and recursivelock_countis saved/restored across the wait. New blocking gatetest_ao_monitor(6 cases incl. the platform-independent errant-wake witness) plus a never-> 1-exclusive-holder invariant. - BUG-136:
reef_objects_cleanup()'s cancel loop spun on and use-after-freed a freed-but-still-linked process;cancel_process()now unlinks underprocess_list_mutexbefore cancelling and joins the thread before freeingcontinue_cond/the struct. New gatetest_ao_cleanup_uaf. - BUG-137: cancelling a process parked in
await's ownership loop had nopthread_cleanuphandler, so POSIX cancellation reacquired then leaked the monitor mutex, permanently deadlocking the object. Addedpthread_cleanup_push/poparound all three cancellable monitor cond-waits; theawaithandler also evicts the cancelled process fromawaiting_condand forwards a racing handoff (afind_conditionuse-after-free the mutex-only fix's own test surfaced under ASAN). New gatetest_ao_cancel_parked_await. - BUG-135: removed the confirmed-dead
awaiting_lockqueue and the write-onlylockedfield fromreef_protected_object_t(pure removal, no behavior change). - BUG-142:
run()bodies built no precise-GC shadow frame, so a GC-pointer local declared directly inrun()was unrooted for its entire lifetime and could be collected out from under live code.gen_run_methodnow calls the samebuild_shadow_framehelper the exclusive/shared method paths use. New regressiontest_bug142_run_body_gc_local.reef(RED content-corruption pre-fix, GREEN under plain and ASAN builds). - BUG-118: an
awaitcondition referencing a method parameter or local (not justself) failed C compilation; the generated condition function now snapshots captured free variables by value alongsideself(reusingcollect_free_vars). Self-only sites are byte-for-byte unchanged. - BUG-117:
awaitwritten lexically insiderun()is now rejected at typecheck with a diagnostic naming the working workaround (move it into anexclusive fn/procand self-call it).run()'s thread never holds the monitor, so an earlier same-day attempt to make it compile silently no-op'd the wait — the rejection is the sound fix. - BUG-143: inside any method body,
let k = self.method()inferred the local asintregardless of the callee's real return type — codegen'sinfer_expr_typehad no arm for aSelf-receiver call. Added the missing arm (including theTGenericreceiver case).
Compiler — unsound programs now rejected
- BUG-124: a bare zero-arg enum-constructor pattern in a
matcharm (Status_Pending =>without parens) parsed as a catch-all binding — a silent non-exhaustive match ending in a runtime segfault. Now a hard type error; the 23-file stdlib blast radius (graphics/UI) was mechanically parenthesized in the same change. - BUG-140: a
matchwith two catch-all-shaped arms (a binding then a trailing_) that reached the switch-codegen path emitted two Cdefault:labels ("multiple default labels"). Rejected at typecheck instead (mirrors BUG-124), counting only unguarded catch-alls so the legitimate guarded-fallback idiom (_ when c1,_ when c2,_) is unaffected.
Compiler — codegen correctness
- BUG-129: a
matchcatch-all binding (other =>) was never assigned the scrutinee across 9 codegen sites on the if-else-chain and match-expression paths — reading the binding segfaulted on a NULL. A sharedgen_match_binding_assignhelper now mirrors the already-correctPVarcase (GC-tracked store vs. plain local), with guard-before-binding ordering. - BUG-128: codegen's bare-pattern match classification was cruder than
the typechecker's, so a binding named after a different enum's variant
built an undeclared C tag; codegen now uses the same
resolve_enum_for_scrutineelookup, and variant resolution fails loudly instead of silently falling back. - BUG-131: array-index assignment (
arr[i] = v) emitted an unchecked C subscript store even outsideunsafe. The store is now bounds-checked (single-evaluating array/index/RHS) exactly mirroring the read path's dispatch andunsafe-gating; RFE-005 made this 64-bit-reachable. - BUG-138: a
stringmatch whose only arm is a catch-all binding selected the integerswitchpath —switch (char*)is a hard C error. Thestrcmpchain is now selected whenever the scrutinee is string-typed, not only when a string-literal pattern is present. - BUG-130:
ui/widgets/slider.reefused the reserved keywordstepas a field/parameter name (a parse failure invisible to the suite since nothing imported it); renamed tostep_value, and aui.widgets--checksmoke section now covers all 8 widget files. - BUG-122: the inline-asm clobber list emitted invalid 32-bit
sub-register names (
r8d,r9d, …) for extended x86-64 registers R8-R15, which GCC rejects; codegen now normalizes any R8-R15-family clobber to its single valid GCC spelling and de-dups (REX-lesseax/ecx/… untouched). New dedicatedgcc -c(real-compile) regression gate.
Standard library
- BUG-121:
compress.gzip's in-memorygzip_compress/gzip_decompressemitted plain zlib framing (78 9c …) despite the name; rebuilt on zlib's stream API withwindowBits = 31/47so they emit and auto-detect true gzip framing (1f 8b …), verified interoperable with systemgzip(1)/gunzipboth directions. Also fixed az_stream/errnumnative-heap leak on the new path. - BUG-144:
compress.zlibleaked its scratch buffers on all return paths (pre-existing); freed on every path (ASANdetect_leaks=1: 0).
Tooling & packaging
- BUG-125:
paths.mlnow resolves both the flatlib/{stdlib,runtime}layout (install.sh/package.sh) and the nestedlib/reef/{stdlib,runtime}layout thatmake install/package-binary.sh(the real forge release) actually produce, at every home candidate. - BUG-126:
install.sh/package.sh/build-release.shderive the version fromreef-compiler/dune-projectinstead of a nonexistent top-levelVERSIONfile. Newmake install-testregression-guards the whole package→install→run-without-REEF_HOMEpath across both layouts.
Changed
- RFE-005 (reported downstream): array and string index expressions now
accept the full integer family (not just
int), and the bounds-check path is widened toint64_t. - BUG-127:
finalize()is now allowed in any position among an Active Object's methods (previously position-constrained). - Lazy sweep revived as the sole sweep mechanism. Mark is the only
- Lazy sweep revived as the sole sweep mechanism. Mark is the only
stop-the-world work; sweep is folded into the allocator under
heap_alloc_lockand runs incrementally, concurrently with other mutators, once the world resumes. Added a swept-clean-until-next-epoch latch, mark-measuredbytes_liveaccounting (replacing threshold inflation from stale byte counts), and pause/sweep telemetry. - Deleted the dead cooperative-GC hybrid:
reef_objects_set_gc_status, thegc_requested/gc_safepointpark arm, and the unusedreef_heaps_write_barrier— all verified zero-caller at tip.--quantum-checkscodegen emission andreef_objects_yield_or_gc's name/signature are preserved (downstream carve-out; unrelated to hosted GC correctness).
Added
docs/design/GC_PLATFORM_CONTRACT.md: the normative contract for the shipped STW-mark/lazy-sweep design — lock discipline, the SIGURG suspension protocol, fork safety, and F2 (see below).- New blocking test gates:
test_ao_monitor,test_ao_cleanup_uaf,test_ao_cancel_parked_await,test_sigurg_reserved,test_gc_suspend,test_gc_stw_retention,test_gc_lazy_sweep, and the re-enabled/amplifiedtest_gc_race_windowandtest_gc_fork— all run under both the plain and ASAN ctest suites. make install-test(scripts/test-install.sh): end-to-end package→install→run-without-REEF_HOMEsmoke test covering both install layouts (BUG-125/126 guard).
Build & CI
- The source tarball builds warning-tolerantly by default.
-Werroris now gated behindREEF_WERROR(default OFF), so a compiler- or version-specific warning cannot break an end user's build of a pristine release. CI and developers opt into strict mode (RUNTIME_WERROR=ON/-DREEF_WERROR=ON). - CI now builds the runtime under both GCC and clang at Release
-O3with-Werror, and smoke-builds the packaged source tarball before publishing. This closes the gap that let a GCC-15-only-Werror=stringop-truncationin a test file (test_gc_fork.c— not product code) break the initial 0.8.0 source tarball on a stock GCC toolchain, while the clang-based build passed.
Known limitations / follow-ups
- F2 (accepted, documented, not fixed): a narrow return-value register
window — an allocated object's only live reference can transiently sit in
a register or dead stack slot between a callee's frame unlinking and the
caller storing it into a chain-rooted slot.
__reef_gc_inflightcloses the dominant instance (the most recent allocation on a thread); the residual (an earlier allocation returned past a later one) is bounded, empirically unobserved across hundreds of stress runs, and formally accepted as an owner decision rather than scheduled for a fix. See the platform contract §8.3 for the two named (unimplemented) closures. - BUG-139 (open, Low):
match's if-else-chain codegen re-evaluates the scrutinee once per arm; a side-effecting/non-deterministic scrutinee (match f() { … }) runsf()N times. Pure-value scrutinees (the common case, incl. any plain local) are unaffected, and a repo-wide grep found zero in-tree triggers. Fix (deferred): bind the scrutinee to one temp. - BUG-145 (open, Low): an unconditional catch-all arm placed before a
specific arm silently shadows it, and switch-codegen label
order-independence can dispatch to the later specific
case. Pre-existing and anti-idiomatic; fix (deferred): generalize BUG-140's rule to "any arm after an unguarded catch-all is unreachable". - BUG-141 (open, enhancement):
run()has noawaitsemantics of its own — a bareawaitthere is rejected (BUG-117) with a working workaround; automatic lowering to that shape is a possible future enhancement.
Test suite: 518 passed / 0 failed / 21 skipped (was 478/0/21 at v0.7.8);
runtime test_heaps 44/44, ctest 19/19 (was 9/9 at v0.7.8 — grew with the
new GC and Active Object monitor gates), make test-asan 19/19,
make docs-check 19/19.
0.7.8 — 2026-07-21
The baremetal restoration release. Two regressions that made baremetal
targets unbuildable since the C6 precise-GC landing (2026-05-18) are fixed —
the raw-address integer→pointer cast baremetal code depends on, and the
missing C6 shadow-frame contract in the freestanding builtins header — and
a suite gate now catches this class of regression automatically. A
main-thread await() runtime bug is fixed, and the website's examples
library ships its seventh and final entry.
Fixed
- BUG-119: integer→pointer/array casts (
addr as pointer,addr as [T]) insideunsafeblocks are accepted again, symmetric with the already-unsafe-gated pointer→X direction. This restores the pre-0.5.20 raw-address idiom — MMIO registers, physical pages, page tables, trap frames — that all baremetal Reef code depends on, and had no workaround (not even an FFI shim) since it regressed. Unblocks a downstream OS migration (~282 call sites) and the shippedreef-os/UART drivers, which no longer compiled under the regression. Casts outsideunsafecontinue to be rejected with the existing error. New coverage: a positive round-trip test (examples/test_unsafe_ptr_cast.reef) plus twoEXPECT_FAILnegatives. - BUG-116:
reef-baremetal/include/reef_baremetal.hnow carries the C6 precise-GC shadow-frame contract —reef_gc_frame_hdr, a freestanding__reef_gc_top(a plain global; freestanding has no TLS runtime, so schedulers save/restore it per-thread and SMP ports go per-hart), and freestandingreef_string_check/reef_array_checkrouted toreef_panic. Every baremetal target has failed freestanding C compilation on any nontrivial program (string or array indexing) since the C6 landing on 2026-05-18; this was the gap. Baremetal kernels build again for the first time since then. - BUG-115:
reef_objects_register_main_process()never allocated the main thread'scontinue_cond, soawait()called directly frommain()with an initially-false condition returned immediately instead of blocking — silent and 100% reproducible, but never caught because every prior await-from-main test only exercised the condition-already-true fast path. Found building the newparallel_hashexample (a worker-AO/collector design wheremainblocks oncollector.wait_for()until all workers finish). Fixed by allocatingcontinue_condinreef_objects_register_main_process()exactly asreef_objects_create_process()already does. - BUG-123: the three Limine example kernels
(
kernel_limine{,_aarch64,_riscv64}.reef) read their string's NUL terminator inprint_string'swhile ch != '\0'loop without wrapping the read inunsafe.reef_string_check's bounds semantics are correct (index ==strlenis out of bounds, matching the hosted runtime exactly, and matchingcore.str.length's identical idiom which does wrap inunsafe) — this was an example-source bug, exposed the moment BUG-116 let the freestanding build run far enough to hit it. Boot-verified under QEMU:kernel_limine.reefnow prints its full banner through "System halted." before reaching its own halt loop. run_tests.shredirects example stdin from/dev/null. Under an interactive terminal, GNUtimeoutputs its child in a background process group; the first stdin-reading example (birthday_calc) gotSIGTTINon TTY read and stopped forever, freezingmake test-all. Never seen in CI or piped runs, where stdin is already EOF.
Added
- Freestanding-compile suite gate: every suite run now emits C for the
three Makefile-built baremetal kernels and syntax-checks it freestanding
—
gccnatively for amd64,clangcross for amd64/arm64/riscv64 (no cross-gccneeded). Closes the suite gap that let BUG-116 ship silently: a codegen change that assumes hosted-only symbols now fails the suite instead of shipping unnoticed.kernel.reefis excluded (not built by any Makefile, and affected by an unrelated pre-existing asm-clobber codegen defect, BUG-122, that-fsyntax-onlycannot catch anyway). examples/parallel_hash.reef: concurrent SHA-256 over worker Active Objects, demonstrating the worker/collectorwait_for()pattern that surfaced BUG-115.- Website examples library complete at seven entries
(
docs/marketing/examples/), including entry 06 (baremetal kernel), un-blocked by the BUG-116/BUG-123 fixes and backed by a genuine QEMU boot capture.
Known issues
- BUG-122 (Low, pre-existing, not fixed in this release): the inline-asm
clobber-list codegen emits invalid GCC 32-bit sub-register clobber names
(
r8d,r9d, ...) for the extended x86-64 registers R8–R15. GCC accepts only the unsuffixed spelling (r8, ...) in a clobber list; Clang normalizes and is unaffected. Found incidentally while building the freestanding suite gate above. Narrow blast radius: the only in-tree consumer isexamples/baremetal_kernel/kernel.reef, which is not built by any Makefile and is not exercised by the test suite.
Test suite: 478 passed / 0 failed / 21 skipped (was 469/0/21 at v0.7.7;
grew via the freestanding gate, BUG-119/BUG-115 regression coverage, and
the run_tests.sh stdin fix's interactive-run determinism — no example or
baseline was removed); runtime test_heaps 45/45 unchanged, ctest 9/9
unchanged.
0.7.7 — 2026-07-21
The licensing and internals release. The project's license is formally established, ten compiler bugs from the reference-documentation audit are fixed (five of them by rejecting unsound programs that previously compiled), the emitted C prelude is relocated into the runtime as real, tested C, and CI now runs the GC ASAN redzone suite on every push.
License
- Reef is now licensed Apache-2.0 WITH LLVM-exception. Previously the LICENSE file was empty while packaging metadata claimed MIT. Apache-2.0 adds an explicit patent grant; the LLVM runtime-library exception waives attribution obligations that embedded runtime code would otherwise impose on compiled Reef binaries. This is the first release under the new license.
Changed — programs now rejected (previously accepted, unsound)
- BUG-113:
sharedActive Object methods that mutate state are rejected — they broke the data-race-freedom guarantee. - BUG-114: a
sharedmethod calling anexclusivemethod onselfis rejected — it deadlocked silently at runtime. - BUG-105:
spawnon an Active Object method is rejected at typecheck (previously generated invalid C). - BUG-106: a required parameter after a default parameter is rejected at
typecheck (previously passed
--check, failed as a raw C error). - BUG-107: Active Object method-level generics are rejected at typecheck (previously passed, failed as a raw C error).
Fixed
- BUG-108: set union/intersection/difference (
+,*,-) now lower to bitwise operations — they emitted integer arithmetic, producing silent wrong answers. - BUG-109: closure capture analysis now walks string-interpolation nodes
and 16 sibling expression forms it previously skipped, including
matchguards. - BUG-112:
deferblocks now run at the implicit end of void Active Object method bodies instead of being silently discarded. - BUG-110: architecture names (
amd64,arm64,riscv64) are contextual keywords instead of reserved words — tworeef-osmodules were unimportable. - BUG-111: removed the
--gc-signalsflag; itsrtminmode emitted a call to a runtime symbol that does not exist.
Changed — generated C
- Every generated
.cfile starts with a provenance header: reefc version, source module, DO-NOT-EDIT notice, and license scoped in prose to the embedded runtime portions (deliberately no file-level SPDX tag — the user's translated code remains theirs). - The hosted builtin prelude (~500 lines: string concat/alloc, bounds checks,
stdio wrappers, formatting) is no longer emitted inline. It lives in
reef-runtime/include/reef_prelude.hand is pulled in with a single#include; the duplicated forward declarations are gone and the header is the single source of truth for builtin signatures. Compiled output is unchanged (preprocessed output verified byte-identical at relocation). Note: reefc 0.7.7 requires the matching runtime install — older runtime trees lackreef_prelude.h.
Added
- Unit tests for the GC-adjacent prelude helpers (
reef_string_concat,reef_alloc_string_buffer,reef_buffer_append): content, GC-survival, and abort-guard coverage, running under the ASAN GC-heap redzone build where an out-of-bounds write traps at the exact prelude line. - CI runs the ASAN runtime suite (
make test-asan) on every push, PR, and tag. Stress tests scale down under sanitizers; the plain build keeps full scale and the strict BUG-027 performance gate. docs/runtime/ARCHITECTURE.md: placement rule for compiler builtins (prelude vslibreef_runtime.avs baremetal).
Documentation
- README code examples fixed (neither compiled).
docs/language/referenceaudited against the compiler; the audit filed the nine bugs fixed above.- Research-page marketing copy revised.
Test suite: 469 passed / 0 failed / 21 skipped (was 460/0/22);
runtime test_heaps 45/45.
0.7.6 — 2026-07-17
The hardening release. No new stdlib surface: same-named cross-module generics, full TOML value grammar, a large compiler-correctness campaign (15 bugs fixed, several found by compile-gating every documentation example), and both top-level language documents brought current.
Added
- TOML value grammar (BUG-089):
encoding.tomlnow validates scalar values — integers (sign,_separators,0x/0o/0b, no leading zero), floats (fraction/exponent,inf/nan), booleans, and full RFC 3339 date-times (all four TOML shapes, field-range checked; per-month day counts and leap years are not validated). A malformed unquoted value (port = eighty,a = 01,t = 25:00:00) is a positionedErr(InvalidInput)instead of a silently accepted raw string. - TOML structured values, flattened into the existing key-path model:
dotted keys (
a.b.c = 1→ keya.b.c; quoted segments supported), inline tables (k = { x = 1 }→k.x; nested tables recurse; TOML 1.0 rules enforced — single-line, no trailing comma, duplicate keys rejected), and inline arrays (k = [1, 2]→k.0,k.1+ a synthetick.#lenlength marker; multi-line with comments, trailing comma legal, mixed types permitted, nested arrays/tables compose). New accessorstoml_array_len_doc/toml_array_item_doc. - Same-named generics across modules (BUG-066): two modules may now each define a generic type with the same short name — each definition is independently specialized under a module-qualified C symbol. A module that can see both definitions cannot use the bare name (rejected as ambiguous, naming the candidate modules).
- All-paths-return analysis (BUG-096): a value-returning function whose
control flow can reach the end of its body without returning is now a
compile error ("may reach the end of its body without returning a value")
instead of silently returning garbage. A trailing bare expression remains a
valid implicit return;
panic/exittails, both-branchif/else, all-armmatch, andwhile truedivergence are recognized.
Fixed
istype-test with aliases (BUG-104):p is Boxed(an alias to a struct) works;isagainst an alias resolving to a primitive is a clean compile error instead of a C build failure. The target ofisis now validated at typecheck (it was previously ignored entirely).- Type aliases are usable (BUG-103): primitive aliases (
type Age = int) were rejected by the return-type check, interpolation, comparisons, and arithmetic; all type-dispatch sites now resolve aliases. Two latent miscompiles fixed along the way:==/+on string aliases lowered to pointer operations, and arrays of primitive aliases were traced as pointer arrays. A cyclic type alias is now a clean compile error (previously hung the compiler). Casting to/from a primitive alias works (5 as Age). - Lambda defer scope (BUG-102): a
deferinside a lambda ran at the wrong time (leaked into the enclosing function's cleanup, duplicated); lambdas now have their own defer scope with the same capture-before-defer return semantics as every other construct. - String interpolation single evaluation (BUG-100):
${expr}evaluated side-effecting expressions twice (once to measure, once to format); each interpolated expression now evaluates exactly once, in order, with GC-safe handling of freshly allocated strings. - defer/return semantics unified (BUG-101): the return value is now
computed before deferred blocks run in every construct (plain functions,
struct and trait methods, Active Object methods) — a mutating
defercannot change the returned value — and a GC-typed return value is kept rooted while defers execute. Also fixed: struct methods leaked defer registrations across methods and never flushed defers on implicit returns. - Generic Active Objects work (BUG-095): the documented
Container[T]pattern (a generic AO assigning aT-typed parameter to aT-typed field) never compiled ("cannot assign 'T to T"); AO fields are now registered with the enclosing type parameters, and generic AO templates are correctly excluded from C emission. - AO method implicit returns (BUG-097): an
exclusive/sharedmethod ending in a bare trailing expression compiled but returned garbage; it now returns the expression's value, anddeferblocks are flushed on all AO return paths with capture-before-defer semantics (a mutatingdefercannot change the returned value). - Module-level Active Object / struct instances (BUG-098): methods called
on a module-level
letinstance from a different function emitted un-mangled C calls ("call to undeclared function"); module-level bindings now carry their inferred types. - Match exhaustiveness for transitive enums (BUG-099): matching all
variants of an
Option/Result(or any enum) returned by another module no longer requires a direct import of the enum's defining module; the spurious "Non-exhaustive match" error is gone, and genuine misses now name the actual missing variant. - Generic type confusion (BUG-094): passing one module's
Box[string]where another module's same-namedBox[string]is expected is now a typecheck error (previously compiled silently through a C pointer-type warning and misread fields at runtime). - TOML table-array capacity (BUG-089 T6): exceeding 64 unique
[[name]]table-arrays is nowErr(OutOfRange)(previously silently collided later tables under index 0).
0.7.5 — 2026-07-15
The Result/Option release. Fallible stdlib operations no longer signal
failure through in-band sentinels ("", -1, nil, silent lenient
recovery). They now return result.Result[T, core.error.Error] when failure
carries a reason, or option.Option[T] when a value may simply be absent.
Pure functions, predicates, and constants are unchanged.
This is a breaking API change across most of the stdlib. Callers must
bind the result and branch on it (result.is_ok/unwrap_ok,
option.is_some/unwrap_or) instead of comparing against a sentinel.
Added
core.error.Error { kind, message, code }— the canonical structured error type, constructed witherror.error(kind, msg).ErrorKindis an enum (InvalidInput,NotFound,IoError,Timeout,WouldBlock,OutOfRange, …).core.option/core.resultare now the canonical genericOption[T]/Result[T, E]. Unwrapping the wrong variant panics.panic(msg)noreturn builtin (plus a freestandingreef_panicfor baremetal targets).- Strict, validating JSON and TOML parsers:
json_parse_doc/toml_parse_docreturnResult[Doc, Error]; malformed input is a positionedErrinstead of a silently-recoveredOk. NewJsonDocstruct. - JSON
\uXXXXescape decoding (BMP + surrogate pairs → UTF-8) on read. neterrno→ErrorKindmapping viasocket.net_err().- Compiler intrinsics
reef_feof/reef_ferror/reef_ftello/reef_fseeko(back the io/fs Result migration).
Changed
- Parsers →
Result[T, Error], rejecting invalid input:str.to_int,core.convert.*(toInt/toFloat/from_hex/from_binary/from_octal),time.time_parse_iso,date.date_parse. Trailing garbage is now anErr. - Environment:
sys.env.get_env→Option[string](was a""sentinel; set-but-empty and unset now agree via the Option). - Collections lookups →
Option[V]:vec,list,stack,queue,map,hashmap,growable,ordered_map,trie(get/pop/peek/first/last/dequeue/…). - io / fs →
Result[T, Error]: file reads/writes/opens,seek/tell,statmetadata,mkdir/rmdir/cwd, symlink/hardlink/readlink, copy/remove/rename/tree ops. ConsolereadLine→Option[string].file_size/file_mtimewidened toint64. - encoding decoders →
Result[T, Error]: base64, hex, utf8, msgpack. - net →
Result: tcp/udp/unix/dns/tls fallible ops;recvreturnsOk("")for a clean EOF (distinct fromErr). http: the publicget/downloadAPI returnsResult; an HTTP status is data (4xx/5xx →Ok, transport failure →Err), anddownload_filefailures now carry a reason. text.string_internerandcore.arrayindex/min/max → Option/Result.- JSON writer now escapes control characters
< 0x20(\b/\f/\u00XX) per RFC 8259 (was emitting raw bytes → invalid JSON).
Removed
- Monomorphized
core.result/core.optiontypes and thecore.result_generic/core.option_genericmodules — use the canonical genericcore.result/core.option. json_parse_status/toml_parse_status(subsumed byErr(OutOfRange)on overflow) andTomlDoc.truncated.
Fixed
- A long tail of monomorphization / type-inference compiler bugs surfaced by
the migration, most of the form "a generic specialization was silently
dropped → C-stage call to undeclared function": BUG-065, 067, 069, 070,
071, 072, 073, 074, 075, 076, 077, 078, 079, 080, 081, 082, 083, 084, 086,
and 093 (the
infer_expr_typeif-expression-initializer case, closing that family). BUG-066 (same-name cross-module generic collision) gets a clear compile-time guard. - Stdlib: BUG-057/058/059/060/062/063/064 (graphics parsers,
strlength overflow, ctype), BUG-087 (msgpack integer sign-extension + int-width overflow), BUG-088 (JSON\udecoding), BUG-090 (JSON control-char escaping), BUG-091 (unixrecvGC copy).
Security
- The strict JSON/TOML parsers reject malformed structure instead of silently
recovering, so a malformed document is a caught
Errrather than a plausible-looking partialOk. msgpack integer decode no longer sign-extends or overflows.str.repeat/str.replaceguard length overflow with a panic.
0.7.2 — 2026-07-08
Security
- Hardened the PNG, BMP, and TTF parsers against hostile input: bounds-checked
reads, dimension/count caps, integer-overflow guards, a TTF
hmtxout-of-bounds-write clamp, and a compound-glyph recursion limit.
Fixed
- Graphics rendering correctness: arc direction (clockwise arcs no longer
become their large CCW complement); non-zero winding fill (previously
rendered nothing); blend modes
add/multiply/screennow respect destination alpha. - Added 10 new headless graphics regression tests (memory-surface rendering, no X11 dependency) covering the parser-hardening and rendering-correctness fixes above.
Changed
canvas.fillnow composites semi-transparent fills over the destination (previously overwrote) and defaults to theNonZerofill rule (was even-odd). Opaque convex fills are unchanged; self-intersecting/nested same-winding paths and translucent fills now render correctly.
Added
graphics.font.ttf.load_ttf_bytes([int]),graphics.canvas.set_fill_rule,graphics.backend.memory.surface_to_rgba8_bytes(all additive).reefcauto-links-lzwhencompress.zlibis used.
0.7.1 — 2026-07-08
Fixed
- Memory-safety codegen bug (BUG-055): a function returning an allocating expression (a struct literal, or a call that allocates) could free still-live locals mid-return. The shadow-frame epilogue that unroots a function's GC roots was emitted before the return expression was evaluated, so a garbage collection triggered during that evaluation could sweep objects reachable only through the frame being returned from. The return value is now captured while the frame is still live. Affected any allocating return expression, not just the graphics code that surfaced it.
Security
- Allocator size guard (BUG-056):
reef_heaps_new_arraynow rejects a negative array length, or anelem_size × countproduct that would overflowsize_t, with a clear abort instead of unsigned-wrapping it into a multi-gigabyte allocation. Defense-in-depth backstop beneath any future codegen or library bug that produces a bad size; array lengths cross the allocator boundary as a signed value so a negative never silently becomes huge.
0.7.0 — 2026-07-06
Changed
- TLS backend: Reef no longer bundles LibreSSL. The runtime TLS shim
(
libreef_tls.a) builds against the OS-provided OpenSSL (>= 3.0) and TLS-using programs link the systemlibssl/libcryptodynamically. Building with HTTPS support now requires OpenSSL development files (e.g.libssl-dev). Thenet.tlsAPI is unchanged.REEF_CA_BUNDLEstill overrides the trust store; the system default store is used otherwise. - Abrupt peer close (EOF without close_notify) still reads as clean EOF, matching the previous backend's behavior.
Security
- TLS trust configuration now fails closed: an explicitly configured CA
source (
REEF_CA_BUNDLEortls_config_set_ca_file/_ca_path) that cannot be loaded is a hard error instead of silently falling back to the system trust store.REEF_CA_BUNDLEtakes a CA bundle file (not a directory). - A verifying TLS connection with no server name is now rejected ("server name not specified") instead of silently skipping hostname verification.
Added
- Offline TLS regression suite (
test_tls, 50 checks against a loopback TLS server) covering verification defaults, the insecure knobs, fail-closed trust loading, socket-upgrade ownership, and EOF handling.
Removed
- Vendored LibreSSL tree (
third-party/libressl/).
0.6.5 — 2026-06-01
Changed
- Source control and CI migrated from git to Isurus, the company's Mercurial forge with built-in CI; this is the first release published through Isurus.
scripts/bump-version.shnow drives Mercurial (hg commit/hg tag) instead of git.
0.6.4 — 2026-05-19
Closes the BUG-055 family. Defensive hardening only — no observable behavior change for current code. The 10 new warnings added here fire zero times across the 379-test suite, but stand as passive guards for future typechecker regressions.
Added
-
BUG-055 follow-up B55.3 (audit): silent TInt fallbacks in
infer_expr_typenow warn where they could plausibly misclassify a GC pointer. The original Var-lookup-miss atcodegen_analysis.ml:196and the Call-Var-callee fallback at line 247 (added in v0.6.1) were joined by ten more, covering the same shape across:- Method dispatch resolution failures (5 sites under
Call → FieldAccess → Var— generic-type instance, regular-type instance, trait impl mangling, var-not-in-env) Callwith complex callee whose inferred type isn'tTFunctionArrayAccesson a value inferring to neitherTStringnorTArrayFieldAccess: field-not-found in struct, field-not-found in AO, obj-not-a-struct/AO, obj not a struct at all (4 sites)GenericCallfn_name not in envQualifiedGenericCallfn_name not in module ns, and qualifier not a known module (2 sites)
Every other
_ -> TIntsite in the file (suffix-less IntLit, empty SetLit / empty Match-arm defaults, TypeId reflection, the Built-in-array unknown-method fallback) gained an explanatory comment but no warning — each is either a correct default or unreachable in typecheck-valid code. - Method dispatch resolution failures (5 sites under
Final BUG-055 family verdict
| Release | What landed |
|---|---|
| v0.6.1 | Pre-pass binding threading — closed the let-alias warning + latent UAF |
| v0.6.2 | ForEach element + collection precise rooting — closed the foreach SIGSEGV |
| v0.6.3 | Match pattern binding precise rooting — closed the 24% block-reuse corruption |
| v0.6.4 | Audit of remaining silent TInt fallbacks — defensive guards for future regressions |
The compiler now has consistent precise-marking coverage across all the binding-introducing constructs Reef supports (let / mut / foreach element / match pattern) and active warnings for every silent-fallback site that could plausibly degrade a GC-pointer expression to TInt.
Final suite
- Compiler: 379 passed / 0 failed / 22 skipped (unchanged from v0.6.3).
- Runtime: 38 / 38.
0.6.3 — 2026-05-19
Closes the second follow-up from BUG-055: Match pattern bindings have the same precise-marking gap as the let-alias (v0.6.1) and foreach-elem (v0.6.2) cases.
Fixed
-
BUG-055 follow-up F2: Match pattern bindings unrooted. Arms like
match opt | Some(x) => use(x)previously emittedxas a plain C local outside__reef_locals. When the matched value's heap payload was held ONLY through the enum's union — for examplelet opt = make_some_of_string(i)where the string is allocated inside the constructor and never named anywhere else — body-internal allocations could sweep the payload AND reuse its block, leavingxreading garbage content.Verified pre-fix: 12/50 wrong-suffix corruptions on a 50-iter Option[string] match with allocation pressure tuned to provoke block reuse (
REEF_HEAP_BLOCK_SIZE=262144). Post-fix: 0/50.Two-part fix:
-
Pre-pass (
codegen_util.ml::collect_tracked_vardecls_in_stmtMatch arm) now extracts pattern bindings via a newpattern_variant_payload_typeshelper that resolves variant payload types from the scrutinee's enum binding. HandlesTGenerictype-parameter substitution for monomorphized enums (e.g.,Option[string]→Option_stringwith payloadTString). PVar bindings (whole-scrutinee) get the scrutinee's type; PConstructor bindings get one payload type per slot. -
Emit-pass (
codegen.ml::gen_match_arm) at three sites — the PVar default arm (~line 2086), the PConstructor single-binding case (~line 2154), and the PConstructor multi-binding case (~line 2192) — assigns through__reef_locals.<var>when the binding is ingc_tracked_locals, instead of declaring a plain C local. Without this, the pre-pass classification took effect but the body's Var references (routed through__reef_localsvia the existinggc_tracked_localscheck at line 261) read uninitialized memory.
Regression test:
reef-compiler/examples/test_gc_match_pattern_alias.reefcarries// ENV: REEF_HEAP_BLOCK_SIZE=262144to provoke GC + block reuse reliably. -
Final suite
- Compiler: 379 passed / 0 failed / 22 skipped (was 378 — +1 for the new regression test).
- Runtime: 38 / 38.
Remaining BUG-055 follow-ups
- Categorise the ~22 silent
-> TIntfallbacks left ininfer_expr_type. The Call-arm sibling I added a warning to in v0.6.1 has now run across three versions of the suite and never fired, so the others are probably similarly inert; each still warrants a one-line "when can this fire" note.
0.6.2 — 2026-05-19
Closes the first follow-up from BUG-055: the foreach-element precise-marking
gap. Pre-v0.6.2, for x in [a, b, c] over a non-rooted collection was a
hard UAF when the loop body allocated.
Fixed
-
BUG-055 follow-up F1: foreach element + collection unrooted. Sibling of v0.6.1's let-alias fix. ForEach codegen previously emitted both
__arr(the collection alias) and the loop element variable as plain C locals — body-internal allocations could sweep the array's contents while the loop was still iterating. Trigger pattern was foreach over an array literal, a function-call result, or any other collection not held in a rooted slot. Pre-fix repro SIGSEGV'd atstr.length(s)reading post-sweep poisoned memory (0xDEADDEAD).Two-part fix:
- Pre-pass (
codegen_util.ml::collect_tracked_vardecls_in_stmt) now infers the element type from the collection expression (TString → TChar,TArray inner → inner), registers the loop variable inctx.type_env, and seeds the GC-tracked-locals set when the element type is GC-pointer-shaped. - Emit-pass (
codegen.ml::gen_stmtForEach arm) wraps the array-iteration block in a transientreef_gc_frame_hdrthat roots__arr, and routes a GC-tracked element through__reef_locals.eleminstead of a plain C local. String iteration is unchanged (the elements are chars, not GC pointers).
Regression test:
reef-compiler/examples/test_gc_foreach_string_alias.reef— array-literal foreach with body-internal allocation pressure. - Pre-pass (
Final suite
- Compiler: 378 passed / 0 failed / 22 skipped (was 377 — +1 for the new regression test).
- Runtime: 38 / 38.
Remaining BUG-055 follow-ups
Both deferred — not blocking and not yet known to bite:
- Verify
Matchpattern bindings (Some(x) ->) reachctx.type_env. The match codegen is more involved; needs an audit trace. - Categorise the ~22 silent
-> TIntfallbacks left ininfer_expr_type(the Call-arm sibling I added a warning to in v0.6.1 hasn't fired once across the suite, so the others are probably similarly inert, but each warrants checking).
0.6.1 — 2026-05-19
Fixes BUG-055 — a codegen pre-pass asymmetry that had been latent since
v0.5.0. The visible symptom was a flood of "Could not infer type for variable 'X', defaulting to int" warnings on most non-trivial Reef
programs (118 sites in the shipped stdlib, 244+ in a downstream package manager); the deeper
issue was a precise-marking gap for GC-pointer aliases.
Fixed
-
BUG-055: pre-pass binding-threading asymmetry.
codegen_util.ml'scollect_tracked_vardecls_in_stmt(the pre-pass that decides which locals get lifted into__reef_localsfor precise GC tracking) calledinfer_expr_typeon each VarDecl's initializer but never added the resulting binding toctx.type_env. The latergen_stmtemission pass did. So Pass 1 saw an incomplete env: anylet X = Ywhere Y was a local from a prior stmt in the same scope hit the Var-lookup fallback, warned spuriously, and silently classified X asTInt. When X was actually a string or struct pointer, the misclassification meant X got emitted as a plain C local outside__reef_locals— invisible to the chain walker, swept by any GC during X's live range. Same family of UAF as C6.3 case 01 (nested call args) and BUG-053 (interior-pointer heap-walk corruption).Fix: thread the binding forward in the pre-pass.
Hashtbl.addallows duplicates and lookup returns the most recent, sogen_stmt's lateradd_bindingfor the same name is harmless. ~10-line change incodegen_util.ml. The 118 stdlib warnings, the 244+ downstream warnings, and the latent UAF all close at once.Reframing: the original BUG-055 report claimed v0.6.0 introduced this via a stricter typechecker. Verified false — v0.5.20 emits byte-for- byte identical warnings; the warning was added in
da976e9("Phase 4: Codegen refactor") shipped in v0.5.0. The warning is from codegen, not the typechecker. Severity revised Low → Medium for the latent UAF angle.
Added
test_gc_let_alias.reefregression test exercising the string-chainlet t = spattern under deliberate allocation pressure.--emit-cof this test now showstin the__reef_localsstruct (i.e., precisely tracked by the GC).- Warning on
infer_expr_typeCall-arm silent fallback atcodegen_analysis.ml:247— the sibling site of the Var-arm fallback at line 196. Fires when a Call's callee is in scope but not resolvable as an FnBinding/GenericFnBinding/lambda. Currently unreachable across the entire test suite; lands as a passive guard against future builtin/FFI binding propagation gaps.
Internal
- Plan used the Level 2 (tactical fix + audit) approach. Audit identified
three follow-up tasks (filed as deferred work):
ForEach (idx, elem, coll, body)doesn't addelemtoctx.type_envin either pass. Same family of bug forfor x in [string]patterns under allocation pressure.Matchpattern bindings (Some(x) ->) need a separate trace to confirm they reachctx.type_envvia the gen_stmt match arm.- ~22 remaining silent
-> TIntfallbacks ininfer_expr_type(mostly nested FieldAccess paths) warrant categorisation by reachability + can-this-be-GC-typed.
Final suite
- Compiler: 377 passed / 0 failed / 22 skipped (was 376 — +1 for the new regression test).
- Runtime: 38 / 38 tests passing.
0.6.0 — 2026-05-19
Unified GC overhaul. Reef now has precise stack marking via per-thread shadow-frame chains, canonical user pointers (no interior-pointer slow path), and cooperative stop-the-world (SIGURG tickle instead of async-signal-based suspension). The transitional conservative stack scan that bridged the rollout is gone. Closes BUG-053 and BUG-054.
This is the largest single release since 0.5.0 (41 commits, ~300 net lines removed from the runtime). Compiler ABI is unchanged for hosted programs that don't reach into the runtime header; downstream consumers that provided their own conservative-scan implementations need the migration steps in the downstream notes below.
Added
- Precise stack root marking (Phase F). Every Reef-generated function
emits a
__reef_framecontaining areef_gc_frame_hdrand avoid** roots[]array, linked through a__thread reef_gc_frame_hdr* __reef_gc_topchain.reef_objects_scan_all_stackswalks the chain for every registered process (using the GC-calling thread's live top and each parked thread'sprocess->gc_top_at_safepointsnapshot). - Cooperative stop-the-world. New
reef_objects_gc_safepointparks the calling thread ongc_resume_condafter publishing its chain top and incrementinggc_acked; the GC thread waits forgc_acked == target_countbefore scanning. Quantum-check sites and a SIGURG tickle path replace the old async-signal-based STW. REEF_HEAP_BLOCK_SIZEenv hook for test-driven heap-expansion scenarios (default 64 MB). Used byexamples/test_gc_heap_expansion.reefto force many expansions cheaply.REEF_GC_STRICTenv var to abort (instead of log) on heap-invariant violations during GC.- Two new regression tests that lock in the precise-GC paths:
test_gc_nested_concat.reef(case 01 — nested string concat) andtest_gc_ao_precise_chain.reef(Gate A — AO worker chain publish). - C-side regressions for BUG-054 in
reef-runtime/tests/test_heaps.cdrive the region-list lookup directly without depending on what addressesmmapreturns.
Fixed
- BUG-053: interior-pointer GC vs. array stack temporaries — closed
by Phase A's canonical pointer layout. Every user pointer is now at
block_base + REEF_HEAP_BLOCK_OFFSET, whereblock_baseis aligned toBLOCK_SIZE. Thefind_containing_objectslow path and the interior-pointer recovery it depended on are gone (also a slow-path source of BUG-034-class re-corruption). - BUG-054: heap-expansion clobber in
allocate_from_region— the allocator used to assumeinsert_memory_blockappended to the tail of the region list and re-acquired the region with a tail walk. Sinceinsert_memory_blocksorts by start address, the new region is not necessarily at the tail; mmap returning a lower address would make the tail walk return the wrong region and clobber an unrelated block's metadata. Fixed by introducingreef_heaps_find_memory_block_by_startand looking up bybegin_block_adr. - C6 case 01 (nested GC-allocating call args) — codegen for
reef_string_concatoperands now lifts both args into chain-rooted slots in a transient__nest_framebefore the call. Closes the unrooted window where an inner concat's result sat only in the outer call's argument slot during the outer call's allocation. Affectedtest_module_array_init_canonical,test_gc_struct_stress, andtest_toml_sizedwhen precise marking ran without the safety net.
Removed
- Transitional conservative stack scan (C6.4). The four runtime
symbols
reef_heaps_register_candidate,reef_heaps_scan_memory_range,reef_heaps_check_candidates,reef_heaps_clear_candidatesare deleted, along with the candidate array,REEF_MAX_CANDIDATES,REEF_MAX_OBJECT_SIZE, andreef_heaps_is_valid_heap_pointer. The// Conservative Stack Scanning (transitional…)section ofreef_heaps.his gone. Precise marking is the sole source of stack roots; there is no longer a safety net. get_current_sparch-specific inline-asm helper — was only used by the conservative scan.- Signal-based STW.
reef_machine.c's old SIGUSR1/SIGUSR2 thread- suspension path is replaced by the cooperative protocol; signal handlers and the associated state are gone. find_containing_objectand the interior-pointer recovery slow path (BUG-053 root cause).reef_heap_block_t.nextfield renamed to.length(BUG-053 prep — the field stored array length all along; the name was misleading and contributed to the bug).
Changed
- Active Object direct root in
reef_objects_find_rootsnow marks viareef_heaps_mark_root(process->obj)instead of going through the (now-deleted) candidate API. reef_heaps_new_array/new_ptr_arraywriteblock->lengthat allocation time;traverse_mark_stackreads it from the header instead of an interior word. Removes one source of interior-pointer arithmetic.- Coalesced-into block headers are scrubbed with a DEBUG_VALUE pattern (A2-style) so any stale traversal sees a recognisable poison value instead of stale type pointers.
Internal
- Phase F design with seven sequential phases (A–G).
- C6 plan with three verification gates (AO chain audit, FFI boundary, downstream consumer) all closed before C6.4 landed.
- C6 case catalog — the empirical precise-marking-gaps catalog. Only one category surfaced (nested GC call args); BUG-054's predicted ArrayLit/MapLit/ match-scrutinee/closure-capture patterns didn't regress under the hook.
- Liveness analysis scaffold and stack-map data structures in the
runtime (
reef_machine.cregistry) and compiler (liveness.mlstub) — placeholders for the future move from per-frameroots[]lists to fully-precise per-PC stack maps. Not used in 0.6.0; lays groundwork for a later milestone. REEF_DISABLE_CONSERVATIVE_SCANenv hook (C6.1) was introduced and then deleted in the same release once C6.4 made it redundant. Its job was to surface precise-marking gaps empirically during the campaign.
Downstream / migration notes
- Freestanding consumers with their own runtime fork: adopting v0.6.x is a one-time runtime port (chain TLS + safepoint publish + walker) — the only substantial step; the C6.4 header deletion needs a small consumer-side compat header for the four removed prototypes.
- Hosted consumers (illumos/Linux) link the hosted runtime and pick up the changes automatically. No source changes needed.
0.5.21 — 2026-05-13
Closes BUG-052, RFE-003, and RFE-004 — all reported by a downstream OS
project. Also consolidates six private copies of int_to_str /
int_to_string across stdlib encoding/net modules.
Fixed
- BUG-052: module-level
new [T](n)produces invalid C static initializer — Codegen now detects non-constant module-level initializers (calls,new, struct literals, interpolated strings, etc.) and defers them to a runtime call sequenced AFTERreef_heaps_initand GC root registration, BEFORE the user'smain. Works across imported modules. Also corrected the--no-gcpath to callreef_heaps_initand emit deferred inits when needed.
Added
- RFE-003: hex / octal / Unicode escape sequences in string literals
— Both regular and triple-quoted strings now support
\xNN(2-digit hex byte),\NNN(1–3 digit octal byte), and\u{N+}(Unicode codepoint, UTF-8 encoded; rejects surrogates and codepoints > 0x10FFFF). - RFE-004:
str.from_intandstr.to_int—core.strgainsfrom_int(n: int): string(lifts the common pattern across stdlib; INT_MIN-safe) andto_int(s: string): ResultInt(strict — rejects empty input, non-digits, trailing garbage, and integer overflow). The pre-existingcore.convert.to_string/core.convert.toIntare unchanged; seedocs/core/str.mdfor when to use which.
Refactored
- Six stdlib modules (
encoding/json,encoding/yaml,encoding/html,encoding/toml,net/http,net/extras) no longer carry private copies ofint_to_str/int_to_string; they now callstr.from_int. Four of those modules also drop privateappend_stringorstr_appendhelpers in favor ofstr.concat. Net: ~280 lines removed across stdlib.
Internal
- New codegen-context field
module_initializers : (string list) ref, parallel togc_root_registrations, accumulates deferred initializers across all imported modules. - New helper
is_c_constant_initializerin codegen.ml detects which AST forms can serve as C static initializers and which must be deferred.
Reporter credit
- BUG-052, RFE-003, RFE-004 reported by a downstream OS project (originating from boot-UI visual-pass work).
0.5.20 — 2026-05-04
Closes BUG-051 — the broader gap surfaced during BUG-050 (v0.5.19): every typecheck class except struct-literal validation was silently skipped for imported module bodies. The broad-fix attempt in v0.5.19 surfaced ~49 latent stdlib issues; this release audits and resolves all of them.
Fixed
- BUG-051: full typecheck on imported module bodies — Driver
(
bin/reefc.ml) now runscheck_module_with_importson every loaded module rather than just the entry point. Catches return- type mismatches, undefined functions, argument-count mismatches, field-assignment type errors, and every other typecheck class for imported module code at compile time instead of runtime/link-time.
Typechecker loosenings
The strict baseline was preventing routine stdlib patterns that were silently passing only because imported bodies were never checked. Each loosening is an explicit, defensible language-design choice that matches C/Rust semantics where applicable:
nilin==/!=outside unsafe — comparison itself is safe; only deref does.if conn != nilno longer requires unsafe.- Bitwise ops on byte/uint8 with int via integer promotion —
routine in crypto code:
byte_val & 255,byte_val | 0x80. - Unary negation extended to all signed numeric types — int8/ int16/int32/int64, plus float/float32/float64. Was int-only.
- Numeric
ascasts no longer require unsafe — was forcing wrappers likeunsafe { value as int }for routine int64→int narrowing. Float and char casts also accepted. - Implicit widening int→int64 / int32 — no information loss.
progress.elapsed_ms = 0works without0i64literal. - Comparison ops allow any int-pair / float-pair — e.g.
elapsed_ms < 1000where elapsed_ms is int64. - Char compares against ints — routine ASCII checks like
c >= 65 and c <= 90. - Char ↔ uint8 coercion in unsafe — both are single bytes; stdlib byte/string interop.
- Arithmetic ops use C-style integer/float promotion —
int * int64 → int64(wider type wins). Mirrored incodegen_analysis.infer_expr_typeso untypedletbindings and string interpolation format specifiers infer the same wider type (otherwise silent narrowing in C output). reef_array_data_ptradded to typechecker builtin table — function is auto-emitted as inline C by codegen but the binding was missing, causing undefined-function errors in stdlib io/file and crypto/sha256 once their bodies were typechecked.
Stdlib fixes (genuine bugs surfaced by the broad typecheck)
encoding/toml.reef:352:let digit_char→mut digit_char(the variable is reassigned in the unsafe block immediately below; pre-fix the bug masked itself because the body was never checked).io/file.reef:170,181:return []→return new [byte](0)— empty array literal can't be element-type-inferred from return context with current typechecker.net/http.reef:2516,2520: restructured a uint8/int mix where the new arithmetic promotion would have triggered narrowing- assignment errors on every iteration (case-folding loop).
Test infrastructure
tests/run_tests.sh: routed EXPECT_FAIL files in compiler-examples/ throughtest_checkinstead of skipping them. Previously every EXPECT_FAIL file underreef-compiler/examples/was silently unrun, masking missing-assertion bugs.
Tests
tests/cross_module/bug051/: 2 negative tests verifying full typecheck fires on imported modules (return-type mismatch, argument-count mismatch).examples/test_bug_051_loosenings.reef: 9 positive assertions covering each loosening — including the exact case that caught the pre-shipping codegen-analysis blocker (int + int64→ must promote to int64 in BOTH typechecker AND infer_expr_type).examples/test_bug_051_narrowing_rejected.reef: negative test locking the safety boundary — int64 → int withoutasmust still error.
Internal
- Suite: 362 → 368 passed (+6 from new tests). 1 pre-existing GC stress flake; 22 skipped (test_save_png in -lz group from v0.5.14).
- Code review caught a critical typechecker/codegen-analysis divergence in arithmetic promotion (Blocker 1) before tagging — the shipped fix mirrors the promotion logic in both passes.
0.5.19 — 2026-05-04
Single-bug release closing BUG-050 (filed by repoman v0.1.1 against v0.5.18). High-severity silent-acceptance bug: the typechecker's missing-field check on struct literals fired correctly in single-file mode but was skipped when the literal was inside an imported module's function body. Build passed; the uninitialized field caused runtime segfaults when read.
Fixed
-
BUG-050: missing-field check on struct literals skipped inside imported module bodies —
check_module_with_importsvalidates ONLY the bodies of the module it's called on. Imported modules' types/signatures are loaded into namespaces (so the consumer can reference them), but their function bodies — including struct literals inside those bodies — were never typechecked. An incompleteOuter { ... }literal in an imported module compiled cleanly, and the consumer segfaulted at runtime when the uninitialized field was read.Fix: new
Typechecker.validate_struct_literals_with_importspass that walks every imported module's expressions looking specifically forStructLitnodes and runs the duplicate/unknown/missing field checks. Uses a minimal context (module's own type defs + imported type names — just enough forlookup_bindingto find the struct) rather than invoking the full typechecker. The full-typecheck approach was attempted and reverted because it surfaced ~49 unrelated pre-existing strictness issues in stdlib modules that had been silently passing because their bodies were never checked.Driver change in
bin/reefc.ml: after the main module's full typecheck, iterate every loaded module viaHashtbl.iterand run the focused validator.Walker covers: StructLit, Call, GenericCall, QualifiedGenericCall, GenericConstructor, New, NewGeneric, NewArray, BinOp, UnOp, If, Match (expression form), ArrayLit, ArrayAccess, ArraySlice, FieldAccess, Spawn, TypeTest, TypeCast, TypeName, TypeId, ToMap, SetMember, SetLit, MapLit, InterpolatedString, Lambda. Statement shapes: VarDecl, Return, ExprStmt, Assignment, If, Unless, While, DoWhile, DoUntil, Loop, UnsafeBlock, Defer, Await, For, ForEach, Match (statement form) — the last three were added in code review followup after empirical confirmation that data-construction patterns inside
for x in arr/for i in 1 to N/match ... end matchwere the most likely real-world bug sites and would have silently compiled as initially shipped.
Test infrastructure
tests/run_tests.shcross-module runner now supports the// EXPECT_FAIL: <substring>marker (mirrors the existing single-file negative-test mechanism). Used by BUG-050's four negative tests.
Known follow-up
- BUG-051:
the BUG-050 fix is targeted at the struct-literal bug class. Every
OTHER typecheck (return type mismatches, field-assignment type
errors, undefined variables, etc.) is still skipped for imported
module bodies. The broader fix needs a stdlib audit first to
resolve the ~49 latent strictness issues the attempted broad fix
surfaced. Workaround until then: library authors should run
reefc <module>.reefdirectly to typecheck their module bodies in single-file mode.
Internal
- Suite: 357 → 362 passed (+5 from new tests in
tests/cross_module/bug050/: 1 positive + 4 negatives covering outer scope, ForEach, For, and Match-statement bodies). 1 pre-existing GC stress flake; 22 skipped (test_save_png in -lz group from v0.5.14).
0.5.18 — 2026-05-02
Single-bug release closing BUG-049, a regression introduced by v0.5.17's
new process_run wrapper. Same family as BUG-042 (struct/AO method
short-name collision) but on a different resolver code path.
Fixed
-
BUG-049: parameter named
args(or any module name) silently resolved as the module, not the parameter — When v0.5.17 addedprocess_run(program, args: [string])to fix BUG-048, the parameter nameargscollided with the commonimport sys.args as args(orimport sys.args) pattern. The resolver matchedargsagainstmodule_namespacesBEFORE checking whetherargswas a local/ parameter binding, soargs.length()insideprocess_run's body emittedreef_sys_args_length()(which doesn't exist), breaking every consumer's C compile as soon as they importedsys.process.Fix combines two changes:
-
Resolver gate (the principled fix) — extracted
Types.binding_shadows_namespacehelper applied uniformly at 12 sites acrosslib/typechecker.ml,lib/codegen.ml, andlib/codegen_analysis.ml. The helper returnstruefor any binding kind that should shadow a module of the same name:VarBinding,ConstBinding,ModuleVarBinding, andModuleMutBinding. Without this, module-level mutable variables and constants could also silently misresolve. -
Stdlib rename (defense in depth) —
process_run/process_run_execparameterargs→arg_list. Strictly redundant after the gate but reduces the collision surface for the most common case.
Test fixture update:
examples/test_path.reefhad a local varpathshadowing the importedio.pathmodule — pre-fix the resolver let the module name win silently, post-fix the local correctly shadows. Renamed local top(the test was relying on the buggy behavior).New test
examples/test_bug_049_param_shadows_module.reefexercises parameter, local, and module-level const shapes with collision- prone names (args,str). -
Internal
- Code review identified and addressed three gap classes the initial
fix missed: ungated sites in
codegen_analysis.ml, missingModuleVarBinding/ModuleMutBindingin the codegen gate, and an ungatedNew (type_name, args)arm in the typechecker. The shared helper resolves the asymmetry by giving all sites a single source of truth for the shadowing predicate. - Suite: 356 → 357 passed (+1 from new BUG-049 regression test, +0 net from the test_path fixture rename). 1 pre-existing GC stress flake; 22 skipped (test_save_png in -lz group from v0.5.14).
0.5.17 — 2026-05-02
Single-bug patch release closing BUG-048 (filed by repoman v0.1
against v0.5.16). API ergonomics fix for sys.process — the existing
spawn primitives followed raw POSIX execve(2) semantics but the
parameter name args implied the Python/Go convention, leading to
confusing first-time-user errors.
Added
sys.process.process_run(program, args)— high-level wrapper that auto-prependsprogramasargv[0], matching Pythonsubprocess.run/ Goexec.Command/ Nodechild_process.spawnconvention. Use this for typical spawning; the runtime handles the argv[0] convention for you.sys.process.process_run_exec(program, args)— exec variant (replaces current process; does not return on success).
Changed
-
process_spawnandprocess_execparameterargs→argv. These remain POSIX-purist primitives (caller supplies the full argument vector). The rename makes the convention visible at the signature; tightened docstrings spell out: argv[0] is conventionally the program name; the runtime does NOT prependprogramfor you. Preferprocess_run/process_run_execfor typical use; reach forprocess_spawn/process_execonly when you specifically need to override argv[0] (busybox-style multi-call binaries, argv[0]-sniffing tools, etc.).Backward-compatible: Reef has no keyword arguments, so the parameter rename is purely cosmetic at every call site. Existing callers that used the working POSIX form
process_spawn("stat", ["stat", arg])continue to work unchanged.
Internal
- Code review verified that the runtime's
execvpcall passes the argv array through directly (no wrapper transformation), so the documented behavior matches actual emission. - Suite: 355 → 356 passed (+1 from
test_bug_048_process_run.reef, which exercises bothprocess_runshapes and the preservedprocess_spawnPOSIX form). 1 pre-existing GC stress flake; 22 skipped (test_save_png in -lz group from v0.5.14).
0.5.16 — 2026-05-01
Single-bug patch release closing BUG-047, the qualified struct literal
parser asymmetry filed by repoman v0.1 against v0.5.13. The v0.5.15
attempt was deferred after a naive fix introduced an LR(1) state-90
conflict that broke all obj.field access; this release ships the
researched-and-tested fix.
Fixed
-
BUG-047:
module.Type { ... }struct literal syntax error —lib.Foo { x: 1 }previously failed to parse with a generic syntax error; only the bare formFoo { ... }(with optional qualified type annotationlet f: lib.Foo = ...) worked. Fix inlib/parser.mly: add the rule atpostfix_exprlevel rather thanprimary_exprlevel —| obj = postfix_expr LBRACE fields = ... RBRACE { match obj.expr_desc with | FieldAccess ({ expr_desc = Var _; _ }, type_name) -> make_expr (StructLit (type_name, fields)) ... | _ -> failwith "..." }This works because the qualifier (
lib.Foo) reduces through the existingpostfix_expr DOT IDENTFieldAccess path before the LBRACE disambiguation, leaving state 90 (the conflict site of the failed v0.5.15 attempt) untouched. Out-of-scope shapes (e.g.obj.method() { x: 1 }) are rejected with a clear parse-time error.The bare workaround pattern
let b: lib.Foo = Foo { x: 2 }continues to work for backward compat. Plainobj.fieldaccess — which the v0.5.15 attempt accidentally broke — works as before.
Investigation notes
The v0.5.15 attempt placed the rule at primary_expr level as
IDENT DOT IDENT LBRACE field_init_list RBRACE. Menhir reported one
new shift/reduce conflict in state 90 (after reading just IDENT):
adding a shift on DOT action suppressed the reduce IDENT → primary_expr action that the existing FieldAccess rule depends on,
breaking ALL obj.field expressions. Menhir's default "shift wins"
resolution is what triggered the regression.
Approach validated against the OCaml compiler's own grammar (which
sidesteps the issue with Module.{ ... } syntax — adopted as Reef's
direct fix would have required a syntax change), Menhir documentation
on shift/reduce conflicts, and Rust/Go's contextual-parser-state
techniques (not applicable to LR(1)).
Test: tests/cross_module/bug047/ exercises qualified head, bare
workaround, and plain field access (locks down the no-regression
contract).
Internal
Suite: 354 → 355 passed (+1 from new regression test). 1 pre-existing GC stress flake; 22 skipped (test_save_png in -lz group from v0.5.14).
0.5.15 — 2026-05-01
Two-bug patch release closing BUG-043 (filed during BUG-042 work in v0.5.13) and BUG-046 (filed by repoman v0.1 against v0.5.13). A third filed bug, BUG-047 (qualified struct literal syntax), was attempted but deferred — the parser fix requires more careful design than this batch could accommodate; investigation notes are in the bug record.
Fixed
-
BUG-046: cross-module monomorphization fixpoint missing in driver — When module B's body registered an instantiation of a generic defined in module A, but A had already been monomorphized before B was scanned, A's specialization was never emitted. Concrete failure: a consumer importing a module that exports a
Result[T, E]-returning function would fail to compile (with C errors likeunknown type name 'Result_Foo_string') if the consumer never called the function — the producer'smake_ok()body referencedResult_Foo_stringbut the typedef was missing because nothing triggered the cross-module emission. Fix inbin/reefc.ml: wrap the per-modulemonomorphize_moduleloop in an outer fixpoint that re-iterates over every module untilcount_instantiationsis stable. 50-iteration safety cap with a warning. New regression test attests/cross_module/bug046/. -
BUG-043: AO method generic return-type resolution gap —
let r = ao.method_returning_generic()generatedint r = AO_method(...), breaking downstream calls to generic helpers likeis_ok/unwrap_ok. Root cause: codegen's per-decl type_env registration setstruct_methods=[]forActiveObjectDeclwhileClassDeclcorrectly populated the methods list. Plain struct methods worked; AO methods fell through to the TInt fallback and any return type whose downstream interpretation mattered (Result, bool, etc.) got a wrong-typed VarBinding. Fix inlib/codegen.ml: mirror ClassDecl's method-registration block for ActiveObjectDecl (also registersinitto keep parity with the typechecker). New regression testexamples/test_bug_043_ao_method_generic.reefexercising int, bool, and Result return types.Side fix:
examples/test_ao_chained_calls.expectedwas documenting the BUGGY behavior — bool method calls printed1/0because bool was silently coerced to int through the TInt fallback. Updated to the correcttrue/false. The test was passing because the bug masked itself.
Deferred
- BUG-047:
module.Type { ... }struct literal syntax error — Low-severity parser asymmetry; clean workaround exists (bare literal with qualified type annotation). The fix attempt added a parallelIDENT DOT IDENT LBRACE field_init_list RBRACErule, but Menhir reported a new shift/reduce conflict that broke the existing FieldAccess(Var, IDENT) path used by the workaround pattern itself. Investigation notes captured for a future release that can revisit the parser carefully.
Internal
- Suite: 352 → 354 passed (+2 from new regression tests). One pre-existing GC stress flake remains;
test_save_pngstill in the-lzskip group (v0.5.14).
0.5.14 — 2026-05-01
Stdlib feature-completeness release. The 2026-05-01 audit across all 21 stdlib packages identified read/write asymmetries (parsers without serializers, loaders without writers) and absent-but-expected functions. Six high-impact gaps are closed in three sprints, plus two pre-existing bugs (BUG-044, BUG-045) surfaced during code review.
Added
Sprint 1 — small wins (4 modules):
encoding/csv:csv_format_line(fields: [string], n: int): string— array-form formatter handling arbitrary widths with the same RFC-4180 quoting/escaping as the existing hardcodedcsv_format_line_2/3/4(which remain for backward compat).encoding/msgpack:pack_float/unpack_float,pack_binary/unpack_binary,unpack_binary_len,is_float/is_binarypredicates. Closes the round-trip gap where pack/unpack pairs existed for nil/bool/int/string/array/map but not float or binary blobs. Usesruntime.reef_float64_to_bitsfor IEEE-754 conversion. Auto-picks bin8/bin16/bin32 by length.encoding/utf8:utf8_validate(s: string): bool— whole-string validator with DFA-style range checks per RFC 3629 (catches overlong, surrogates, invalid continuations, out-of-range codepoints). Common API-boundary check.encoding/ini:IniBuilder+ini_serializewriter with mappings, comments, and value-typed setters (ini_set/ini_set_int/ini_set_bool). Mirrors the TOML builder pattern.
Sprint 2 — parsing pairs (3 modules):
time:time_parse_iso(s: string): Result[time_t, string]— acceptsYYYY-MM-DDTHH:MM:SSwith optional fractional seconds, optional timezone (Z,+HH:MM,-HH:MM), and RFC 3339 tolerance (lowercaset/z, space separator). Fractional seconds parsed but discarded (time_t is 1-sec resolution). Validates leap days using existingdays_in_month(e.g. 1900-02-29 correctly rejected as a non-leap century).time/date:date_parse(s: string): Result[date_t, string]— strictYYYY-MM-DDparser with leap-aware day validation. Rejects shorthand (2026-5-1), wrong separators (2026/05/01), trailing whitespace, and out-of-range months/days.encoding/yaml:YamlBuilder+yaml_serializedocument writer scoped to the JSON-equivalent subset of YAML 1.2. Supports flat mappings, nested mappings (any depth), lists of scalars, lists of mappings, automatic value AND key quoting (closes Sprint 2 review finding), 2-space indent. Out of scope (deferable to v0.5.15+): anchors, aliases, explicit type tags, multi-document streams, block scalars, complex keys, flow style.
Sprint 3 — graphics persistence (2 modules):
graphics/image/png:save_png(filepath, img): boolandencode_png_bytes(img): [byte]— pure Reef encoder using existingcompress.zlib(deflate) andcrypto.crc32. Auto-maps Reef PixelFormat to PNG color type (RGBA→6, RGB→2, gray→0, gray+alpha→4). Always 8-bit depth, no interlace, no palette, filter type None per scanline. CRCs validated byte-perfect against Python'szlib.crc32in code review.test_save_pngjoins the existing-lzskip group in run_tests.sh.graphics/image/bmp:save_bmp(filepath, img): boolandencode_bmp_bytes(img): [int]— uncompressed BMP writer. Auto-selects 32bpp BGRA when image has alpha, 24bpp BGR otherwise. Standard 14-byte file header + BITMAPINFOHEADER + bottom-up rows with 4-byte row padding for 24bpp.
Fixed
- BUG-044:
msgpackhad no entry point for the spec's 0xCF (uint64) and 0xD3 (int64) type codes — values requiring 64-bit representation (timestamps in milliseconds, large counters, file sizes >2GB) couldn't round-trip. Addsmsgpack_pack_int64(value: int64, ...)andmsgpack_unpack_int64(buf, offset): int64. Existingpack_int/unpack_intunchanged (correct for int32-range values). - BUG-045:
yaml_encode_string(legacy per-line emitter) had a hand-rolled needs-quote check that missed many triggers (bool/int/null lookalikes, anchor sigils, flow brackets) AND failed to escape embedded"when it did quote. Now delegates to the sameyaml_value_needs_quoting+yaml_escape_quotedhelpers as the new builder so both API surfaces produce consistent, valid YAML. - Pre-existing loader bugs in
graphics/image/pngandgraphics/image/bmp: both usedfile.readFile(C-string-based, truncates at first NUL byte). Since binary file formats contain NUL bytes throughout headers and pixel data, the loaders were effectively useless for real files. Both now usefile.readBinaryFile. Discovered while writing round-trip tests for the new writers (which obviously couldn't pass otherwise).
Internal
- Test suite: 343 → 352 passing (+9 from new tests across the sprints), plus the existing 22 skipped (test_save_png joined the
-lzgroup). - Two code-review checkpoints (between Sprint 1 and 2, and between Sprint 2 and 3) plus a final review before tagging. Each surfaced small polish items that were addressed before the next sprint started; review process documented for future stdlib batches.
- BUG-043 filed during BUG-042 work (v0.5.13). BUG-044 and BUG-045 filed during this batch's reviews; both fixed in this release.
Roadmap
Medium and low-impact gaps from the audit — full crypto (AES/ChaCha20/Ed25519/RSA), TLS/HTTP server, raw DNS encode/decode, modern compression (zstd/brotli/deflate-raw/snappy), base64 streaming, URL builder, hashmap.values(), udp_recv ergonomics — moved to v0.5.15 per docs/ROADMAP.md §8.1–8.2.
0.5.13 — 2026-05-01
Single-bug patch release: closes BUG-042, the FieldAccess short-name collision noted (and mischaracterized as benign) in the v0.5.12 release notes. Reinvestigation found it was a real misinference: when a struct or AO method shared a short name with a top-level function, monomorph recorded the wrong specialization and the linker failed on undefined symbols at the call site.
Fixed
- BUG-042: FieldAccess Call arms misinfer struct/AO method calls on short-name collision —
scan_expr_for_genericsandinfer_init_typematchedobj.method()against the short-namefn_return_typestable, mis-registering an unrelated top-level function's return type as the method's. Symptom: a call likelet r = box.compute()(whereBox.compute()returnsResult[Banana, string]and a top-levelcompute()returnsResult[Apple, string]) recorded the Apple variant; the typechecker then emitted Banana-variant call sites that referenced never-specialized symbols. Six-phase fix inreef-compiler/lib/monomorph.ml:- Gate both existing
FieldAccess(Var ns, ...)arms withnot (Hashtbl.mem ctx.var_types ns)so the top-level lookup only fires for genuine module qualifiers. scan_fn_body_for_genericsnow seedsvar_typeswith each parameter's type (andselffor methods) so the gate works for parameter qualifiers, not just locals.- New
struct_method_return_types : (string * string, reef_type)table keyed by(struct_or_ao_name, method_name), populated inregister_genericsfor non-generic methods on non-generic structs/AOs. - Complementary FieldAccess arms in both scanners use the new table when
nsis a known local/param of struct/AO type. - Self-qualified arm added separately because
self.method()parses asFieldAccess(Self, ...), notFieldAccess(Var "self", ...). Closes the recurrence path for methods that call other methods on self. - Bonus fix surfaced during cross-module test writing:
infer_expr_type'sNewarm now stripsalias.Typeprefix the same waycodegen_analysis.mldoes, so cross-modulelet box = new lib.Box()resolves to the unprefixed name that matchesregister_generics' keying convention. - Collision warning when two modules register the same
(struct_name, method_name)with different return types — surfaces the same-shape bug one level up instead of silently overwriting. - Four new regression tests: local-var, parameter,
self-qualified, and cross-module shapes.
- Gate both existing
Known follow-up
- BUG-043: AO methods returning generic types (e.g.
exclusive fn make(): Result[Foo, string]) hit a typechecker gap — the call site falls back toTUnknown, emitting unspecialized helpers downstream. BUG-042's monomorph fix correctly registers and emits the AO method specialization, but the typechecker doesn't resolve the call's return type to use it. Plain struct methods work; AO methods need a separate typechecker-side fix. Filed for next release.
0.5.12 — 2026-04-30
Single-bug patch release: closes BUG-041, the cross-module
generic-return-type monomorphization gap surfaced by repoman v0.1
against v0.5.11. Together with BUG-037 (v0.5.10) and BUG-038 (v0.5.11),
the cross-module generic story is now complete.
Fixed
- BUG-041: cross-module generic return type not monomorphized in consumer — When a non-generic function in module A returned
Result[Foo, string](or any generic enum/struct), consumers in other modules failed to compile: the monomorphized type wasn't emitted and the generic helper specializations (is_ok_Foo_string,unwrap_ok_Foo_string, etc.) were missing. Two adjacent fixes inmonomorph.ml:infer_init_typenow handlesCall(FieldAccess(Var ns, fn_name), _)by consultingfn_return_types[fn_name]. Pre-fix, qualified calls without an explicit type annotation left the variable's type asTUnknown, poisoning downstream generic-helper inference.scan_expr_for_generics'sCall(Var)andCall(FieldAccess)arms now fall through tofn_return_typeswhen the called function isn't itself generic. If the return type isTGeneric(name, args),record_instantiationregisters the type for emission.- Both fixes work in concert: A makes downstream type-arg inference work, B captures the underlying type instantiation. Pure additive — no existing emission paths altered.
- New
tests/cross_module/infrastructure runs each subdir-project to catch cross-module regressions in CI. First inhabitant:bug041/mirrors the project layout from the bug report.
Internal
reef-compiler/tests/run_tests.shgained a "Cross-Module Tests" section that compiles and runs everytests/cross_module/<project>/tests/*.reeffrom inside the project root.
Known low-priority follow-up (BUG-042)
The FieldAccess arms in scan_expr_for_generics look up function names by short name across all of fn_return_types / generic_functions. A theoretical short-name collision between a struct method and a top-level function would over-record an instantiation (benign — produces dead C specialization). Not observed in practice; tightening the qualifier check is bundled into the next compiler-correctness batch.
0.5.11 — 2026-04-30
Followups to v0.5.10 from a fresh batch of bugs surfaced by repoman v0.1 adoption, plus a thorough sweep of every deferred review finding from the prior cycle. No items remain on the deferred list after this release.
Fixed
- BUG-038: Monomorphized generic enum with user-struct payload emitted invalid C — When a generic enum like
Result[T, E]was monomorphized with a user-defined struct asT, the generatedResult_Foo_stringreferencedFoo*at the top of the C output (in the imports' emission section) beforeFoowas typedef'd later (in main's emission section). GCC rejected with "unknown type name 'Foo'". Fix: emit forwardtypedef struct Name Name;declarations for every non-generic ClassDecl and ActiveObjectDecl across all loaded modules at the very top of the C output, before any module body. Pure additive — covers the pointer references that monomorphizations need without affecting struct layout. Verified C11-compatible withgcc -Wall -Wextra -Wpedantic -std=c11. Reported byrepoman v0.1build flow. - BUG-038 followup: SumTypeDecl forward decls — Extended
gen_forward_typedefsto also emit forward typedefs for non-generic enum types. Defensive — no current Reef source can construct a "pointer to enum" struct field that triggers the case (struct fields holding enum types emit as values per BUG-036), but the asymmetry was a future-debugging trap. - BUG-039: Parser accepts
@module.Type[args].Variant(args)qualified generic constructor syntax — Pre-fix, only the bare@Type[args].Variant(args)parsed; the module-aliased form failed with a syntax error. Theresult_generic.reefdocstring used the bare form so following the docs worked, but it was inconsistent with Reef's "import + qualify everything" convention. Reported byrepoman v0.1. - BUG-040:
spawnwith enum-typed argument codegen mismatch — ThreeSpawnarms in codegen (Spawn(Call),Spawn(GenericCall),Spawn(QualifiedGenericCall)) generated their spawn-args struct field types via the legacyc_type_name. With an enum-typed argument the struct heldEnumType *but the trampoline call passed by value — same shape as BUG-036, different code path. All three sites switched toc_type_name_with_context. Latent in v0.5.10; the BUG-036 commit had explicitly flagged the closure-capture struct field paths as a follow-up risk if exercised. io.file.fsyncportability — Changed fromO_RDONLYtoO_RDWRso the helper works on macOS and the BSDs (POSIX leavesfsyncon a read-only fd unspecified). Also now reportsclose()errors instead of swallowing them. Behavior-visible: callers fsyncing a file they can read but not write (e.g., mode0444) will now getfalsewhere they gottrueon Linux before — this is a portability fix, not a regression. The directory variant (fsync_parent) intentionally stays onO_RDONLYbecause directories cannot be openedO_RDWR.
Added
io.file.fsync_parent(path)— Completes the crash-safe atomic-write recipe:writeFile(tmp) → fsync(tmp) → rename(tmp, dst) → fsync_parent(dst). Without the trailing parent-directory fsync, a power loss after rename but before the directory entry is durable can leave the destination missing. The file atpathdoes not need to exist; only its containing directory does. New C helperreef_fs_fsync_parentopensdirname(path)withO_RDONLY, callsfsync(), closes. Resolves the HIGH from the v0.5.10 final code review (the docstring had called out the gap honestly but the helper itself didn't ship).io.path.expand_user(p)— Handles~user/fooviagetpwnam;expand_homenow delegates the~usercase here. Cases:~root→/root(viagetpwnam)~root/foo→/root/foo~root/→/root/(preserves directory marker, matchesexpand_home("~/"))~/foo,~,/abs,relative→ same as before (delegates to expand_home behavior)~unknown_user/foo→ passthrough ongetpwnamfailure- New C helper
reef_fs_user_home(name)uses thread-local buffer pattern (matchesreef_fs_uid_name/gid_namefrom BUG-033).
sys.flag.subcommand_parserdeclarative API (deferred Phase 2 of repoman feedback item 5):subcommand_parser()→SubcommandParseradd_subcommand(sp, name, desc)→FlagParser(caller defines flags on the returned parser)dispatch(sp)/dispatch_args(sp, argv)→ matched subcommand name or""sub_error(sp)/sub_usage(sp, prog_name)- Built atop
flag_parser_fromfrom v0.5.10. Per repoman's design answer: no global flags before the subcommand token, no sub-subcommands.
test.framework.assert_contains_string— Now emits aWARN: assert_contains_string with empty needlenotice when the needle is empty. Still passes (matchescore.str.containscontract that empty is contained), but the vacuous assertion is now visible in test output rather than slipping past review.
Changed
encoding.tomlparser deduplication —toml_parse,toml_parse_sized, andtoml_parse_statuspreviously held three near-identical 130-line bodies (the only differences being the entry cap and an optional post-loop scan). Extracted to a sharedparse_toml_entrieshelper; each public function is now a 3-line wrapper. Behavior is byte-for-byte unchanged. Net: −257/+26 lines. Resolves the HIGH from the v0.5.10 Sprint 1 code review.
Internal / development workflow
reef-compiler/tests/run_tests.shexportsREEF_HOMEso tests resolve against the working tree (already shipped in v0.5.10; verified still working).- Cumulative review confirms all
c_type_name→c_type_name_with_contextmigration sites identified by BUG-036 + BUG-040 are now complete; no barec_type_namecalls remain in any path that emits user types.
0.5.10 — 2026-04-29
Stdlib enhancements driven by repoman v0.1 downstream feedback. Adds
encoders for TOML and JSON (closing the asymmetric reader-without-writer
gap), atomic-write primitives in io.file, ergonomic TomlDoc struct
API, subcommand-aware flag parsing, home-directory expansion in
io.path, several new test-framework assertions, and a codegen fix for
Active Object methods that take or return enum types.
Added
core.result_generic— genericResult[T, E]type withis_ok[T,E],is_err[T,E],unwrap_ok[T,E],unwrap_err[T,E],unwrap_or[T,E]. Mirrorscore.option_generic. Construction requires explicit type args (@Result[int, string].Ok(42)) because each variant only carries one of the two type parameters. The type-specificcore.resultvariants (ResultInt,ResultStr, etc.) remain available for backward compatibility.encoding.toml.TomlBuilder— streaming TOML serializer withtoml_builder(),toml_set_string/_int/_bool/_string_array,toml_begin_table/_end_table,toml_array_append_table([[name]]), andtoml_render. Backed bytext.stringbuilder; supports basic-string escaping for",\, newline, tab, CR. Round-trips throughtoml_parse_doc.encoding.json.JsonBuilder— streaming JSON serializer withjson_builder(),json_set_*,json_begin/end_object,json_begin/end_array,json_array_append_*,json_array_begin_object/_arrayfor nested array elements, andjson_render. Comma placement via per-scopehas_itemstack with overflow-safe push/pop bounded byJSON_MAX_DEPTH.encoding.toml.TomlDoc— struct-based parse API:toml_parse_doc(input),toml_parse_doc_sized(input, max_capacity), plustoml_get_doc/_int_doc/_bool_doc/has_key_docwrappers. Bundles(keys, values, count)parallel-array threading into a single value with explicittruncatedflag. Coexists with the existing flat API.encoding.toml.toml_parse_statusandencoding.json.json_parse_status— variants returning-1when the cap was hit and substantive content remained in the input. Trailing whitespace, newlines, and# comments(TOML only) at the cap boundary are treated as clean parses.io.file.rename(old, new)andio.file.fsync(path)— thin wrappers over theruntime.reef_fs_*helpers.renamedelegates to the same C function asfs.ops.rename;fsyncis new (reef_fs_fsyncopens the path, calls fsync, closes). Doc-block notes the parent-directory fsync is not yet exposed for full crash-safe atomic writes.io.path.expand_home(path)— expands~→$HOME,~/foo→$HOME/foo, preserves~/as$HOME/, passes~user/...through (deferred). Returns the input unchanged whenHOMEis unset.io.path.join(a, b)— canonical name;join_pathretained as a backward-compatible synonym.sys.flag.flag_parser_from(args: [string])— alternate constructor that binds the parser to a caller-supplied argv slice. Enables subcommand dispatch without forking the parse loop. Backward-compatible with existingflag_parser()callers.test.framework.TestRunner— seven new exclusive-proc assertions:assert_contains_string,assert_ok_int/_str/_bool,assert_err_int/_str/_bool. (ResultFloatvariants intentionally omitted; add on demand.)- C runtime:
reef_fs_fsync(path)inreef_fs.c.
Fixed
- BUG-036: Active Object methods with enum-typed parameters or return values generated mismatched C —
gen_exclusive_method,gen_shared_method, AO method forward declarations, AO field declarations, AOinitparameters, AO allocator-wrapper init parameters, plain struct method parameters and return types, and AO method body return types all called the legacyc_type_name(which rendersTUserTypeas a pointer regardless of whether it is an enum or struct). Switched all nine sites toc_type_name_with_context, which checksis_enum_typeand emits the value type for enums. Aligns with regular function emission (already context-aware since BUG-026). - BUG-037: cross-module generic functions and types not specialized at qualified call sites —
monomorph.ml's scanner handledCall(Var fn, args)and explicitGenericCall/GenericConstructorAST shapes but had no arm forCall(FieldAccess(Var ns, fn), args), the shape that every cross-module qualified call (lib.peek(b)) parses as. The same gap existed for type annotations:TGenericwas recognized butTGenericQualified(lib.Box[int]) was not. Even when the scanner was extended, the driver inbin/reefc.mlregistered each module's generics only when that module's own monomorphization turn came — too late for call sites in the consumer module. Three-part fix: (a) added aFieldAccessarm toscan_expr_for_generics's Call match; (b) added aTGenericQualifiedarm to the VarDecl type-annotation match; (c) pre-registered generics from all imported modules in the driver before scanning begins. Also extended codegen'sFieldAccesscall-site emission to consultGenericFnBindingfrom the type environment and produce the specialized mangled name (matching the behaviour theCall(Var)arm has had since BUG-026). Unlockscore.option_genericandcore.result_genericfor cross-module use.
Changed
reef-compiler/tests/run_tests.shexportsREEF_HOMEpointing at the working tree, so tests no longer requiremake installto pick up stdlib changes.
Deferred (filed as separate bugs)
subcommand_parserdeclarative API (item 5b in repoman feedback) — Phase 1 (flag_parser_from) fully unblocks repoman; the cobra-style declarative API is a follow-on for v0.5.11.expand_user("~user/...")— deferred; needs agetpwnam-backed runtime helper. Currentexpand_homepasses~user/...through unchanged so adding it later won't break callers.toml_parse_status/toml_parse_sizedparse-loop deduplication — flagged as HIGH in code review; deferred to a focused refactor commit. Adding it under the encoder work would have entangled two large edits in one file.
0.5.7 — 2026-03-11
GC crash fix: interior pointer dereference in conservative stack scanner.
Fixed
- BUG-034: GC crash dereferencing invalid type descriptor from interior pointer —
reef_heaps_is_valid_heap_pointer()had a tautological interior pointer check:REEF_GET_OBJECT(REEF_GET_HEAP_BLOCK(ptr))always equalsptr, so interior pointers (pointers into the middle of a heap object's data) were never rejected. When the speculative block header computation read garbage data from another object, dereferencingblock->type->sizecrashed with SIGSEGV on an unmapped address. Fix: replace tautological check withfind_containing_object()to verify the candidate is a real block boundary, and validate thatblock->typeis not in heap bounds before dereferencing (type descriptors live in data/BSS segment, never on the GC heap).
0.5.6 — 2026-03-10
GC stability fix for large struct array walks + stdlib uid/gid name resolution.
Fixed
- BUG-033: GC crash on large struct array walk — Conservative stack scanner registered every non-NULL stack word as a candidate pointer, overflowing the 4096-entry limit almost immediately. Non-heap values (return addresses, frame pointers, integers) consumed all slots, causing real heap pointers to be silently dropped. Live objects went unmarked and were swept, causing use-after-free corruption. Fix: pre-filter candidates with
reef_heaps_in_heap_bounds()before registering (~95% reduction), increase limit to 65536, add overflow warning. - OOM abort:
reef_heaps_new_object()andreef_heaps_new_array()now abort with diagnostic message instead of returning NULL on allocation failure.
Added
fs.statuid/gid name resolution:uid_name(),gid_name(),file_uid_name(),file_gid_name()— zero-allocation POSIX name resolution usinggetpwuid()/getgrgid()with thread-local buffers. Eliminates need for Reef-level/etc/passwdparsing.- C runtime functions:
reef_fs_uid_name(),reef_fs_gid_name(),reef_fs_file_uid_name(),reef_fs_file_gid_name()inreef_fs.c - GC stress test:
test_gc_struct_stress— 3000 structs with 7 string fields + garbage pressure - Reef test:
test_fs_uid_name— uid/gid name resolution viafs.stat - Streaming I/O reference guide:
docs/language/reference/125_STREAMING_IO.md
0.5.5 — 2026-03-10
Compiler codegen cleanup: suppress all C compiler warnings from generated code.
Fixed
- BUG-032: Unused variables/constants/parameters in codegen — Four categories of C compiler warnings eliminated: (1) module-level
constdeclarations marked__attribute__((unused))(25 warnings), (2) local variable declarations fromVarDeclmarked__attribute__((unused))(8 warnings), (3) function parameters suppressed with(void)param;casts at function entry (1 warning), (4) volatile MMIO stubs marked__attribute__((unused))(1 warning), (5) all builtin helper functions wrapped with#pragma GCC diagnostic ignored "-Wunused-function"(69 warnings). Generated C code now compiles cleanly withclang -Wall -Wextraandgcc -Wall -Wextra.
0.5.4 — 2026-03-09
Bug fix: GC does not trace module-level mut variables as roots.
Fixed
- BUG-030: Module-level
mutvariables not traced by GC — Module-levelmutvariables of heap-allocated types (string, arrays, structs, functions,any) are stored asstaticC globals in BSS/data segment. The conservative stack scanner never reaches them, and they had no type descriptors for tracing. Fix: runtime addsreef_heaps_register_global_root()to register individual global variables as GC roots. Compiler emits registration calls inmain()for each module-levelmutof pointer type. Also fixes the root cause of BUG-031 (GC corrupts arrays during HTTP download with progress callback) —net.extrasmodule-levelmutstrings (bar_url,bar_filename,bar_fill_char) are now properly rooted.
Added
reef_heaps_register_global_root()runtime API for registering individual GC roots- Reef regression test
test_gc_module_mut_roots— module-levelmut stringsurvives GC pressure
0.5.3 — 2026-03-09
Bug fix: GC fails to trace array fields in structs due to interior pointer layout.
Fixed
- BUG-029: Arrays in structs collected while live — Reef's
[T]array layout storesbase+1in struct fields (skipping the length slot at index -1).mark_object()assumed all pointers are exact object starts, soREEF_GET_HEAP_BLOCK(base+1)read garbage from the wrong offset and silently failed. Arrays referenced through struct fields were never marked and got swept. Fix:mark_object()now falls back tofind_containing_object()when the direct block header probe fails validation, handling both thebase+1array trick andarray_type_sentinel.size==0blocks.
Added
- Reef regression test
test_gc_array_in_struct— struct with[string]field survives GC pressure
0.5.2 — 2026-03-08
Bug fix: GC corrupts live heap objects via lazy sweep double-allocation.
Fixed
- BUG-028: Auto-GC corrupts string references —
lazy_sweep()coalesced blocks already on free lists fromlinear_scan_sweep(), causingfind_free_block()to return already-allocated blocks (double allocation). String concat loops produced corrupted output; struct string fields were silently overwritten. Four-part fix: (1) clear free lists when lazy sweep restarts after a GC cycle, (2) move auto-GC trigger to before allocation so no half-initialized block exists during collection, (3) capture stack base frommain()via&argcfor reliable conservative stack scanning, (4) includeTString,TFunction,TAny, andTGenericin codegen type descriptorpointer_offsetsso the GC mark phase traces all pointer fields in structs and active objects.
Added
- Interior pointer support in conservative stack scanner (
find_containing_object) - Heap bounds check in
mark_object()to safely skip.rodatastring literals - Register flushing (
__builtin_unwind_init()) before stack scanning - Reef regression test
test_gc_struct_strings— 100 structs with string fields survive GC - Reef regression test
test_string_concat_loop— 10,000-iteration concat loop (previously corrupted at ~1,370)
Changed
reef_objects_register_main_process()now takesvoid* stack_baseparameter (API change)- All 10 baseline codegen tests regenerated for new
register_main_process(&argc)signature
0.5.1 — 2026-03-08
Bug fix: automatic GC triggering prevents heap allocator degradation.
Fixed
- BUG-027: Heap allocator O(n²) degradation —
reef_string_concat()in a loop hung after ~3,200 iterations due to dead blocks accumulating on free lists without GC ever running. Added three-layer fix: (1) threshold-based auto-GC triggers collection when allocation pressure exceedsmax(bytes_live * 2, 256KB), (2) GC-before-expand safety net tries a GC cycle before growing the heap whenfind_free_block()returns NULL, (3) existing--no-gcflag serves as escape valve. Programs that previously hung now complete in under 1 second.
Added
- C stress test
test_gc_auto_trigger— 50,000 tight-loop allocations verifying auto-GC fires and completes in <10 seconds - Reef regression test
test_string_concat_loop— 10,000-iteration string concat loop
0.5.0 — 2026-03-07
Filesystem module family (RFE-002) — native filesystem operations replacing shell-outs.
Added
fs.statmodule — file metadata and type queries: is_file, is_directory, is_symlink, is_pipe, is_socket, is_block_device, is_char_device, exists, file_size, file_mode, file_uid, file_gid, file_mtimefs.permmodule — permission operations: chmod, chown, lchown, set_executable, set_readonly, is_readable, is_writable, is_executable, plus 12 POSIX constants (S_IRUSR through S_ISVTX)fs.linkmodule — link operations: symlink, readlink, hardlinkfs.opsmodule — file operations: copy_file, copy_file_preserve, copy_symlink, remove_file, rename, remove_tree, copy_treereef_fs.c— new C runtime file consolidating all filesystem operations (~50 functions)- Filesystem operations guide —
docs/language/reference/120_FILESYSTEM_OPERATIONS.md - 6 new compiler tests + 21 C-level unit tests (316 total passing)
Changed
- Filesystem C implementations migrated from OCaml codegen.ml string literals to proper C runtime file
remove_treehas safety checks: refuses "/" and paths with < 2 componentscopy_treehas 256-level depth limit, preserves symlinks
0.4.0 — 2026-03-05
Systems programming enhancements (RFE-001) for a downstream OS project.
Added
sys.fdmodule — file descriptor operations: open, close, read, write, dup, dup2, pipe, set_nonblocking, set_cloexec, plus constants (STDIN/STDOUT/STDERR, O_RDONLY/WRONLY/RDWR/CREAT/TRUNC/APPEND/CLOEXEC)sys.pollmodule — poll(2) event loop: poll_clear, poll_add, poll_wait, poll_readable, poll_writable, poll_error, poll_hangup (64 fd limit, thread-local)net.unixmodule — Unix domain sockets: unix_connect, unix_listen, unix_accept, unix_send, unix_recv, unix_close, unix_unlinksys.processadditions — process_fork, process_spawn, process_exec, process_setsid, process_getpgid, process_setpgid, process_wait_any, process_wait_any_nohang, killpg, umask, exit_nowsys.signaladditions — signal_block, signal_unblock, signal_wait, selfpipe_create, selfpipe_register, selfpipe_read, SIGRTMIN, SIGRTMIN_offsetencoding.tomladditions — toml_alloc_keys_sized, toml_alloc_values_sized, toml_parse_sized; default entry limit raised from 256 to 1024--no-gccompiler flag — disable GC and Active Object initialization--gc-signals rtmincompiler flag — use SIGRTMIN+0/1 instead of SIGUSR1/2 for GC thread suspensionreef_poll.c— new C runtime file for poll(2) wrapper- String array marshaling — reef_string_array_create/free for [string] to char** conversion
- Systems programming guide —
docs/language/reference/115_SYSTEMS_PROGRAMMING.md - 14 new tests (310 total passing)
Changed
- CI builder image now includes clang and lld as default toolchain
- TOML parser default entry limit raised from 256 to 1024
exit_now()flushes stdout/stderr before calling_exit()- GC signals in
reef_machine.cnow configurable viareef_platform_gc_set_signals()
Fixed
- GCC
-Werror=unused-resulton self-pipe write() in signal handler
0.3.5 — 2026-03-04
Fixed
- Cross-module non-exported function return type falls back to int instead of failing (BUG-026)
Changed
- Updated inline assembly docs for
-masm=intelusage and--emit-cworkflow
0.3.4 — 2026-03-03
Fixed
- Operator precedence:
a - b > cparsed asa - (b > c)instead of(a - b) > c(BUG-024) - Separated unary ops into
unary_exprnon-terminal; orderedexprbeforeunary_exprfor correct Menhir reduce/reduce resolution
0.3.3 — 2026-02-28
Fixed
- Inline asm missing register clobbers — codegen now scans substituted asm body for hard-coded register names and adds them to the clobber list (BUG-023)
- Case-insensitive register detection for x86 Intel syntax; ARM64 xN/wN and AMD64 rax/eax deduplication
0.3.2 — 2026-02-26
Added
- Conditional imports with
ifarchblocks —ifarch amd64 ... end ifarchfor architecture-specific module imports (BUG-022)
0.3.1 — 2026-02-25
Added
- Heap allocator: segregated free lists (16 size classes) for O(1) allocation
- ASAN/UBSAN build targets (
make test-asan,make test-ubsan) - Concurrent allocation stress test (
test_concurrent_alloc.c) - Module-level
mutwarning when accessed from Active Object methods - Changelog (
docs/changelog-0.3.1.md)
Fixed
- Heap free list race condition — added
heap_alloc_lockmutex (BUG internal) - AO chained method calls missing class prefix in codegen (BUG-018)
- Await condition functions emitted twice in multi-module builds (BUG-019)
reef_objects_await()never checks condition before sleeping — deadlock fix (BUG-020)- Baremetal TLS global variable causes deadlock with cooperative scheduling (BUG-021)
- 382
-Wparentheses-equalitywarnings in generated C code eliminated
Changed
- Major version bump 0.2.x → 0.3.x reflecting runtime heap allocator rewrite
- Async AO design resolved: use AO composition pattern (A2/Bluebottle model)
0.2.12 — 2026-02-24
Fixed
- AO intra-method calls missing class prefix in codegen (BUG-017)
0.2.11 — 2026-02-24
Fixed
- Duplicate
__reef_quantumdeclaration in multi-module builds (BUG-016)
0.2.10 — 2026-02-24
Added
- Await codegen implementation (BUG-012)
- Cooperative yield points via
--quantum-checksflag (BUG-014)
Fixed
- Baremetal AO stub signatures (BUG-013)
- Platform abstraction layer for AO scheduling (BUG-015)
0.2.9 — 2026-02-24
Fixed
- Widened ticks_increment test tolerance for slow platforms (RISC-V, QEMU)
0.2.8 — 2026-02-24
Added
- C toolchain selection: clang/lld as defaults with
--cc/--ldflags,CCenv var, and config file support
Fixed
- Inline asm codegen — parameter substitution and register handling (BUG-011)
reefc doctoroutput clarified: shows Active vs Available for compiler and linker
0.2.7 — 2026-02-24
Fixed
- Pointer cast crash in codegen
- Module constant namespacing in C output (BUG-010)
- Large hex literal parsing
0.2.6 — 2026-02-23
Fixed
- 7 baremetal bugs: volatile MMIO access, spinlock dispatch, firmware bindings (BUG-001 through BUG-007)
0.2.5 — 2026-02-23
Added
- Platform/target info to
reefc infoandreefc doctorcommands
Fixed
- Build output binary naming
0.2.4 — 2026-02-23
Added
- RISC-V 64-bit baremetal target (
riscv64-baremetal) - AArch64 baremetal target (
arm64-baremetal) - Script-friendly
--versionoutput
0.2.3 — 2026-02-22
Added
- Array methods:
append,pop,remove(immutable-style, return new arrays) - For-each with index:
for i, item in collection - For loop step:
for i in 0 to 100 step 10 - Unsafe modules:
unsafe module ... end module(bounds checks suppressed module-wide) - Trait method dispatch Phase 1: concrete type dot notation
- Trait method dispatch Phase 2: generic function dispatch via monomorphization
- Baseline tests (codegen comparison) and negative tests (EXPECT_FAIL)
Fixed
- Runtime signal handler async-safety
- Lock NULL checks in runtime
- libc FFI modules: extern "C" declarations use actual C names
Changed
- Cleaned up roadmap: resolved deferred features and runtime stubs
- Replaced failwith/Unknown placeholders with proper error handling throughout compiler
0.2.2 — 2026-02-21
Added
- Do-while and do-until loops:
do while cond ... end do,do until cond ... end do
Fixed
- Timer thread: replaced 1ms polling with condition variable sleep
- Unsafe bounds check suppression
- Overhauled test infrastructure
Changed
- Added trait method dispatch roadmap
0.2.1 — 2026-02-20
Added
- Complex function call expressions (call-through)
- Multi-level module qualification (
module.EnumType.Variant) - Exhaustiveness warning when wildcard hides enum variants
Fixed
mangle_typegaps for several type combinations- Active Objects codegen: use lock/unlock instead of thread-per-call
- GC-managed closures (was leaking with malloc)
- Bounds checking on array/string access
Changed
- Added O(n^2) concatenation warnings to
str.concatandstr.joindocs
0.1.16 — 2026-01-30
Added
- Callback-based download progress reporting
- Compiler usage guide and man page for reefc
- Program exit codes section in BASICS guide
Fixed
- UTF-8 character handling in string literals
- Closure symbol naming collision across modules
0.1.15 — 2026-01-29
Added
exit()builtin for program exit codesfn main(): intfor returning exit codes from main- CLI tools updated to use proper exit codes
0.1.14 — 2026-01-28
Fixed
sha256_file()hanging on large files (>12MB)
0.1.13 — 2026-01-28
Added
- Streaming SHA256 API
Fixed
- Infinite loop in HTTP chunked transfer encoding decoder
- HTTP chunked encoding parsing
0.1.12 — 2026-01-27
Changed
- Renamed config file from
reefc.tomltoreefc.conf
0.1.11 — 2026-01-27
Patch release (no notable changes).
0.1.10 — 2026-01-27
Patch release (no notable changes).
0.1.9 — 2026-01-27
Changed
- Consistent version banner across all reefc commands
0.1.8 — 2026-01-26
Added
- Binary distribution packaging (
package-binary.sh) - Binary installation instructions in README
Fixed
- Binary distribution layout for out-of-box experience
- HTTP redirects and streaming downloads
- TLS reliability improvements
0.1.7 — 2026-01-25
Added
- CI pipeline
reefc doctorcommand for installation diagnostics- HTTPS support in
net.httpmodule - TLS/SSL support with bundled LibreSSL 4.2.1
- illumos and FreeBSD build support
- X11 backend for graphics
- OS development support (
reef-baremetal/,reef-os/) - Editor support and VS Code extension
- Standard library integration
- Source packaging script
Fixed
- illumos TLS hang
- illumos build: added
__EXTENSIONS__forIPV6_V6ONLY - X11 backend for composited desktops (XWayland)
Removed
- Wayland support (broken)
0.1.0 — 2026-01-25
Initial release.
- OCaml compiler with lexer, parser, type checker, C code generation
- C runtime with Active Objects, GC, heaps
- Standard library: core types, collections, I/O, networking, crypto
- 261 compiler test files
- Full CLI: build, run, new, init, clean, doc, info